django-with-asserts - Test HTML with Context Managers ===================================================== django-with-asserts offers an easier way to test HTML content than Django's standard ``assertContains(response, ..., html=True)`` test. Using `lxml `_ and the ``with`` statement, django-with-asserts exposes several new assertions, which make your tests more explicit and more concise by focusing on the attributes, values, and content that is relevant to your testing. Instead of boilerplate that includes unimportant checks of ``maxlength``:: self.assertContains( resp, '`_ to provide Level3 CSS selectors (see `supported selectors `_). It returns the matching :py:class:`lxml.html.HtmlElement` instances as the with statement's target. :py:meth:`~with_asserts.mixin.AssertHTMLMixin.assertHTML` and :py:meth:`~with_asserts.mixin.AssertHTMLMixin.assertNotHTML` are similar to Django's :py:meth:`assertContains` and :py:meth:`assertNotContains` Rationale ---------- django-with-asserts technically strays from the original intent of context managers in Python (using the ``with`` statement as an advanced ``try`` / ``except`` / ``finally`` construct). Instead it uses the with statement as a mini domain specific language. This approach allows for cleanly testing multiple parts of the response (note, the with statement does not introduce a new scope, so this is mainly cosmetic):: with self.assertHTML(resp, 'input[name="email"]') as (elem,): self.assertEqual(elem.value, 'bob@example.com') with self.assertHTML(resp, 'input[name="first_name"]') as (elem,): self.assertEqual(elem.value, 'bob') Additionally, django-with-asserts does not aim to replace or reduce the need for functional testing tools like Selenium, windmill, webunit, etc. Instead, django-with-asserts simply aims to provide an easier way to test HTML than currently is provided by :py:meth:`assertContains` Usage ----- django-with-asserts provides two approaches for incorporating its assertions into your test classes. The first, a subclass of Django's :py:class:`django.test.TestCase`, is provided as a drop-in replacement, :py:class:`with_asserts.case.TestCase`:: from with_asserts import TestCase class MyTest(TestCase): def test_view(self): resp = self.client.get('/my-view/') with self.assertHTML(resp) as html: self.assertEqual(html.find('head/title').text, 'My Title') The second approach is a mixin, :py:class:`with_asserts.mixin.AssertHTMLMixin`, which is added into your existing :py:class:`django.test.TestCase` test or custom subclass:: from django.test import TestCase from with_asserts import AssertHTMLMixin class MyTest(TestCase, AssertHTMLMixin): def test_view(self): ... At it's simplest, with no selector, :py:meth:`~with_asserts.mixin.AssertHTMLMixin.assertHTML` will parse the ``HttpResponse.content`` using lxml and return the entire document as an :py:class:`lxml.html.HtmlElement`. You can use any of lxml's `HTML Element `_ methods, its `xpath `_ method, or the `Element Tree `_ methods (e.g find, findall, and findtext):: with self.assertHTML(resp) as html: self.assertEqual(html.find('head/title').text, 'My Title') Similar to :py:meth:`~django.test.TestCase.assertContains`, :py:meth:`~with_asserts.mixin.AssertHTMLMixin.assertHTML` will ensure the status code of the HttpResponse. By using CSS Selectors, like ``#container``, ``li.menu``, ``.footer``, and ``input[name="email"]`` (see `supported selectors `_), you can obtain a list of matching elements. If no matching elements are found, the assertion with fail:: with self.assertHTML(resp, 'li.menu') as elems: self.assertEqual(5, len(elems)) Using Python's list destructuring, we can directly access individual elements, especially useful if only one or a few matches exist:: with self.assertHTML(resp, 'li.active') as (li,): self.assertEqual(li.attrib['href'], '/about/') with self.assertHTML(resp, 'td.cell') as (first, second): self.assertEqual(first.text, '10.5') self.assertEqual(second.text, '23') While we can pass an ID selector, we can alternatively pass the `element_id`, which will always return a single HtmlElement if it is found:: with self.assertHTML(resp, element_id='container') as elem: self.assertEqual(elem.attrib['width'], '100%') Just as :py:meth:`~django.test.TestCase.assertNotContains` is the inverse of assertContains, so to :py:meth:`~with_asserts.mixin.AssertHTMLMixin.assertNotHTML` will ensure that no matching element is found:: self.assertNotHTML(resp, 'input[name="old_password"]') API --- .. autoclass:: with_asserts.mixin.AssertHTMLMixin .. automethod:: with_asserts.mixin.AssertHTMLMixin.assertHTML .. automethod:: with_asserts.mixin.AssertHTMLMixin.assertNotHTML .. autoclass:: with_asserts.case.TestCase .. automethod:: with_asserts.case.TestCase.assertHTML .. automethod:: with_asserts.case.TestCase.assertNotHTML Future ------- django-with-asserts is an experiment in creating a DSL for improving testing in Django. While less impactful, one future improvement is making an ``assertJSON``, similar to ``assertHTML``. Install ------- django-with-asserts is available on `PyPI `_ and can be installed with `pip `_:: pip install -U django-with-asserts It has a dependency on lxml and cssselect (formerly part of lxml). Contribute ---------- The code is hosted at https://github.com/johnpaulett/django-with-asserts We use `tox `_ to run the test suite:: django-with-asserts$ tox Creating test database for alias 'default'... ............. ---------------------------------------------------------------------- Ran 13 tests in 0.044s py27-1.5: commands succeeded py27-1.4: commands succeeded docs: commands succeeded congratulations :)