Skip to content

Loading…

HH-32429 (HH-31965) xml testing helpers, XmlResponseTestCase mixin and tests for them #40

Merged
merged 5 commits into from

3 participants

@maizy
HeadHunter member

Добавил фукции для сравнения XML:

  • на равенство (almostEquals): допускается только наличие доп. атрибутов
  • на "совместимость": допускается наличие доп. тагов и атрибутов

Также сделал Mixin для unittest.TestCase с двумя asserts на основе выше указанных функций и тесты на этот mixin.

Пример использования есть в тесте tests/test_testing_test_utils.py, по нему можно понять, какие xml считаются совместимыми.

UPD:
Задача вынесена в подзадачу HH-32429, для выпуска в релизе frontik
При этом весь код в ветке HH-31965

@katraev

@hamilyon @AndreySumin вы делали базовую тестовую инфраструктуру - посмотрите, может будет интересно
@curlup yo

@katraev katraev commented on an outdated diff
frontik/testing/test_utils.py
((184 lines not shown))
+def remove_xpaths(elem, xpaths):
+ """
+ Remove element that matches xpath from it's parent.
+ """
+ for x in xpaths:
+ res = elem.xpath(x)
+ if len(res) == 0:
+ continue
+ for e in res:
+ parent = e.getparent()
+ if parent is not None:
+ parent.remove(e)
+ return elem
+
+
+class XmlResponseTestCaseMixin:
@katraev
katraev added a note

Тогда уж все классы от object? хоть это и mixin

@maizy HeadHunter member
maizy added a note

fixed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@katraev katraev commented on the diff
frontik/testing/test_utils.py
((75 lines not shown))
+ if attrib not in xml1.attrib:
+ reporter('xml2 has an attribute xml1 is missing: {attrib} (path: {path})'
+ .format(attrib=attrib, path=_describe_element(xml2)))
+ return False
+ if not _xml_text_compare(xml1.text, xml2.text):
+ reporter('Text: {t1} != {t2} (path: {path})'
+ .format(t1=xml1.text.encode('utf-8'), t2=xml2.text.encode('utf-8'), path=_describe_element(xml1)))
+ return False
+ if not _xml_text_compare(xml1.tail, xml2.tail):
+ reporter('Tail: {tail1} != {tail2}'
+ .format(tail1=xml1.tail.encode('utf-8'), tail2=xml2.tail.encode('utf-8'), path=_describe_element(xml1)))
+ return False
+ return True
+
+
+class __DownstreamReporter(object):
@katraev
katraev added a note

чем этот callable класс отличается от переменной?

@maizy HeadHunter member
maizy added a note

Мне в https://github.com/hhru/frontik/pull/40/files#L1R168 нужно получить последнюю ошибку в рекурсивных вызовах внтури цикла и её отрепортить в переданный reporter callback.

Там такой алгортим, что нормально когда нескоторые xml_check_compatibility в цикле ниже (https://github.com/hhru/frontik/pull/40/files#L1R170) возвращают false и соотвественно вызывают reporter callback, нужно чтобы хотябы один из них совпал. Если не один не совпал я пишу ошибку с последним из вариантов, который пробовал.

Вроде бы переменной сделать такое не удасться.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@maizy
HeadHunter member

По окончанию review отпишусь во внутреннем блоге про эти инструменты.

@SuminAndrew SuminAndrew commented on an outdated diff
frontik/testing/test_utils.py
((17 lines not shown))
+
+# ----------------------------------------------------
+# XML comparing helpers
+
+
+def _describe_element(elem):
+ root = elem.getroottree()
+ if not root:
+ return '? [tag name: {0}]'.format(elem.tag)
+ else:
+ return root.getpath(elem)
+
+
+def _xml_text_compare(t1, t2):
+ if not t1 and not t2:
+ return True
@SuminAndrew HeadHunter member

А есть смысл в первых двух строках?

@maizy HeadHunter member
maizy added a note

Да, никакой.

И две строчки ниже также имели мало смысла.

fixed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@katraev

с моей стороны все ок.

@maizy maizy merged commit 69e32b2 into master
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Showing with 470 additions and 2 deletions.
  1. +10 −0 .gitignore
  2. +227 −2 frontik/testing/test_utils.py
  3. +233 −0 tests/test_testing_test_utils.py
View
10 .gitignore
@@ -17,3 +17,13 @@ dist/
frontik.egg-info/
frontik.iml
frontik_dev.cfg
+# OS X junk
+.AppleDouble
+.DS_Store
+Icon?
+.Spotlight-V100
+.Trashes
+# Windows junk
+ehthumbs.db
+Thumbs.db
+Desktop.ini
View
229 frontik/testing/test_utils.py
@@ -1,9 +1,234 @@
# -*- coding: utf-8 -*-
-import lxml
+
import StringIO
+import itertools
+
+import lxml
+from lxml import etree
+
def pretty_print_xml(xml):
parser = lxml.etree.XMLParser(remove_blank_text=True)
tree = lxml.etree.parse(StringIO.StringIO(lxml.etree.tostring(xml)), parser)
- print lxml.etree.tostring(tree, pretty_print = True)
+ print lxml.etree.tostring(tree, pretty_print=True)
+
+
+# ----------------------------------------------------
+# XML comparing helpers
+
+
+def _describe_element(elem):
+ root = elem.getroottree()
+ if not root:
+ return '? [tag name: {0}]'.format(elem.tag)
+ else:
+ return root.getpath(elem)
+
+
+def _xml_text_compare(t1, t2):
+ return (t1 or '').strip() == (t2 or '').strip()
+
+
+def _xml_tags_compare(a, b):
+ # step 1 - cmp tag name
+ res = cmp(a.tag, b.tag)
+ if res != 0:
+ return res
+
+ # step 2 - cmp attribs
+ res = cmp(dict(a.attrib), dict(b.attrib))
+ if res != 0:
+ return res
+
+ # step 3 - cmp children
+ a_children = a.getchildren()
+ b_children = b.getchildren()
+ a_children.sort(_xml_tags_compare)
+ b_children.sort(_xml_tags_compare)
+ for a_child, b_child in itertools.izip_longest(a_children, b_children):
+ child_res = cmp(a_child, b_child)
+ if child_res != 0:
+ res = child_res
+ break
+
+ return res
+
+
+def _xml_compare_tag_attribs_text(xml1, xml2, reporter, compare_xml2_attribs=True):
+ if xml1.tag != xml2.tag:
+ reporter('Tags do not match: {tag1} and {tag2} (path: {path})'
+ .format(tag1=xml1.tag, tag2=xml2.tag, path=_describe_element(xml1)))
+ return False
+ for attrib, value in xml1.attrib.items():
+ if xml2.attrib.get(attrib) != value:
+ reporter('Attributes do not match: {attr}={v1!r}, {attr}={v2!r} (path: {path})'
+ .format(attr=attrib, v1=value, v2=xml2.attrib.get(attrib), path=_describe_element(xml1)))
+ return False
+ if compare_xml2_attribs:
+ for attrib in xml2.attrib.keys():
+ if attrib not in xml1.attrib:
+ reporter('xml2 has an attribute xml1 is missing: {attrib} (path: {path})'
+ .format(attrib=attrib, path=_describe_element(xml2)))
+ return False
+ if not _xml_text_compare(xml1.text, xml2.text):
+ reporter('Text: {t1} != {t2} (path: {path})'
+ .format(t1=xml1.text.encode('utf-8'), t2=xml2.text.encode('utf-8'), path=_describe_element(xml1)))
+ return False
+ if not _xml_text_compare(xml1.tail, xml2.tail):
+ reporter('Tail: {tail1} != {tail2}'
+ .format(tail1=xml1.tail.encode('utf-8'), tail2=xml2.tail.encode('utf-8'), path=_describe_element(xml1)))
+ return False
+ return True
+
+
+class __DownstreamReporter(object):
@katraev
katraev added a note

чем этот callable класс отличается от переменной?

@maizy HeadHunter member
maizy added a note

Мне в https://github.com/hhru/frontik/pull/40/files#L1R168 нужно получить последнюю ошибку в рекурсивных вызовах внтури цикла и её отрепортить в переданный reporter callback.

Там такой алгортим, что нормально когда нескоторые xml_check_compatibility в цикле ниже (https://github.com/hhru/frontik/pull/40/files#L1R170) возвращают false и соотвественно вызывают reporter callback, нужно чтобы хотябы один из них совпал. Если не один не совпал я пишу ошибку с последним из вариантов, который пробовал.

Вроде бы переменной сделать такое не удасться.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
+
+ def __init__(self):
+ self.last_error = None
+
+ def __call__(self, *args, **kwargs):
+ self.last_error = args[0]
+
+
+def xml_compare(xml1, xml2, reorder_tags=True, reporter=None):
+ """
+ XML comparing for etree.Element
+ Based on https://bitbucket.org/ianb/formencode/src/tip/formencode/doctest_xml_compare.py#cl-70
+ """
+ if reporter is None:
+ reporter = lambda x: None
+ pre_cmp = _xml_compare_tag_attribs_text(xml1, xml2, reporter=reporter)
+ if not pre_cmp:
+ return False
+ children1 = xml1.getchildren()
+ children2 = xml2.getchildren()
+ if len(children1) != len(children2):
+ reporter('Children length differs, {len1} != {len2} (path: {path})'
+ .format(len1=len(children1), len2=len(children2), path=_describe_element(xml1)))
+ return False
+ if reorder_tags:
+ children1.sort(_xml_tags_compare)
+ children2.sort(_xml_tags_compare)
+ i = 0
+ for c1, c2 in zip(children1, children2):
+ i += 1
+ if not xml_compare(c1, c2, reporter=reporter, reorder_tags=reorder_tags):
+ reporter('Children not matched (path: {path})'
+ .format(n=i, tag1=c1.tag, tag2=c2.tag, path=_describe_element(xml1)))
+ return False
+ return True
+
+
+def xml_check_compatibility(old, new, reorder_tags=True, reporter=None):
+ """
+ Check two xml compatibility.
+
+ new_xml >= old_xml:
+ * new_xml should contains all attribs and properties from old_xml
+ * new_xml may have any extra attribs
+ * new_xml may have any extra properties
+ """
+ if reporter is None:
+ reporter = lambda x: None
+ pre_cmp = _xml_compare_tag_attribs_text(old, new, reporter=reporter, compare_xml2_attribs=False)
+ if not pre_cmp:
+ return False
+
+ old_children = old.getchildren()
+ new_children = new.getchildren()
+ if len(old_children) == 0:
+ return True
+ elif len(new_children) < len(old_children):
+ reporter('Children length differs, {len1} < {len2} (path: {path})'
+ .format(len1=len(old_children), len2=len(new_children), path=_describe_element(old)))
+ return False
+ else:
+ new_children_index = {}
+ for child in new_children:
+ tag = child.tag
+ if tag not in new_children_index:
+ new_children_index[tag] = []
+ new_children_index[tag].append(child)
+ for tag in new_children_index.iterkeys():
+ new_children_index[tag].sort(_xml_tags_compare)
+
+ old_children.sort(_xml_tags_compare)
+ for child in old_children:
+ tag = child.tag
+ if tag not in new_children_index or len(new_children_index[tag]) == 0:
+ reporter('Tag {tag} not exist in new xml (path: {path})'
+ .format(tag=tag, path=_describe_element(old)))
+ return False
+
+ any_matched = False
+ downstream_reporter = __DownstreamReporter()
+ for match_child in new_children_index[tag]:
+ is_compatible = xml_check_compatibility(child, match_child, reorder_tags=reorder_tags,
+ reporter=downstream_reporter)
+ if is_compatible:
+ any_matched = True
+ new_children_index[tag].remove(match_child)
+ break
+ if not any_matched:
+ reporter(downstream_reporter.last_error)
+ return False
+ return True
+
+
+def remove_xpaths(elem, xpaths):
+ """
+ Remove element that matches xpath from it's parent.
+ """
+ for x in xpaths:
+ res = elem.xpath(x)
+ if len(res) == 0:
+ continue
+ for e in res:
+ parent = e.getparent()
+ if parent is not None:
+ parent.remove(e)
+ return elem
+
+
+class XmlResponseTestCaseMixin(object):
+ """
+ Mixin for L{unittest.TestCase} or other class with similar API.
+
+ Add assertion:
+ * assertXmlAlmostEquals
+ * assertXmlCompatible
+
+ Add helpers:
+ * remove_xpaths
+ """
+
+ # ----------------------------------------------------
+ # Assertions
+ def _xml_cmp_assertion(self, cmp_func, x1, x2, msg=None):
+ if msg is None:
+ msg = 'XML not equals'
+ if not isinstance(x1, etree._Element):
+ x1 = etree.fromstring(x1)
+ if not isinstance(x2, etree._Element):
+ x2 = etree.fromstring(x2)
+
+ def _fail_reporter(err_message):
+ self.fail('{0}: {1}'.format(msg, err_message))
+
+ cmp_func(x1, x2, reorder_tags=True, reporter=_fail_reporter)
+
+ def assertXmlAlmostEquals(self, expected, real, msg=None):
+ """
+ Assert that xml almost equals.
+ Before comparing XML tags and properties will be ordered.
+ If real or expected xml has some extra tags, assertion fails.
+ """
+ self._xml_cmp_assertion(xml_compare, expected, real, msg)
+ def assertXmlCompatible(self, old, new, msg=None):
+ """
+ Assert that xml almost equals.
+ Before comparing XML tags and properties will be ordered.
+ If real or expected xml has some extra tags, assertion fails.
+ """
+ self._xml_cmp_assertion(xml_check_compatibility, old, new, msg)
View
233 tests/test_testing_test_utils.py
@@ -0,0 +1,233 @@
+# _*_ coding: utf-8 _*_
+
+import unittest
+
+from lxml import etree
+
+from frontik.testing import test_utils
+
+
+class TestHelpers(unittest.TestCase):
+
+ def test_remove_xpath(self):
+ root = etree.fromstring('''
+ <root>
+ <removeMe ppp="mmm"/>
+ <level2>
+ <removeMe2 n="1"/>
+ <removeMe2 n="2"/>
+ </level2>
+ </root>
+ '''.strip())
+ xpath1 = 'removeMe[@ppp="mmm"]'
+ xpath2 = 'level2/removeMe2'
+
+ self.assertTrue(len(root.xpath(xpath1)) == 1)
+ self.assertTrue(len(root.xpath(xpath2)) == 2)
+
+ test_utils.remove_xpaths(root, [xpath1, xpath2])
+
+ self.assertTrue(len(root.xpath(xpath1)) == 0)
+ self.assertTrue(len(root.xpath(xpath2)) == 0)
+
+
+class TestXmlResponseMixin(unittest.TestCase, test_utils.XmlResponseTestCaseMixin):
+
+ # ----------------------------------------------------
+ # assertXmlAlmostEquals
+ def test_assertXmlAlmostEquals_abs_equals(self):
+ tree1_str, _, = self._get_almost_equals_xml()
+ tree1 = etree.fromstring(tree1_str)
+ tree1_2 = etree.fromstring(tree1_str)
+ try:
+ self.assertXmlAlmostEquals(tree1_str, tree1_str)
+ self.assertXmlAlmostEquals(tree1, tree1_2)
+ except self.failureException, e:
+ self.fail('XML should be absolute equals (Reported error: "{0!s}")'.format(e))
+
+ def test_assertXmlAlmostEquals_with_strings(self):
+ tree1_str, tree2_str = self._get_almost_equals_xml()
+ try:
+ self.assertXmlAlmostEquals(tree1_str, tree2_str)
+ except self.failureException, e:
+ self.fail('XML should be almost equals (Reported error: "{0!s}")'.format(e))
+
+ def test_assertXmlAlmostEquals_with_tree(self):
+ tree1_str, tree2_str = self._get_almost_equals_xml()
+ tree1 = etree.fromstring(tree1_str)
+ tree2 = etree.fromstring(tree2_str)
+ try:
+ self.assertXmlAlmostEquals(tree1, tree2)
+ except self.failureException, e:
+ self.fail('XML should be almost equals (Reported error: "{0!s}")'.format(e))
+
+ def test_assertXmlAlmostEquals_same_tags_order(self):
+ x1_str = '''
+ <elem>
+ <a/>
+ <a>
+ <c prop="1">
+ <d prop="x"/>
+ <d prop="y"/>
+ <d/>
+ </c>
+ <c prop="1" a="1"/>
+ <c/>
+ </a>
+ </elem>
+ '''.strip()
+
+ x2_str = '''
+ <elem>
+ <a>
+ <c/>
+ <c prop="1" a="1"/>
+ <c prop="1">
+ <d prop="x"/>
+ <d/>
+ <d prop="y"/>
+ </c>
+ </a>
+ <a/>
+ </elem>
+ '''.strip()
+ try:
+ self.assertXmlAlmostEquals(x1_str, x2_str)
+ except self.failureException, e:
+ self.fail('XML should be almost equals (Reported error: "{0!s}")'.format(e))
+
+ # ----------------------------------------------------
+ # assertXmlCompatible
+ def test_assertXmlCompatible_abs_equals(self):
+ tree1_str, _ = self._get_almost_equals_xml()
+ try:
+ self.assertXmlCompatible(tree1_str, tree1_str)
+ except self.failureException, e:
+ self.fail('XML should be absolute equals (Reported error: "{0!s}")'.format(e))
+
+ def test_assertXmlCompatible_with_extra_property(self):
+ old = '''
+ <elem>
+ <a answer="42" douglas="adams"/>
+ </elem>
+ '''.strip()
+
+ # add: elem[@prop], a[@new], a[@new2]
+ new = '''
+ <elem prop="some">
+ <a answer="42" new2="no" douglas="adams" new="yes"/>
+ </elem>
+ '''.strip()
+ try:
+ self.assertXmlCompatible(old, new)
+ except self.failureException, e:
+ self.fail('XML should be compatible (Reported error: "{0!s}")'.format(e))
+
+ def test_assertXmlCompatible_with_extra_tags(self):
+ old = '''
+ <elem>
+ <z prop="1"/>
+ <a>
+ <c/>
+ <c month="jan"/>
+ <b/>
+ </a>
+ <z prop="3"/>
+ <a disabled="true"/>
+ <txt>some text</txt>
+ </elem>
+ '''.strip()
+
+ # add extra tags: yy, dd, txt, aa, new
+ # reoder: elem/*, elem/a/*
+ new = '''
+ <elem>
+ <a disabled="true"/>
+ <a>
+ <aa/>
+ <b/>
+ <c month="apr"/>
+ <c month="jan"/>
+ <c/>
+ <dd/>
+ </a>
+ <txt>some text</txt>
+ <txt>some new text</txt>
+ <z prop="3"/>
+ <z prop="1">
+ <new nested="tag"/>
+ </z>
+ <yy/>
+ </elem>
+ '''.strip()
+ try:
+ self.assertXmlCompatible(old, new)
+ except self.failureException, e:
+ self.fail('XML should be compatible (Reported error: "{0!s}")'.format(e))
+
+ def test_assertXmlCompatible_incompatible_property(self):
+ old = '''
+ <elem>
+ <a answer="42" douglas="adams"/>
+ </elem>
+ '''.strip()
+
+ # remove: a[@answer], add a[@extra]
+ new = '''
+ <elem>
+ <a douglas="adams" extra="extra"/>
+ </elem>
+ '''.strip()
+ self.assertRaises(self.failureException, self.assertXmlCompatible, old, new)
+
+ def test_assertXmlCompatible_incompatible_less_tags(self):
+ old = '''
+ <elem>
+ <a>
+ <b/>
+ <c/>
+ </a>
+ <m/>
+ <z/>
+ </elem>
+ '''.strip()
+
+ # remove: z
+ # reorder: m<->a
+ new = '''
+ <elem>
+ <m/>
+ <a>
+ <b/>
+ <c/>
+ </a>
+ </elem>
+ '''.strip()
+ try:
+ self.assertXmlCompatible(old, new)
+ except self.failureException, e:
+ self.assertTrue('Children length differs' in str(e))
+
+ # ----------------------------------------------------
+ # helpers
+ def _get_almost_equals_xml(self):
+ tree1_str = '''
+ <elem start="17" end="18">
+ <zAtrib/>
+ <aAtrib>
+ <cAtrib/>
+ <bAtrib a="1" b="2"/>
+ </aAtrib>
+ </elem>
+ '''.strip()
+ #props and tags order changed
+ tree2_str = '''
+ <elem end="18" start="17" >
+ <aAtrib>
+ <bAtrib b="2" a="1"/>
+ <cAtrib/>
+ </aAtrib>
+ <zAtrib/>
+ </elem>
+ '''.strip()
+ return tree1_str, tree2_str
Something went wrong with that request. Please try again.