Testdaten generieren
Es macht Sinn, schon in der Entwicklungsphase mit größeren Datenmengen zu arbeiten. Wir wollen mit 10 verschiedenen Usern arbeiten, die Events in Kategorien anlegen. Später Sollen diese Testuser für bestehende Events auch noch Reviews schreiben können.
Damit wir diese Testuser nicht alle händisch eintragen können, schreiben wir uns ein eigenes Management-Command, dass uns Testuser erstellt.
Verzeichnis für ein Managment-Commando anlegen
Ein Management-Commando in Django wird so aufgerufen:
python manage.py create_user
Dieses Kommando müssen wir allerdings erstmal entwickeln.
Dazu erstellen wir uns im Verzeichnis user
folgende Ordner- und Dateistruktur.
Die Datei create_user.py
ist erstmal völlig leer.
├───management
│ └───commands
│ │ create_user.py
ein erstes Commando
Jedes eigene Kommando erbt von BaseCommand. Die Methode handler
wird
überschrieben und mit eigenem Code angereichert. Wir schreiben nun folgenden
Code in die Datei event_manager/user/management/commands/create_user.py
.
from django.core.management.base import BaseCommand
from django.db import transaction
class Command(BaseCommand):
def handle(self, *args, **options):
print("Hello World!")
Jetzt können wir unser Kommando mal testweise ausführen. Dazu
starten wir manage.py mit dem Namen der Datei, also create_user
,
allerdings ohne Dateisuffix .py
python manage.py create_user
Das war doch einfach! Jetzt können wir loslegen, um Testuser im
System anzulegen. Dazu müssen wir erstmal factory-boy
installieren.
Factory-Boy hilft uns dabei, möglichst realitätsnahe Zufallsdaten zu erstellen, die wir dann
als Testdaten nutzen können.
Management Commands für Skripte und Tools
Django’s Management Commands sind der ideale Ort, um kleine Helferprogramme
zu starten wie Imports, starten von asynchronen Jobmanagern und vielem
mehr. Da BaseCommand
auch das äußerst beliebte argparse 1 aus der Python
Standard-Bibliothek implementiert, kann man die Commands auch mit
Argumenten aufrufen, wie wir unten gleich sehen werden.
Das Projekt kann dann nahezu vollständig über manage.py
gesteuert werden.
Die User-Fabrik
Factory-Boy
Wir werden Factory Boy
verwenden, um Dummy-Daten für unsere App zu generieren.
Bei Factory Boy handelt es sich um eine Bibliothek, die für automatisierte Tests
entwickelt wurde, aber auch für diesen Anwendungsfall gut funktioniert.
Factory Boy kann leicht konfiguriert werden, um zufällige, aber realistische
Daten wie Namen, E-Mails und Absätze zu generieren, da intern die
Faker-Bibliothek
verwendet wird.
Wir erstellen sogenannte Factories
,
die wir als Django-Model-Klasse anlegen. So kann man sich zum Beispiel eine UserFactory
erstellen, die Testdaten für einen User erstellt.
Diese UserFactory
werden wir später auch zum Erstellen von Testusern bei den Integrationstests mit dem Unittest-Client verwenden.
Eine Übersicht aller Standard-Faker findet sich hier: https://faker.readthedocs.io/en/master/providers.html
Faker
bzw. dessen Provider bieten die Erstellung von allen möglichen
Testdaten. Sogar die Lokalisierung auf eine Sprache ist möglich, wenn man
zum Beispiel nur deutsche Usernamen generieren möchte. Wir nutzen den
default-Case, und der ist englisch.
Installieren wir nun factory-boy
in unser Environment. Wir fügen in unsere
requirements-dev.in
Datei den Eintrag factory-boy
hinzu, kompilieren die Datei und erstellen
damit unsere requirements-dev.txt
.
In einem weiteren Schritt installieren wir die nötigen Pakete mit pip-compile
.
In die requirements-dev.in
fügen wir folgenden Eintrag ein:
-c requirements.txt
factory-boy
Um aus den in-Dateien die endgültigen Requierements.txt-Dateien zu machen, müssen diese wieder compiliert werden.
(eventenv) pip-compile requirements-dev.in
Danach wirden die neuen Einträge installiert (darauf achten, auch die requirements.txt zu syncen, da sonst Pakete fehlen würden).
(eventenv) pip-sync requirements.txt requirements-dev.txt
Um Dummy-Daten zu erstellen, müssen wir eine sogenannte Fabrik
erstellen, die uns Dummy-Daten generiert.
Dazu legen wir die Datei event_manager/user/factories.py
an und füllen Sie mit folgendem Inhalt:
import factory
from django.contrib.auth import get_user_model
from django.contrib.auth.hashers import make_password
class UserFactory(factory.django.DjangoModelFactory):
"""Eine Fabrik-Klasse um User zu generieren."""
class Meta:
model = get_user_model()
username = factory.Faker("user_name", locale="de_DE")
email = factory.Faker("email", locale="de_DE")
password = factory.LazyFunction(lambda: make_password("abc")
Ein User hat drei Attribute, die zwingend erforderlich sind, um ihn später auch
nutzen zu können: email-Adresse, Passwort und einen Usernamen. Die User-Namen
sollen einen deutschen Touch haben, deshalb nutzen wir hier locale="de_DE"
.
Gleiches gilt für die email-Adressen.
Factory-Boy implementiert einige Hilfsmodelle für verschiedene ORMs, neben SQL-Alchemy
auch für Django.
Um einen reibungslosen Ablauf mit Django zu gewährleisten,
muss unsere User-Fabrik also von factory.django.DjangoModelFactory
erben.
In der Meta-Klasse
der UserFactory
beziehen wir uns auf das aktuelle
in den Settings gewählte User-Model get_user_model()
.
Die Meta-Klasse kennen wir schon von dem Event-Model
und werden sie später auch bei der Formularverarbeitung wiedersehen.
Wir wollen nun email-Daten faken
, d.h. randomisiert erstellen, sowie
jedem Testuser zu Testzwecken das (gut zu merkende) Passwort abc zuweisen (damit wir uns später auch
über diesen User einloggen können, um Events zu verfassen oder
Reviews zu schreiben). Da das Passwort nur gehasht in die Datenbank darf,
führen wir noch make_password
aus django.contrib.auth.hashers
aus.
Mehr dazu in der Doku von Factory-Boy: https://factoryboy.readthedocs.io/en/stable/orms.html#django
Das Commando ausbauen
Wir ersetzen jetzt den Inhalt der Datei event_manager/user/management/commands/create_user.py
mit folgenden Inhalt:
from django.core.management.base import BaseCommand
from django.db import transaction
from django.contrib.auth import get_user_model
from user.factories import UserFactory
User = get_user_model()
class Command(BaseCommand):
def add_arguments(self, parser) -> None:
"""Die Argumente für die Anzahl der User festlegen."""
parser.add_argument("-n",
"--number",
type=int,
help="Amount of users to be generated",
required=True)
parser.epilog = "Usage: python manage.py create_user -n 10"
@transaction.atomic
def handle(self, *args, **options):
# delete all users except admin user
User.objects.exclude(username="admin").delete()
# das geparste Kommandozeilenargument -n
number = options.get("number")
if number and number > 0:
for _ in range(number):
p = UserFactory()
print(f"=> {p}")
print(f"{number} User erfolgreich angelegt!")
Zuerst importieren wir die nötigen Module: unsere eben erstellte Fabrikklasse, das BaseCommand sowie die transaction und eine Helferfunktion zum Referenzieren auf das aktuelle UserModel.
Unsere Klasse Command
startet mit der Methode add_arguments
, die uns erlaubt, Argumente für einen Kommandozeilenparser
zu spezifizieren. Wir können das Management-Kommando später mit python manage.py create_user -n 20
starten und damit 20 User erstellen.
Django implementiert für diesen Zweck das brilliante argparse
-Modul aus der Python Standard-Bibliothek.
In der Methode handle
löschen wir alle bestehenden User-Objekte bis auf das Objekt mit dem Namen admin
, also dem Adminuser.
Danach legen wir die mit der übergebenen Anzahl festgelegten User an.
Für die User in usernames
werden jetzt die entsprechenden Testuser
angelegt. Zuvor werden alle User bis auf den Adminuser gelöscht. Die
Befehlsfolge implementieren wir hier als Transaktion
.
Was sind Transaktionen?
In der Datenbankentwicklung bezeichnet eine Transaktion
eine Gruppe von
Teilaktionen, die in einer oder mehreren Datenbanktabellen erfolgreich
ausgeführt werden müssen, bevor sie endgültig festgeschrieben (Commit)
werden
können. Falls eine der Teiltransaktionen fehl schlägt, werden die anderen
Teilaktionen entweder nicht mehr durchgeführt, oder rückgängig gemacht
(Rollback).
Somit wird vermieden, dass Inkonsistenzen in der Datenbank entstehen.
Django bietet mit dem @transaction.atomic
-Dekorator eine einfache
Möglichkeit, genau solche Transaktionen zu erstellen.
Vorsicht ist geboten, wenn es sich um Views mit hohem Traffic handelt:
atomische Tranksaktionen sind nicht kostenlos und kosten unter Umständen
Performance. Ihr Einsatz sei gut abgewägt.
Mehr zu Transaktionen in Django in der Doku: https://docs.djangoproject.com/en/stable/topics/db/transactions/
Kommando auf der Konsole ausführen
Jetzt können wir unser Kommando nochmal ausführen.
python manage.py create_user -n 10
Das Programm startet jetzt und erstellt uns 10 Testuser, die Namen variieren natürlich:
$ python manage.py create_user -n 10
=> randolfklapp
=> lilianbiggen
=> yplath
=> qortmann
=> oscarscholtz
=> niklassoelzer
=> mechtildwinkler
=> wkoch-ii
=> adelgunde30
=> eleonoreeckbauer
10 User erfolgreich angelegt!
Wir sollten jetzt 10 Testuser in der Datenbank haben. Das prüfen wir entweder in der Admin oder auf der shell
>>> from django.contrib.auth import get_user_model
>>> get_user_model().objects.all()
<QuerySet [<User: admin>, <User: Bob>, <User: Alice>, ...]>
get_user_model
liefert uns das aktuelle User-Model. Auf dem führen wir via dem Manager objects
die Methode all
aus.
Grundsätzlich sollte man immer get_user_model
nutzen, wenn man Zugriff auf die User-Klasse benötigt, da diese Funktion immer das aktuell festgelegte User-Model nimmt.
Factories für Events und Categories
Nun wollen wir natürlich auch noch ein Kommando erstellen, um Kategorien und Events anzulegen.
Dazu legen wir im Verzeichnis events
diese Dateistruktur an:
├───management
│ └───commands
│ │ create_events.py
und füllen event_manager/events/management/commands/create_events.py
mit folgendem Inhalt:
"""
Generating Event Data.
This module provides a management command to generate random
event data built with factory boy and the faker libary.
"""
import random
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
from django.db import transaction
from events.factories import CategoryFactory
from events.factories import EventFactory
from events.models import Category
from events.models import Event
CATEGORIES = 4
EVENTS = 20
class Command(BaseCommand):
def add_arguments(self, parser):
parser.description = "Generate Random Events and Categories"
parser.add_argument(
'-e',
'--events',
type=int,
help='Number of events to be generated',
)
parser.add_argument(
'-c',
'--categories',
type=int,
help='Number of categories to be generated, max is 10',
)
parser.epilog = (
"Usage example: python manage.py create_events events=100 "
"categories=10"
)
@transaction.atomic
def handle(self, *args, **options):
num_events = n if (n := options.get("events")) else EVENTS
num_categories = n if (
n := options.get("categories")) else CATEGORIES
if any([num_events < 0,
num_categories < 0,
num_categories > 10
]):
raise ValueError(("Negative Werte nicht erlaubt. Nur maximal 10"
" Kategorien"))
print(
f"Generating {num_events=} {num_categories=} "
)
user_list = get_user_model().objects.all()
if not user_list:
print("Es existieren keine User im System.")
print("Bitte führe erst python manage.py set_testusers aus")
raise SystemExit(1)
print("Lösche Model Data...")
for m in [Event, Category]:
m.objects.all().delete()
print("Erstelle Kategorien...")
categories = CategoryFactory.create_batch(num_categories)
print("Erstelle Events...")
for _ in range(num_events):
event = EventFactory(
category=random.choice(categories),
author=random.choice(user_list),
)
Ok, hier ist eine Menge passiert. Sehen wir uns das mal an.
Zuerst einmal importieren wir alle nötigen Pakete und Module in unser Programm.
Dazu gehört auch das random
-Modul, da wir Events zufällig Kategorien
zurordnen wollen. Die Fabriken fehlen allerdings noch.
import random
import factory
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
from django.db import transaction
from events.factories import CategoryFactory
from events.factories import EventFactory
from events.models import Category
from events.models import Event
Das Kommando soll später mit Argumenten aufgerufen werden können, zum Beispiel
python manage.py create_events events=100 categories=4
. Allerdings wollen
wir das optional lassen und dem User Defaultwerte bieten.
CATEGORIES = 4
EVENTS = 20
Die Klasse Command
hat nun eine neue Methode: add_arguments
. Dieser
Methodenname ist generisch und in dieser Methode geben wir Argumente an, die
an das Programm übergeben werden können.
Mit parser.description
geben wir wie schon zuvor dem Argumentparser erstmal eine Beschreibung, die aufgerufen
wird, wenn zum Beispiel python manage.py create_events --help
ausgeführt
wird.
Dann erstellen wir zwei optionale Arugmente, die als int
geparste
und die via der help
-Eigenschaft ebenfalls in der Hilfe angzeigt werden. Diese beiden Argumente bezeichnen die Anzahl der Events sowie der
Kategorien.
class Command(BaseCommand):
def add_arguments(self, parser):
parser.description = "Generate Random Events and Categories"
parser.add_argument(
'-e',
'--events',
type=int,
help='Number of events to be generated',
)
parser.add_argument(
'-c',
'--categories',
type=int,
help='Number of categories to be generated, max is 10',
)
parser.epilog = (
"Usage example: python manage.py create_events events=100 "
"categories=10"
)
Wenn wir help für dieses Subkommando ausführen …
python manage.py create_events --help
erhalten wir diese Ausgabe (gekürzt), wie das Subkommando zu nutzen ist:
usage: manage.py create_events [-h] [-e EVENTS] [-c CATEGORIES] [--version]
[--no-color] [--force-color] [--skip-checks]
Generate Random Events and Categories
options:
-h, --help show this help message and exit
-e EVENTS, --events EVENTS
Number of events to be generated
-c CATEGORIES, --categories CATEGORIES
Number of categories to be generated, max is 10
Usage example: python manage.py create_events events=100 categories=10
In der handle
-Methode fragen wir erstmal die Argumente ab und prüfen, ob
sie in dem erlaubten Bereich liegen. Das heisst, die Zahl darf nicht negativ
sein und die Anzahl an Kategorien darf 10 nicht übersteigen. Die Kategorien
wollen wir später nämlich als Wortliste anlegen und nicht völlig zufällig
erzeugen. Hier kommt übrigens mal der neue Walrus-Operator :=
sinnvoll zum Einsatz.
num_events = n if (n := options.get("events")) else EVENTS
num_categories = n if (
n := options.get("categories")) else CATEGORIES
if any([num_events < 0,
num_categories < 0,
num_categories > 10]
):
raise ValueError(("Negative Werte nicht erlaubt. Nur maximal 10"
" Kategorien"))
Im nächsten Schritt selektieren wir die aktuellen User, die wir ja vorher schon
erstellt hatten. Befinden sich aktuell keine User im System, beendet das
Programm mit einem SystemExit
. Die user_list
benötigen wir für das
Erstellen eines Events, um aus ihr einen zufälligen User zu ziehen.
user_list = get_user_model().objects.all()
if not user_list:
print("Es existieren keine User im System.")
print("Bitte führe erst python manage.py create_user aus")
raise SystemExit(1)
Nun löschen wir alle bisherigen Events und Kategorien …
print("Lösche Model Data...")
for m in [Category, Event]:
m.objects.all().delete()
Übrigens hätten wir die Events an dieser Stelle gar nicht löschen müssen, da die Events via einem ForeignKey mit den Kategorien
in Beziehung stehen. Und da wir beim Erstellen dieser Verbindung models.CASCADE
gewählt hatten, also das kaskadierende Löschen, würden alle Events ebenfalls beim Löschen der
Kategorien mitgelöscht. Aber sicher ist sicher, man weiß ja nie, was später noch passiert.
Danach erstellen wir Testdaten mit der (noch nicht existenten Category- sowie
Event-Factory). Die erstellten Kategorien sammeln wir dann in der Liste
categories
, aus der wir später beim Erstellen eines Events zufällig einen
Eintrag ziehen. Dazu hatten wir die Methode create_batch
genutzt und ihr
als Argument die Anzahl der Kategorien eingegeben, die der User erstellen will.
Beim Erstellen der Events ziehen wir zufällig eine Kategorie und einen User und
übergeben diese Werte dem EventFactory
- Konstruktor.
print("Erstelle Kategorien...")
categories = CategoryFactory.create_batch(num_categories)
print("Erstelle Events...")
for _ in range(num_events):
event = EventFactory(
category=random.choice(categories),
author=random.choice(user_list),
)
Nun fehlen uns natürlich noch die Factories. Dazu legen wir die Datei
event_manager/events/factories.py
an und füllen sie mit folgendem Inhalt:
import random
from datetime import timedelta
import factory
import faker
from user.factories import UserFactory
from django.contrib.auth import get_user_model
from django.utils import timezone
from . import models
User = get_user_model()
categories = [
"Sports",
"Talk",
"Cooking",
"Freetime",
"Hiking",
"Movies",
"Travelling",
"Science",
"Arts",
"Pets",
"Music",
"Wellness",
"Religion",
]
class CategoryFactory(factory.django.DjangoModelFactory):
"""Erstellt eine Kategorie aus einer vorgegebenen Liste."""
class Meta:
model = models.Category
name = factory.Iterator(categories)
sub_title = factory.Faker("sentence", locale="de_DE")
description = factory.Faker("paragraph", nb_sentences=20, locale="de_DE")
class EventFactory(factory.django.DjangoModelFactory):
"""Event Fabrik zum Erstellen eines neuen Events."""
class Meta:
model = models.Event
# Author und Category werden nur erzeugt, wenn sie beim Erstellen der
# Factory nicht überschrieben werden.
author = factory.SubFactory(UserFactory)
category = factory.SubFactory(CategoryFactory)
name = factory.Faker("sentence")
sub_title = factory.Faker("sentence", locale="de_DE")
description = factory.Faker("paragraph", nb_sentences=20, locale="de_DE")
min_group = factory.LazyAttribute(lambda _: random.choice(list(models.Event.Group)))
is_active = factory.Faker("boolean", chance_of_getting_true=50)
date = factory.Faker(
"date_time_between",
end_date=timezone.now() + timedelta(days=60),
start_date=timezone.now() + timedelta(days=1),
tzinfo=timezone.get_current_timezone(),
)
Das Erstellen der Company- und Event-Objekte ist im Grunde relativ straight
forward. Die CategoryFactory
zieht über den Iterator
einen Eintrag aus
der categories
-Liste, die wir vorweg angegeben hatten. Wir wollen in unserem
Testsystem nämlich möglichst realistische Kategorienamen haben und deshalb
haben wir diese hier selbständig vergeben.
Die EventFactory
ist ein klein wenig komplizierter. Das min-group
-Attribut
wird aus dem Event-Model per random.choice
zufällig gezogen. Das date
-Attribut liegt zwischen
einem Datum von heute und 60 Tage in der Zukunft.
Jetzt können wir Testdaten generieren und prüfen, ob sie in der Datenbank vorhanden sind.
python manage.py create_events --events 20 --categories 5
Wenn wir unsere Datenbank öffnen und die Tabelle events_events
ansteuern,
sehen wir eine ganze Menge Einträge:
Zugegeben, die Namen und Beschreibungen machen keinen Sinn, aber immerhin haben wir valide Testdaten, um unser System besser zu testen.
Optional: Ein Makefile erstellen
Ein kleiner Hinweis an Linux / Unix-Nutzer: wem das Kompilieren und Syncen mit
pip-tools auf die Dauer zu lästig wird, kann sich für diese Aufgabe auch ein
Makefile
anlegen. Dazu unter event_manager/Makefile
das File anlegen
und folgenden Code eintragen:
install:
@pip install -r requirements.txt -r requirements-dev.txt
compile:
@rm -f requirements*.txt
@pip-compile --resolver=backtracking requirements.in
@pip-compile --resolver=backtracking requirements-dev.in
sync:
@pip-sync requirements*.txt
Wir haben im Makefile nun drei Kommandos install
, compile
und
sync
.
Um nun zu Kompilieren uns anschliessend zu Syncen muss nur folgendes ausgeführt
werden:
make compile
make sync
Optional: Das Projekt in der Version v0.1 kopieren
Wer bisher mitgecoded hat, sollte das Projekt eigentlich zum Laufen gebracht haben. Falls es doch irgendwo einen Fehler gibt, den man nicht behoben bekommt, oder einfach nur so, kann sich das Projekt im aktuellen Zustand auch klonen oder runterladen.
Version v0.1 via github clonen
git clone -b v0.1 git@github.com:realcaptainsolaris/event_project.git
und dann nicht vergessen, ein virtuelles Environment anzulegen und zu migrieren:
python -m venv .envs/eventenv
pip install pip-tools
pip-sync requirements.txt requirements-dev.txt
python manage.py migrate
python manage.py runserver
Version v0.1 als zip-Datei runterladen
https://github.com/realcaptainsolaris/event_project/archive/refs/tags/v0.1.zip
Version v0.1 als tar-Datei runterladen
https://github.com/realcaptainsolaris/event_project/archive/refs/tags/v0.1.tar.gz