Testing mit Django

Web-Anwendungen sind komplex und sollten einem permanenten Testing-Verfahren unterliegen. Django kommt mit einem mitgeliefertem Testing-Framework, was für diese Aufgabe unkompliziert nutzen können. Mit dem Testing-Framework können wir neben sogenannten Unit-Tests auch einfache Integrationstests schreiben.

Was ist Unit-Testing?

Um die Effizienz von Tests zu verbessern, ist es üblich, Tests in Einheiten zu unterteilen, die bestimmte Funktionen der Webanwendung testen. Diese Praxis wird als Unit-Test bezeichnet. Es erleichtert das Erkennen von Fehlern, da sich die Tests unabhängig von anderen Teilen auf kleine Teile (Units) Ihres Projekts konzentrieren.

Unit-Tests in Django sind nichts weiter als Python-Module, die wir mit dem Namen „test“ präfixen und die Methoden enthalten, die ebenfalls den Präfix „test“ haben. Ein solches Modul könnten zum Beispiel test_forms.py heissen und Klassen implementieren, die Formulare testen.

Wir wollen nun für die App events folgende Dinge testen: * Models testen * Views testen

Models testen

Wir löschen die Datei event_manager/events/tests.py und legen das Verzeichnis event_manager/events/tests an. Der Grund ist, dass in einem Projekt oft viele Dinge getestet werden, und es übersichtlicher ist, für jede Art von Test ein eigenes Verzeichnis anzulegen, statt alles in eine tests.py-Datei zu schreiben.

Dort hinein kommen unsere Unit-Tests. Dieses Verzeichnis muss ein Python-Package sein, deshalb legen wir hier auch noch eine __init__.py an.

Datei anlegen

Wir legen die Datei event_manager/events/tests/test_models.py an und füllen sie mit folgendem Inhalt:

from django.core.exceptions import ValidationError
from django.test import Client, TestCase
from django.utils import timezone

from events.models import Event
from events.factories import CategoryFactory
from core.factories import UserFactory


def create_user():
    return UserFactory()


class EventModelTests(TestCase):

    def setUp(self):
        """This method runs before each test in a test class."""
        cat = CategoryFactory()
        author = create_user()
        self.event_payload = {
            "name": "Seilspringen und Boxen",
            "description": "Spaß muss sein",
            "sub_title": "Seilspringen für Anfänger",
            "category": cat,
            "min_group": 10,
            "date": timezone.now(),
            "author": author,
        }

    def test_if_event_has_proper_slug(self):
        event = Event.objects.create(**self.event_payload)
        event.full_clean()
        self.assertEqual(event.slug, "seilspringen-und-boxen")

    def test_invalid_event_name_too_short(self):
        """event name must be greater than 2 chars"""
        self.event_payload["name"] = "aa"
        event = Event.objects.create(**self.event_payload)
        self.assertRaises(ValidationError, event.full_clean)

Aus django.test importieren wir TestCase. Von dieser Klasse erben unsere ganzen Testklassen. Dann legen wir die Testklasse EventModelTests an, und definieren eine setUp-Methode, die VOR jeder Test-Methode aufgerufen wird. Dort erstellen wir auf Basis unserer Factories einen User und eine Categorie und erstellen das Dict, mit dem wir in den Methoden dann Event-Objekt bauen.

In der Methode self.event_payload prüfen wir zum Beispiel, ob der Slug, der von Django erstellt wurde, unseren Erwartungen entspricht.

In der Methode test_invalid_event_name_too_short prüfen wir, ob bei der Eingabe eines zu kurzen Eventnames ein Validation-Error ausgelöst wird.

Wir können den Test jetzt starten.

python manage.py test

Wir bekommen folgende Meldung serviert:

File "event_manager\events\tests\test_models.py", line 67, in teste test_invalid_event_name_too_short
self.assertRaises(ValidationError, event.full_clean)
AssertionError: ValidationError not raised by full_clean

Der Grund ist natürlich, dass wir bisher noch keinen Validation-Error auslösen, wenn wir einen zu kurzen Namen eintragen. Das holen wir jetzt nach und öffnen die Datei event_manager/events/models.py und importieren den MinLengthValidator.

from django.core.validators import MinLengthValidator

Dann ändern wir das Event-Model ab:

class Event(DateMixin):
    """Stores a single event entry.
    related to :model:`events.Category` and :model:`user.User`."""

    # anderer Code

    name = models.CharField(
        max_length=100,
        unique=True,
        validators=[MinLengthValidator(3)]
    )

der MinLengthValidator löst einen Validierungsfehler aus, wenn ein Wert kleiner als 3 eingegeben wird.

Wir fügen noch die Methode test_event_creation_with_valid_values hinzu, die prüft, ob ein Objekt mit richtigen Werten auch sauber angelegt wurde.

from django.core.exceptions import ValidationError
from django.test import Client, TestCase
from django.utils import timezone

from events.models import Event
from events.factories import CategoryFactory
from core.factories import UserFactory


def create_user():
    return UserFactory()


class EventModelTests(TestCase):

    def setUp(self):
        """This method runs before each test in a test class."""
        self.cat = CategoryFactory()
        self.author = create_user()
        self.event_payload = {
            "name": "Seilspringen und Boxen",
            "description": "Spaß muss sein",
            "sub_title": "Seilspringen für Anfänger",
            "category": self.cat,
            "min_group": 10,
            "date": timezone.now(),
            "author": self.author,
        }

    def test_if_event_has_proper_slug(self):
        event = Event.objects.create(**self.event_payload)
        event.full_clean()
        self.assertEqual(event.slug, "seilspringen-und-boxen")

    def test_event_creation_with_valid_values(self):
        """test creating review with an invalid rating"""
        event = Event(**self.event_payload)
        event.save()
        event.full_clean()
        event = Event.objects.all()
        self.assertTrue(event.exists())
        self.assertEqual(event[0].name, "Seilspringen und Boxen")
        self.assertEqual(event[0].slug, "seilspringen-und-boxen")
        self.assertEqual(event[0].sub_title, "Seilspringen für Anfänger")
        self.assertEqual(event[0].min_group, 10)
        self.assertEqual(event[0].category.id, self.cat.id)
        self.assertEqual(event[0].author, self.author)
        self.assertEqual(len(event), 1)

    def test_invalid_event_name_too_short(self):
        """event name must be greater than 2 chars"""
        self.event_payload["name"] = "aa"
        event = Event.objects.create(**self.event_payload)
        self.assertRaises(ValidationError, event.full_clean)

Integration Test der Views

Django bietet eine Möglichkeit, Views zu testen, wenn gleich auch nicht so umfangreich, wie das zb. Selenium macht. Trotzdem können wir mit einem Client prüfen, ob zum Beispiel Formulare erfolgreich abgeschickt und die Daten erfolgreich eintragen wurden.

Datei anlegen

Wir legen die Datei event_manager/events/tests/test_views.py an und füllen sie mit folgendem Inhalt:

from django.test import Client, TestCase
from django.urls import reverse
from django.utils import timezone

from events.factories import CategoryFactory
from core.factories import UserFactory
from events.models import Event


def create_user():
    return UserFactory()


class EventFormTests(TestCase):

    def setUp(self):
        author = create_user()
        self.client = Client()
        self.client.force_login(author)
        self.cat = CategoryFactory()

        self.payload = {
            "name": "Seilspringen und Boxen",
            "description": "Spaß muss sein",
            "sub_title": "Seilspringen für Anfänger",
            "min_group": 10,
            "date": timezone.now(),
        }

        def test_if_event_is_correct_saved(self):
            res = self.client.post(
                reverse("events:create_event2", args=(self.cat.id,)),
                self.payload,
            )
            # wird weitergeleitet nach Anlegen?
            self.assertEquals(res.status_code, 302)

            # existiert Objekt in Datenbank?
            event = Event.objects.get(name=self.payload["name"])

            # ist der slug richtig?
            self.assertEqual(event.slug, "seilspringen-und-boxen")

        def test_invalid_min_group_number(self):
            """Test if object is created via form with a invalid min group
            only 5, 10 and 20 is allowed. No Redirection, Status 200
            """
            self.payload["min_group"] = 7

            res = self.client.post(
                reverse("events:create_event2", args=(self.cat.id,)),
                self.payload,
            )
            self.assertEquals(res.status_code, 200)
            events = Event.objects.all()  # lfilter(name=self.payload["name"])
            self.assertFalse(events.exists())

Testabdeckung prüfen

Um die Testabdeckung zu testen, dh. um zu prüfen, ob alle Klassen und Funktionen im Test geprüft wurden, nutzen wir das Paket coverage. wir fügen das coverage-Paket der event_manager/requirements-dev.in hinzu und installieren es.

...
coverage
(eventenv)  pip-compile requirements.in requirements-dev.in
(eventenv)  pip-sync requirements.txt requirements-dev.txt

.coveragerc

Tests- und Migrationsdateien wollen wir ignorieren. Dazu legen wir unter event_manager/.coveragerc die Configurationsdatei für coverage an und kopieren folgende Settings dort hinein:

[run]
source = .
omit =
    ./env/*
    */tests/*
    *apps.py
    manage.py
    *__init__.py
    */migrations/*
    *asgi*
    *wsgi*
    *urls.py

[report]
omit =
    ./env/*
    */tests/*
    *apps.py
    manage.py
    *__init__.py
    */migrations/*
    *asgi*
    *wsgi*
    *urls.py

show_missing = True

[html] directory = tests/coverage/html_report/

in .gitignore:

Alle Files, die mit dem Coverage zusammenhängen, werden in der Versionskontrolle ignoriert. Die .coveragerc wird allerdings nicht ignoriert, da hier die Einstellungen eingetragen werden.

Dazu folgende Zeilen in die .gitignore eintragen.

htmlcov/
html_report/
.coverage
.coverage.*
coverage.xml
*.cover

Testdurchlauf mit Coverage starten

coverage erase
coverage run manage.py test

# oder verbose ...
coverage run manage.py test -v 2

Coverage hat nun eine .coverage Datei angelegt, in der sich die Ergebnisse des Testlaufs finden. Diese Datei ist allerdings eine Binärdatei, mit der sich so erstmal nicht viel anfangen lässt.

einfachen Report printen:

Um einen einfachen Report auf der Shell auszudrucken, genügt dieser Befehl:

coverage report

coverage html

Um eine etwas komfortablere Ausgabe zu haben, kann man sich die Daten auch als HTML-Report erstellen lassen.

Dazu statt coverage report einfach coverage html eingeben. Das verzeichnis tests/coverage/html_report/ wird erstellt. Um den Inhalt zu betrachen, starten wir in diesem Verzeichnis den Python-Webserver und rufen dann mit dem Browser die Adresse http://127.0.0.1:8080 auf:

coverage html
cd tests/coverage/html_report
python -m http.server 8080

Coverage Daten löschen

Um alle Daten, die von coverage angelegt wurden zu löschen, kann man diesen Befehl nutzen:

coverage erase

Die Coverage Arten

Vereinfacht ausgedrückt ist die Codeabdeckung ein Maß für die Vollständigkeit einer Testsuite. 100 % Codeabdeckung bedeutet, dass ein System vollständig getestet ist. Die Testabdeckung sagt allerdings nichts über die Qualität der Tests aus.

Statement Coverage

Einfachste Coverage Art. Prüft, ob jede Zeile des Code in einem Test mindestens einmal gelaufen ist. Damit sind oft Coverages von 100% möglich.

Das Statement Coverate berechnet sich in Prozent wie folgt:

(nr_of_code_lines_run_at_least_once / total_number_of lines) * 100%

Branch Coverage

If-Blöcke, sogenannte Branches, werden im Branch Coverage auch geprüft. Der Zweck der Verzweigungsabdeckungsanalyse besteht darin, die logischen Verzweigungen bei der Ausführung des Codes zu verfolgen und anzuzeigen, ob einige logische Pfade während des Testlaufs nicht ausgeführt werden.

Das Branch Coverage berechnet sich in Prozent wie folgt:

(nr_of_branches_executed_at_least_once  / total_number_of lines) * 100%

Branch Covering in der .coveragerc festlegen.

[run]
branch = True

Dann wie gewohnt den Test via coverage starten

Weiterführende Infos:

Coverage Doku Django templates Coverage