Unit 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.
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. Dort hinein kommen unsere Unit-Tests. Dieses Verzeichnis muss ein Python-Package sein, deshalb kommt auch noch eine __init__.py
hinein.
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
from django.core.validators import MinLengthValidator
Dann ändern wir das Event-Model ab:
class Event(DateMixin):
"""Stores a single blog 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)
Views testen¶
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 wurden.
Datei anlegen¶
Wir legen die Datei event_manager/events/tests/test_models.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¶
pip install coverage
pip install django-nose
INSTALLED_APPS = (
# ...
'django_nose',
)
# Use nose to run all tests. May not work on Windows
# on Windows: coverage run --source=events ./manage.py test
# and comment out TEST_RUNNER
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
# Tell nose to measure coverage on the 'events' and 'user' apps
NOSE_ARGS = [
'--with-coverage',
'--cover-package=events,user',
]
Alles mit tests im Namen wird von der Coverage ignoriert, also die ganzen Testklassen- und Methoden bzw. Migrationen.
[run]
omit = *tests*, *migrations*
Alle Files, die mit dem Coverage zusammenhängen, werden in der Versionskontrolle ignoriert. Die .coveragerc
wird nicht ignoriert.
htmlcov/
.coverage
.coverage.*
coverage.xml
*.cover
Testdurchlauf starten¶
python manage.py test
# oder verbose ...
python manage.py test events -v 2
Falls in Windows ein Fehler auftreten sollte, kann man den Test auch so starten:
coverage run --source=events,user ./manage.py test
einfachen Report printen:¶
coverage report
coverage html¶
Nach dem Testlauf coverage html
eingeben. Das verzeichnis htmlcov
wird
erstellt. Öffne htmlcov/index.html
mit dem Browser.
coverage html
Weiterführende Infos:¶
coverage mit nose: https://django-testing-docs.readthedocs.io/en/latest/coverage.html
über statement und branch covering https://lautaportti.wordpress.com/2011/05/07/test-coverage-analysis/
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.
Statement Coverage¶
Einfachste Coverage Art. Prüft, ob jede Zeile des Code in einem Test gelaufen sind. Damit sind oft Coverages von 100% möglich. number_of_code_lines_run_at_least_once_under_tests / total_number_of lines) * 100%
Branch Coverage¶
If-Blöcke, sogenannte Branches, werden auch geprüft. er 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. Selbst bei einer 100-prozentigen Anweisungsabdeckung ist es ziemlich einfach, weniger als 100 % Filialabdeckung zu haben. number_of_branches_executed_at_least_once_under_tests / all_branches) * 100%
.coveragerc anlegen
[run]
branch = True
muss dann so gestartet werden: coverage run –branch –source=events ./manage.py test
Weiterführende Infos:¶
Templates mit dem Plugin für coverage
testen:
https://pypi.org/project/django-coverage-plugin/