New feature testing in Django 3.2


Django 3.2 released its first alpha release a couple of weeks ago, with the final release due in April. It contains a mix of new features, which you can read about in the release notes. This article is about the changes in testing, some of which can be obtained from earlier versions of Django with packages backport

1. Isolation setUpTestData ()

The release note says:

Objects assigned to classify attributes in TestCase.setUpTestData() are now allocated for each test method. “

setUpTestData() Is a very useful trick for quickly executing tests, and this change makes it much easier to use.

Reception TestCase.setUp() often used from a unit test to create instances of models that are used in each test:

from django.test import TestCase

from example.core.models import Book

class ExampleTests(TestCase):
    def setUp(self):
        self.book = Book.objects.create(title="Meditations")

Test runner calls setUp() before each test. This allows for easy test isolation, as fresh data is taken for each test. The disadvantage of this approach is that setUp() runs many times, which can be quite slow with a large amount of data or tests.

setUpTestData() allows you to create data at the class level – once per TestCase… Its use is very similar to setUp(), only this is a class method:

from django.test import TestCase

from example.core.models import Book

class ExampleTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.book = Book.objects.create(title="Meditations")

In between tests, Django continues to rollback any changes to the database, so they remain isolated there. Unfortunately, prior to this change in Django 3.2, rollback did not happen in memory, so any changes to model instances persisted. This means the tests were not completely isolated.

Take these tests for example:

from django.test import TestCase
from example.core.models import Book

class SetUpTestDataTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.book = Book.objects.create(title="Meditations")

    def test_that_changes_title(self):
        self.book.title = "Antifragile"

    def test_that_reads_title_from_db(self):
        db_title = Book.objects.get().title
        assert db_title == "Meditations"

    def test_that_reads_in_memory_title(self):
        assert self.book.title == "Meditations"

If we run them on Django 3.1, the final test will fail:

$ ./manage.py test example.core.tests.test_setuptestdata
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.F.
======================================================================
FAIL: test_that_reads_in_memory_title (example.core.tests.test_setuptestdata.SetUpTestDataTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/.../example/core/tests/test_setuptestdata.py", line 19, in test_that_reads_in_memory_title
    assert self.book.title == "Meditations"
AssertionError

----------------------------------------------------------------------
Ran 3 tests in 0.002s

FAILED (failures=1)
Destroying test database for alias 'default'...

This is because the in-memory change from test_that_changes_title() persists between tests. This happens in Django 3.2 by copying access objects in each test, so each test uses a separate isolated copy of the in-memory model instance. The tests are now passing:

$ ./manage.py test example.core.tests.test_setuptestdata
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...
----------------------------------------------------------------------
Ran 3 tests in 0.002s

OK
Destroying test database for alias 'default'...

Thanks to Simon Charette for initially creating this functionality in the project django-testdata, and before merging it into the Django system. On older versions of Django, you can use django test data for the same isolation by adding the decorator @wrap_testdata into your methods setUpTestData()… It’s very handy and I’ve added it to every project I’ve worked on.

(Older versions of Django also documented a workaround to re-query the database in every test, but this is slower).

2. Using the default faulthandler

The release note says:

DiscoverRunner currently uses faulthandler by default.

This is a small improvement that can help you debug low-level crashes. Module Python faulthandler provides a way to reset “emergency” traces in response to problems that cause the Python interpreter to crash. Then the Django test runner uses faulthandler. A similar solution was copied from pytest, which does the same.

The problems that faulthandler catches are usually in C libraries, such as database drivers. For example, the OS signal SIGSEGV indicates segmentation fault, which means an attempt to read memory that does not belong to the current process. We can emulate this in Python by sending a signal directly to ourselves:

import os
import signal

from django.test import SimpleTestCase


class FaulthandlerTests(SimpleTestCase):
    def test_segv(self):
        # Directly trigger the segmentation fault
        # signal, which normally occurs due to
        # unsafe memory access in C
        os.kill(os.getpid(), signal.SIGSEGV)

If we do a test in Django 3.1, we see this:

$ ./manage.py test example.core.tests.test_faulthandler
System check identified no issues (0 silenced).
[1]    31127 segmentation fault  ./manage.py test

This doesn’t really help us, since there is no clue as to what caused the segmentation fault.

Instead, we see a trace in Django 3.2:

$ ./manage.py test example.core.tests.test_faulthandler
System check identified no issues (0 silenced).
Fatal Python error: Segmentation fault

Current thread 0x000000010ed1bdc0 (most recent call first):
  File "/.../example/core/tests/test_faulthandler.py", line 12 in test_segv
  File "/.../python3.9/unittest/case.py", line 550 in _callTestMethod
  ...
  File "/.../django/test/runner.py", line 668 in run_suite
  ...
  File "/..././manage.py", line 17 in main
  File "/..././manage.py", line 21 in <module>
[1]    31509 segmentation fault  ./manage.py test

(Abbreviated)

The Faulthandler cannot generate the exact same traceback as a standard exception in Python, but it does give us a lot of information for dibugging a failure.

3. Timing

The release note says:

DiscoverRunner can now track timing, including database setup and total runtime.

Team manage.py test includes option --timingwhich activates several lines of output at the end of the test run to summarize the database setup and timing:

$ ./manage.py test --timing
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...
----------------------------------------------------------------------
Ran 3 tests in 0.002s

OK
Destroying test database for alias 'default'...
Total database setup took 0.019s
  Creating 'default' took 0.019s
Total database teardown took 0.000s
Total run took 0.028s

Thanks to Ahmad A. Hussein for participating in this event as part of the Google Summer of Code 2020.

If you are using pytest the option --durations N works in a similar way.

Because of the pytest fixture system, the database setup time will only show up as the “setup time” for one test, making that test slower than it actually is.

4. Callbacks for the transaction.on_commit () test

The release note says:

New method TestCase.captureOnCommitCallbacks() collects the callbacks functions passed to transaction.on_commit()… This allows you to test these callbacks without using the slower one TransactionTestCase

This is the contribution I made earlier and about which earlier told

So, imagine you are using option ATOMIC_REQUESTS from Django to translate each view into a transaction (which I think it should be!). Then you need to use the function transaction.on_commit () to perform any actions that depend on how long the data is stored in the database. For example, in this simple view for a contact form:

from django.db import transaction
from django.views.decorators.http import require_http_methods

from example.core.models import ContactAttempt


@require_http_methods(("POST",))
def contact(request):
    message = request.POST.get('message', '')
    attempt = ContactAttempt.objects.create(message=message)

    @transaction.on_commit
    def send_email():
        send_contact_form_email(attempt)

    return redirect('/contact/success/')

We do not send email messages unless the transaction saves the changes, as otherwise there will be no ContactAttempt in the database.

This is the correct way of writing this kind, but previously it was difficult to test the callback passed to the function on_commit()… Django will not run callbacks without saving the transaction, but its TestCase avoids saving, and instead rollback the transaction, so this is repeated on every test.

One solution is to use TransactionTestCasewhich allows you to save transactions of your code. But this is much slower than TestCase as it clears all tables between tests.

(I previously talked about three times faster speed thanks to the conversion of tests from TransactionTestCase in TestCase.)

The solution in Django 3.2 is the new feature captureOnCommitCallbacks()which we use as a context manager. It captures any callbacks and allows you to add assertions or test their effect. We can use this to test our opinion in this way:

from django.core import mail
from django.test import TestCase

from example.core.models import ContactAttempt


class ContactTests(TestCase):
    def test_post(self):
        with self.captureOnCommitCallbacks(execute=True) as callbacks:
            response = self.client.post(
                "/contact/",
                {"message": "I like your site"},
            )

        assert response.status_code == 302
        assert response["location"] == "/contact/success/"
        assert ContactAttempt.objects.get().message == "I like your site"
        assert len(callbacks) == 1
        assert len(mail.outbox) == 1
        assert mail.outbox[0].subject == "Contact Form"
        assert mail.outbox[0].body == "I like your site"

So we use captureOnCommitCallbacks() when requested by the test client to view, passing the execute flag to indicate that the “fake save (commit)” should trigger all callbacks. We then check the HTTP response and the state of the database before checking the email sent by the callback. Our test then covers everything on view, staying fast and awesome!

To use captureOnCommitCallbacks() in earlier versions of Django, install django-capture-on-commit-callbacks

5. Improved assertQuerysetEqual ()

The release note says:

TransactionTestCase.assertQuerysetEqual() currently supports direct comparison with another fetching query elements in Django

If you are using assertQuerysetEqual () in your tests, this change will definitely improve your life!

In addition to Django 3.2, assertQuerysetEqual() requires you to compare with the QuerySet after transformation. Next comes the default transition to repr()… So tests using it usually go through a list of precomputed repr() strings for the above comparison:

from django.test import TestCase

from example.core.models import Book


class AssertQuerySetEqualTests(TestCase):
    def test_comparison(self):
        Book.objects.create(title="Meditations")
        Book.objects.create(title="Antifragile")

        self.assertQuerysetEqual(
            Book.objects.order_by("title"),
            ["<Book: Antifragile>", "<Book: Meditations>"],
        )

Validation requires a little more thought about what model instances are expected. And also requires testing tests in case of a change in the method repr() models.

From Django 3.2 it is possible to pass QuerySet or a list of objects to compare to simplify the test:

from django.test import TestCase

from example.core.models import Book


class AssertQuerySetEqualTests(TestCase):
    def test_comparison(self):
        book1 = Book.objects.create(title="Meditations")
        book2 = Book.objects.create(title="Antifragile")

        self.assertQuerysetEqual(
            Book.objects.order_by("title"),
            [book2, book1],
        )

Thanks to Peter Inglesby and Hasan Ramezani for making these changes. They helped improve the Django test suite.

The final

Enjoy these changes when Django 3.2 comes out, or do so earlier via the backport package.


Translation of the article was prepared on the eve of the start of the course “Python Web Developer”

We also invite everyone to watch an open webinar on the topic Using third party libraries in django… In this lesson, we will look at the general principles of installing and using third-party libraries with django; and also learn how to use several popular libraries: django-debug-toolbar, django-cms, django-cleanup, etc.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *