2021-12-25 / Bartłomiej Kurek
Django - virtual mail manager (#4 - testy jednostkowe modeli)

W poprzedniej części stworzyliśmy modele Django dla naszej aplikacji.
W tym artykule omówimy i zrealizujemy implementację testów jednostkowych modeli bazy danych.

W pierwszej fazie omawiamy implementację bardziej szczegółowo, krok po kroku, rozważając niuanse techniczne. Dalsza część zawiera mniej detali, choć trafiamy na ciekawy bug w Django. Po uruchomieniu całości kodu testów uruchamiamy narzędzie coverage i analizujemy stopień pokrycia kodu testami jednostkowymi.

Bug w Django zgłoszony został podczas pisania tego artykułu i został już naprawiony.

Test: domains

Walidacja danych

Pierwszym przypadkiem testowym jest zapis danych. Django nie uruchamia walidacji danych podczas zapisu (ani przy Model.objects.create(), ani przy save()). Musimy tę walidację wywołać sami wykonując clean() bądź full_clean() przed zapisem.

Założenia:

  • pole Domain.domain, nie może być "null"
  • pole Domain.domain musi zaweriać poprawną domenę. Przyjmujemy, że poprawna domena to przyjmniej jeden znak.

Zatem pierwsze testy dotyczą walidacji danych. Próbujemy:

from django.test import TestCase
from django.db.utils import IntegrityError
from django.core.validators import ValidationError

from domains.models import Domain


class TestDomains(TestCase):
    def test_validation_no_domain(self):
        with self.assertRaises(ValidationError):
            domain = Domain()
            domain.full_clean()

    def test_validation_domain_length(self):
        with self.assertRaises(ValidationError):
            domain = Domain(domain="")
            domain.full_clean()

Uruchamiamy testy:

./manage testing test domains -v 2
[...]
test_validation (domains.tests.TestDomains) ... ok
test_validation_domain_length (domains.tests.TestDomains) ... ok

Dla pewności możemy zmienić na chwilę oczekiwany typ wyjątku ValidationError np. na TypeError, a następnie uruchomić testy. Otrzymamy wtedy informację, że oczekiwany wyjątek nie został zgłoszony, a faktycznym wyjątkiem był ValidationError.

======================================================================
ERROR: test_validation_domain_length (domains.tests.TestDomains)
----------------------------------------------------------------------
Traceback (most recent call last):
...
django.core.exceptions.ValidationError: {'domain': ['This field cannot be blank.']}

Zachowanie jest poprawne - Django domyślnie ustawia atrybut blank na False, czyli pole musi zostać wypełnione. Nie jest to od razu oczywiste, a w szczególności dlatego, że atrybut blank w polu modelu dotyczy formularzy, a nie schematu bazy danych. Atrybut null=False dotyczy ograniczenia w bazie danych (contstraint), a blank=False dotyczy uzupełniania wartości przez użytkownika. Jeśli nie podajemy jawnie blank, to domyślnie atrybut ten przyjmuje wartość False.

Przywracamy zatem oczekiwany typ wyjątku na ValidationError, a w samym modelu możemy jawnie dodać blank=False oraz walidator minimalnej długości dla pola domain.

...
from django.core.validators import MinLengthValidator
...
class Domain(BaseModel):
    ...
    domain = models.CharField(null=False, unique=True, blank=False,  # here!
                              validators=[MinLengthValidator(1)],  # here!
                              max_length=255)  # RFC1035
    ...

Następnie dodajemy test sprawdzający czy zapis się powiedzie kiedy podamy jakąś domenę.

class TestDomains(TestCase):
    ...
    def test_validation_pass(self):
        fqdn = "a"
        domain = Domain(domain=fqdn)
        domain.full_clean()
        domain.save()
        self.assertTrue(domain.id, "saved")
        self.assertEqual(domain.domain, fqdn, "Domain name")

Wartości domyślne

Pole Domain.is_enabled domyślnie ma być uzupełniane wartością True. Dodajemy test:

class TestDomains(TestCase):
    ...
    def test_defaults(self):
        domain = Domain(domain="testing.defaults.localhost")
        domain.full_clean()
        domain.save()
        self.assertTrue(domain.is_enabled, "Default: is_enabled")

Pełny zapis

Pozostał nam przypadek zapisu całości: nazwa domeny oraz flaga is_enabled. Dodajemy metodę do testu:

class TestDomains(TestCase):
    ...
    def test_full(self):
        fqdn = "testing.full.is-enabled.localhost"
        domain = Domain(domain=fqdn, is_enabled=False)
        domain.full_clean()
        domain.save()
        self.assertEqual(domain.domain, fqdn, "is_enabled")
        self.assertFalse(domain.is_enabled, "is_enabled")

Uruchamiamy testy dla modelu Domains

Wszystkie testy powinne zakończyć się powodzeniem. Sprawdzamy:

$ ./manage testing test domains -v 2 --timing
[...]
test_defaults (domains.tests.TestDomains) ... ok
test_full (domains.tests.TestDomains) ... ok
test_validation (domains.tests.TestDomains) ... ok
test_validation_domain_length (domains.tests.TestDomains) ... ok
test_validation_pass (domains.tests.TestDomains) ... ok

----------------------------------------------------------------------
Ran 5 tests in 0.006s

OK
Destroying test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')...
Total database setup took 0.291s
  Creating 'default' took 0.291s
Total database teardown took 0.000s
Total run took 0.324s

Wszystko działa poprawnie. Zatwierdzamy zmiany w repozytorium kodu.

Uwagi i obserwacje

Jeśli ktoś pomyślał, że to trochę "bez sensu", że tworzenie (create()) oraz zapis (save()) obiektu nie wywołują jego walidacji - ma całkowitą rację. Oczywistym rozwiązaniem obejścia tej niedogodności byłoby przesłonięcię metody save() i wywołanie w niej full_clean() zawsze. Czyli:

    def save(self, *args, **kwargs):
        self.full_clean()
        super().save(*args, **kwargs)

ALE... Django nie wywołuje walidacji, ponieważ implementuje inne metody (np. update(), czy bulk_create()), które działają na zestawie rekordów. W tych metodach Django zdaje się na optymalizację zapytań do bazy danych (executemany) i wtedy nie wywołuje w ogóle metody save() na poszczególnych obiektach. Próba obejścia tego w ten sposób nie byłaby wystarczająca dla wszystkich przypadków. Zatem podsumujmy:

  • django nie zawsze generuje constraints w bazie danych (spory minus)
  • nie wywołuje walidacji (zachowanie zupełnie nieintuicyjne)

Jeśli chcemy mieć poprawną bazę danych (zachowującą integralność danych), to najłatwiej będzie ją uzyskać używając wprost bazy danych (język SQL), bez żadnych warstw abstrakcji. Wymaga to nieco większego (choć nie zawsze) nakładu pracy, nieco więcej obycia z SQL. Wtedy jednak nie będziemy mieć automatycznego panelu www z Django. Warto zatem odpowiedzieć sobie na pytanie jak ważna jest aplikacja, którą budujemy. Na poziomie SQL będziemy w stanie spełnić wszystkie wymagania i zagwarantować pełną integralność danych. Projekt z panelem zarządzania zrealizujemy szybciej, ale wiele rzeczy będzie "implicit", często błędnych w założeniach, często tylko w Django.

Przykładem mogą być triggery w bazie danych. Django wprawdzie pozwala w modelu ustawić on_update, czy on_delete, ale są to funkcje w Python, a nie w SQL. Nie są to zatem triggery stworzone w bazie danych, które baza danych może wykonywać. Takie triggery uruchomią się jedynie wtedy, kiedy na bazie operujemy z poziomu Django.
Jeśli zatem mielibyśmy zespół osób wsparcia aplikacji (support), który wykonywałby różne operacje bezpośrednio na bazie danych, to te operacje nie skorzystają z implementacji umieszczonej w kodzie Django.

Rozwinę te zagadnienia jeszcze w przyszłości. Teraz - świadomi tych niuansów - wracamy do testowania.

Test: users

Wiemy już jak pisać i uruchamiać testy. Zmniejszamy teraz poziom szczegółowości analizy i przystępujemy do testowania modułu users.

Zaczynamy od testu integralności. Domeny są w osobnej tabeli, tabela users zawiera klucz obcy, a więc tworząc redkord użytkownika musimy referefować istniejący rekord z tabeli domains. Integralności pilnuje tutaj baza danych, zatem przy próbie zapisu użytkownika z błędną domeną w Django musi wystąpić wyjątek IntegrityError.

from django.test import TestCase
from django.db.utils import IntegrityError
from django.core.validators import ValidationError

from users.models import User
from domains.models import Domain


class TestUsers(TestCase):
    def test_create_invalid(self):
        with self.assertRaises(IntegrityError):
            User.objects.create()

Od razu sprawdzamy ten przypadek uruchamiając test:

$ ./manage testing test -v 1 users
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.004s

OK

Kontynuujemy implementację przypadków testowych. Czas na przypadek z błędną domeną (taką, która nie jest zapisana w bazie):

class TestUsers(TestCase):
    ...

    def test_create_invalid_domain(self):
        domain = Domain(domain="this-is-never-saved")
        with self.assertRaises(ValueError):
            User.objects.create(
                domain=domain,
                username="test-create",
                password="test-create",
            )

Następnie testujemy walidację obiektów pamiętając o full_clean(). Walidacja może się nie powieść z dwóch powodów:

  • braku nazwy użytkownika przy tworzeniu obiektu
  • nazwy użytkownika przekraczającej maksymalną długość pola
class TestUsers(TestCase):
    ...

    def test_username_missing(self):
        domain = Domain.objects.create(domain="username-missing")
        with self.assertRaises(ValidationError):
            user = User(
                domain=domain,
                username="",
                password="test-username-missing-password",
            )
            user.full_clean()

    def test_username_too_long(self):
        domain = Domain.objects.create(domain="username-too-long.localhost")
        with self.assertRaises(ValidationError):
            user = User(
                domain=domain,
                username="a" * 128,
                password="test-username-too-long-password",
            )
            user.full_clean()

Przypadek dla zbyt krótkiej nazwy użytkownika w zasadzie jest obsłużony przez domyślną wartość blank=False. Do modelu User dodamy jednak MinLengthValidator(), identycznie jak w przypadku nazwy domeny w pierwszej sekcji artykułu. Nazwa użytkownika musi zawierać co najmniej jeden znak. W teście zatem oczekujemy wystąpienia ValidationError:

class TestUsers(TestCase):
    ...

    def test_username_blank(self):
        domain = Domain.objects.create(domain="username-blank.localhost")
        with self.assertRaises(ValidationError):
            user = User(
                domain=domain,
                username="",
                password="test-username-blank-password",
            )
            user.full_clean()

Test poprawnego zapisu obiektu użytkownika:

class TestUsers(TestCase):
    ...

    def test_create_valid(self):
        domain = Domain.objects.create(domain="create-valid.localhost")
        user = User(
            domain=domain,
            username="test-create-valid",
            password="test-create-valid-password",
            quota=12345,
            is_enabled=True,
        )
        user.full_clean()
        user.save()
        self.assertTrue(user.id, "saved")

Pole quota zdefiniowane jest jako pole typu models.PositiveIntegerField. O ile pamiętam - jest to jeden z niewielu typów pól w Django, który zwykł fizycznie umieszczać constraint (CHECK) w bazie danych. Podczas uruchomienia testu okazuje się, że jednak można podać wartość ujemną. Naprawiamy to w modelu dodając do pola quota walidator MinValueValidator z minimalną wartością 0.

   quota = models.PositiveIntegerField(default=134217728,
                                        validators=[MinValueValidator(0)])

Teraz walidacja faktycznie zgłasza oczekiwany przeze mnie wyjątek ValidationError. Kod tej metody w całości:

class TestUsers(TestCase):
    ...

    def test_quota(self):
        domain = Domain.objects.create(domain="test-quota.localhost")
        user = User(
            domain=domain,
            username="test-quota",
            password="test-quota-password",
        )

        user.quota = -10
        with self.assertRaises(ValidationError):
            user.full_clean()

        user.quota = "abcd"
        with self.assertRaises(ValidationError):
            user.full_clean()

        user.quota = 100
        user.save()
        self.assertEqual(user.quota, 100 * 1024 * 1024, "Quota in bytes")

Skoro zajmujemy się polem quota - testujemu jeszcze metodę klasy from_db(), która przelicza wartość kwoty dyskowej z powrotem na megabajty.

class TestUsers(TestCase):
    ...

    def test_from_db(self):
        domain = Domain.objects.create(domain="test-from-db.localhost")
        user = User.objects.create(
            domain=domain,
            username="test-from-db",
            password="test-from-db-password",
            quota=100,
        )

        self.assertEqual(user.quota, 100 * 1024 * 1024, "Quota in bytes")

        user = User.objects.get(pk=user.id)
        self.assertEqual(user.quota, 100, "Quota in megabytes")

Test flagi is_enabled tworzę zbiorczo - test wartości domyślnej, test zapisu wartości zmienionej - w jednej metodzie.

class TestUsers(TestCase):
    ...

    def test_is_enabled(self):
        domain = Domain.objects.create(domain="test-is-enabled.localhost")
        user = User.objects.create(
            domain=domain,
            username="test-is-enabled",
            password="test-is-enabled-password",
            quota=123456,
        )

        self.assertTrue(user.is_enabled, "Default: is_enabled")

        user.is_enabled = False
        user.save()
        user.refresh_from_db()

        self.assertFalse(user.is_enabled, "Default: is_enabled")

Przed testem hasła w modelu dodajmy od razu MinLengthValidator, wymagając minimum 8 znaków. Następnie testujemu pole hasła, które w bazie ma być przechowywane już w postaci nieodwracalnego, posolonego hasha SHA-512.

Testujemy walidację i hashowanie hasła:

class TestUsers(TestCase):
    ...

    def test_password_validation(self):
        domain = Domain.objects.create(domain="test-password.localhost")
        # too short
        with self.assertRaises(ValidationError):
            user = User(
                domain=domain,
                username="test-password-validation",
                password="a" * 7,
            )
            user.full_clean()

        # too long
        with self.assertRaises(ValidationError):
            user = User(
                domain=domain,
                username="test-password-validation",
                password="a" * 257,
            )
            user.full_clean()

    def test_password(self):
        password = "test-password-password"
        domain = Domain.objects.create(domain="test-password.localhost")
        user = User.objects.create(
            domain=domain,
            username="test-password",
            password=password,
            quota=123456,
        )

        user.refresh_from_db()

        self.assertNotIn(user.password, password, "Hashed")
        self.assertTrue(
            user.password.startswith("{SHA512-CRYPT}$6$"),
            "Crypted, salted, SHA-512"
        )

Na koniec zostały już tylko metody: email() oraz __str__(). To możemy umieścić zbiorczo w jednej metodzie testu.

class TestUsers(TestCase):
    ...
    def test_str(self):
        domain = Domain.objects.create(domain="test-str.localhost")
        user = User.objects.create(
            domain=domain,
            username="test-str",
            password="test-str-password",
        )

        expected = "test-str@test-str.localhost"
        self.assertEqual(user.email(), expected, "email()")
        self.assertEqual(str(user), expected, "__str__()")
        self.assertEqual(str(user), user.email(), "__str__() calls email()")

Uruchamiamy całość:

$ ./manage testing test -v 1 users -v 2
[...]
test_create_invalid (users.tests.TestUsers) ... ok
test_create_invalid_domain (users.tests.TestUsers) ... ok
test_create_valid (users.tests.TestUsers) ... ok
test_from_db (users.tests.TestUsers) ... ok
test_is_enabled (users.tests.TestUsers) ... ok
test_password (users.tests.TestUsers) ... ok
test_password_validation (users.tests.TestUsers) ... ok
test_quota (users.tests.TestUsers) ... ok
test_str (users.tests.TestUsers) ... ok
test_username_missing (users.tests.TestUsers) ... ok
test_username_too_long (users.tests.TestUsers) ... ok

----------------------------------------------------------------------
Ran 11 tests in 0.056s

OK

Testy działają poprawnie, zatwierdzamy zmiany.

Uwagi i obserwacje

Wiele rzeczy w Django może nas zadziwić tak jak mnie przed chwilą typ pola PositiveIntegerField. Jestem przekonany, że ten typ wyjątkowo tworzył constraint w bazie danych... a przynajmniej w PostgreSQL.
...
Po krótkim researchu widzimy, że według programistów Django najwidoczniej w SQLite ten constraint nigdy nie mógł działać, co nie jest prawdą.

sqlite> create table foo(v integer CHECK (v > 0));
sqlite> insert into foo values(-1);
Error: CHECK constraint failed: v > 0

Wklejamy komentarz na GitHub i wracamy do testowania.

Test: aliases

Tabela aliasów ma tylko dwa pola, testy powinne być krótkie, nie ma się nad czym rozwodzić. Oto całość.

from django.test import TestCase
from django.db.utils import IntegrityError
from django.core.validators import ValidationError

from aliases.models import Alias


class TestAliases(TestCase):
    def test_create_invalid(self):
        with self.assertRaises(ValidationError):
            Alias().full_clean()

    def test_create_missing_email(self):
        with self.assertRaises(ValidationError):
            Alias(alias="missing-email@localhost").full_clean()

    def test_create_missing_alias(self):
        with self.assertRaises(ValidationError):
            Alias(email="missing-alias@localhost").full_clean()

    def test_unique_violation(self):
        email = "unique@localhost"
        alias = "unique-alias@localhost"
        o = Alias.objects.create(email=email, alias=alias)
        self.assertTrue(o.id)
        with self.assertRaises(IntegrityError):
            Alias.objects.create(email=email, alias=alias)

    def test_lengths(self):
        with self.assertRaises(ValidationError):
            Alias(
                email="aa",
                alias="length-1-alias@localhost",
            ).full_clean()

        with self.assertRaises(ValidationError):
            Alias(
                email="length-2@localhost",
                alias="aa",
            ).full_clean()

        with self.assertRaises(ValidationError):
            Alias(
                email="a" * 256,
                alias="length-3-alias@localhost",
            ).full_clean()

        with self.assertRaises(ValidationError):
            Alias(
                email="length-3@localhost",
                alias="a" * 256,
            ).full_clean()

    def test_is_enabled(self):
        o = Alias(email="enabled@localhost", alias="enabled-alias@localhost")
        self.assertTrue(o.is_enabled)
        o.is_enabled = False
        o.save()
        o.refresh_from_db()
        self.assertFalse(o.is_enabled)

Podgląd zmian w repozytorium.

Uwagi i obserwacje

Kiedy piszemy testy "na szybko" - łatwo przeoczyć pewne kwestie. W testach do modułów domains i users nie uwzględniłem testów pól unique. Wyglądać będą podobnie jak w przypadku testów aliases. Jeśli rekordy z takimi samymi wartościami istnieją już w bazie danych, spodziewamy się wyjątku IntegrityError. Stopień powtarzalności kodu powyższych testów jest wysoki. Istnieją sposoby, aby ten kod skrócić, lecz w celach poglądowych zostawiamy je takimi, jakimi je tutaj stworzyliśmy.
Testy unikalnych indeksów znajdują się w ostatniej partii zmian.

Coverage

Po napisaniu testów jednostkowych uruchamiamy jeszcze coverage, aby sprawdzić poziom pokrycia kodu testami. Narzędzie coverage instalujemy przy użycu pip install coverage, a następnie uruchamiamy testy tym narzędziem.

$ DJANGO_SETTINGS_MODULE=vmapp.settings.testing \
    coverage run vmapp/manage.py test users domains aliases
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.........................
----------------------------------------------------------------------
Ran 25 tests in 0.089s

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

Narzędzie tworzy plik .coverage, którego możemy użyć do wygenerowania raportu na konsoli, w formacie JSON, lub w HTML.

Przykład:

$ coverage html

Raport w postaci tekstowej możemy wyświetlić w terminalu:

$ coverage report
Name                                       Stmts   Miss  Cover
--------------------------------------------------------------
vmapp/aliases/__init__.py                      0      0   100%
vmapp/aliases/admin.py                         7      1    86%
vmapp/aliases/apps.py                          4      0   100%
vmapp/aliases/migrations/0001_initial.py       5      0   100%
vmapp/aliases/migrations/__init__.py           0      0   100%
vmapp/aliases/models.py                       11      0   100%
vmapp/aliases/tests.py                        37      0   100%
vmapp/common/__init__.py                       0      0   100%
vmapp/common/admin.py                          1      0   100%
vmapp/common/apps.py                           4      0   100%
vmapp/common/migrations/__init__.py            0      0   100%
vmapp/common/models.py                         6      0   100%
vmapp/domains/__init__.py                      0      0   100%
vmapp/domains/admin.py                        14      5    64%
vmapp/domains/apps.py                          4      0   100%
vmapp/domains/migrations/0001_initial.py       5      0   100%
vmapp/domains/migrations/__init__.py           0      0   100%
vmapp/domains/models.py                       10      0   100%
vmapp/domains/tests.py                        42      0   100%
vmapp/manage.py                               12      2    83%
vmapp/users/__init__.py                        0      0   100%
vmapp/users/admin.py                           7      1    86%
vmapp/users/apps.py                            4      0   100%
vmapp/users/migrations/0001_initial.py         6      0   100%
vmapp/users/migrations/__init__.py             0      0   100%
vmapp/users/models.py                         31      0   100%
vmapp/users/tests.py                          83      0   100%
vmapp/vmapp/__init__.py                        0      0   100%
vmapp/vmapp/settings/defaults.py              19      0   100%
vmapp/vmapp/settings/testing.py                2      0   100%
vmapp/vmapp/urls.py                            3      0   100%
--------------------------------------------------------------
TOTAL                                        317      9    97%

Pokrycie kodu testami wynosi 97%. Dla czytelności ograniczamy listing do modułów, w których występują fragmenty kodu pominięte w testach.

$ coverage report --skip-covered -m
Name                     Stmts   Miss  Cover   Missing
------------------------------------------------------
vmapp/aliases/admin.py       7      1    86%   10
vmapp/domains/admin.py      14      5    64%   14-25, 28, 31
vmapp/manage.py             12      2    83%   12-13
vmapp/users/admin.py         7      1    86%   13
------------------------------------------------------
TOTAL                      317      9    97%

27 files skipped due to complete coverage.

Pominięte fragmenty kodu to metody:

  • AliasAdmin.email()
  • DomainAdmin.get_queryset()
  • DomainAdmin.total_users()
  • DomainAdmin.total_active_users()
  • UserAdmin.is_domain_enabled()
  • wyjątek w manage.py, który nie wystąpił podczas testów, ponieważ nie było żadnego problemu z załadowniem odpowiedniego modułu settings (vmapp.settings.testing).

Wszystko się zgadza - stworzyliśmy testy do modeli, a nie tworzyliśmy testów do panelu administracyjnego Django.

Podsumowanie

Tworząc aplikację w Django musimy być świadomi sporej liczby abstrakcji.
Django stosuje je w celu zapewnienia spójnego zachowania operacji wykonywanych na różnych bazach danych. Ma to szereg zalet, jednak ostatecznie to bazy implementują operacje na danych. Prawdopodobnie - prędzej czy później - trafimy na różne niuanse, które będą uwidaczniać konkretnę implementację bazy danych, czy konkretny dialekt SQL. O ile w kodzie Python opartym na Django wszystko wygląda spójnie, to pewne różnice i ograniczenia zawsze będą istnieć. Wiele rzeczy ujętych w kodzie Python będzie zachowywać się inaczej na rożnych bazach danych. Aby mieć pewność, że wszystko działa jak należy, musimy zdać się na solidne testy takiego rozwiązania.

W tym projekcie wykorzystujemy Django, gdyż jest to stosunkowo małe przedsięwzięcie, na którym można też przedstawić i przećwiczyć wdrożenie i integrację takiego systemu. Aplikacja jest mała i prosta. Na tym etapie nie wymaga też widoków danych ani logiki biznesowej, a Django jest wygodnym systemem z gotowym panelem administracyjnym. Tak mała aplikacja nie będzie mieć też problemów o charakterze skalowalności, czy wydajności. Autentykację użytkowników/kont pocztowych będą przeprowadzać inne programy, jednak w oparciu o tę bazę danych zarządzaną z poziomu Django. Docelowy system polega na integracji Django z serwerem obsługującym pocztę (Dovecot /imap/), dlatego poprawność i integralność danych to kluczowe aspekty systemu.

W następnej części zajmiemy się uruchomieniem aplikacji w kontenerze Docker. Wykorzystamy docker-compose, a baza danych trafi na wolumen współdzielony przez Django i Dovecot.