Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP

Loading…

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

Merged
merged 5 commits into from

3 participants

@maizy
Collaborator

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

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

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

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

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

@katraev
Collaborator

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

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 Collaborator
katraev added a note

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

@maizy Collaborator
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 Collaborator
katraev added a note

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

@maizy Collaborator
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
Collaborator

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

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 Collaborator

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

@maizy Collaborator
maizy added a note

Да, никакой.

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

fixed

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

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

@maizy maizy merged commit 69e32b2 into from
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
This page is out of date. Refresh to see the latest.
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 Collaborator
katraev added a note

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

@maizy Collaborator
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.