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.

Eine User-App erstellen

Alles was mit Usern zu tun hat, soll in der App users liegen. Also legen wir diese App an, in der wir zentral sämtliche benutzerbezogene Logik bündeln, wie zum Beispiel später ein eigenes User-Modell, Authentifizierungslogik, Formulare, Views sowie die zugehörigen Templates.

uv run manage.py startapp users

Unser Verzeichnis sieht jetzt so aus:

event_manager
    ├── manage.py
    ├── db.sqlite3
    ├── events
    ├── users  # << neu

Damit die App auch in unserem Projekt bekannt ist, müssen wir sie in den INSTALLED_APPS der settings.py eintragen: Damit die Templates für die Registrierung von Usern später auch gefunden werden, müssen wir sie ganz oben in der Liste der Apps eintragen, damit Django zuerst in diesem Verzeichnis nach Templates sucht. Dazu später mehr.

So sollte der Eintrag in den INSTALLED_APPS aussehen:

INSTALLED_APPS = [
"users", # <= diesen Eintrag hinzufügen
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"events",
]

Verzeichnis für ein Managment-Commando anlegen

Ein Management-Commando in Django wird so aufgerufen:

uv run manage.py create_user

Dieses Kommando müssen wir allerdings erstmal entwickeln. Dazu erstellen wir uns im neuen Verzeichnis users folgende Ordner- und Dateistruktur. Alle Kommandos müssen in einem Unterordner namens management/commands liegen, damit Django sie als solche auch automatisch erkennt.

Die Datei create_user.py ist erstmal völlig leer.

event_manager
    ├── manage.py
    ├── db.sqlite3
    ├── events
    ├── users
        ├── migrations/
        ├── management
           └───commands
                  create_user.py # << neue Datei

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/users/management/commands/create_user.py.

from django.core.management.base import BaseCommand

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

uv run 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 Integrations- und Unittests 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 unseren Dev-Abhängigkeiten hinzu (produktiv wollen wir das Paket ja nicht nutzen, da es nur für die Entwicklung und Tests relevant ist).

uv add --dev factory-boy

Um Dummy-Daten zu erstellen, müssen wir eine sogenannte Fabrik erstellen, die uns Dummy-Daten generiert.

Dazu legen wir die Datei event_manager/users/factories.py an:

event_manager
    ├── events
    ├── users
        ├── factories.py # << neue Datei
        ├── management
           └───commands
                  create_user.py

und füllen Sie mit folgendem Inhalt:

from typing import Final

import factory
from django.contrib.auth import get_user_model

PASSWORD: Final = "abc"
user_list = ["Bob", "Alice", "Grumpy", "Waldo", "Charlie",
              "Dave", "Eve", "Frank", "Grace", "Heidi",]


class UserFactory(factory.django.DjangoModelFactory):
    """
    Factory zur Erzeugung von User-Instanzen für Tests.

    Ziel:
    - Schnelles Erstellen konsistenter Testdaten
    - Vermeidung von Boilerplate beim Anlegen von Usern
    - Korrekte Behandlung von Passwörtern (Hashing!)

    Unterstützt sowohl:
    - build(): erzeugt ein Objekt ohne DB-Zugriff
    - create(): erzeugt und speichert das Objekt in der Datenbank
    """

    class Meta:
        model = get_user_model()
        django_get_or_create = ("user_name",)
        # Verhindert automatisches Speichern nach post_generation Hooks
        # Wir wollen das selber machen, wegen Password-Hashing
        skip_postgeneration_save = True

    # Liefert nacheinander feste Usernamen aus der user_list
    user_name = factory.Iterator(user_list)

    # Generiert eindeutige E-Mail-Adressen
    email = factory.Sequence(lambda n: f"user{n}@example.com")

    @factory.post_generation
    def password(self, create, extracted, **kwargs):
        """
        Setzt das Passwort korrekt über set_password (inkl. Hashing).

        Verhalten:
        - Wird kein Passwort übergeben → Default-Passwort wird verwendet
        - Wird ein Passwort übergeben → dieses wird gesetzt

        Wichtig:
        - set_password speichert NICHT automatisch
        - Speicherung erfolgt nur bei create(), nicht bei build()
        """
        pw = extracted or PASSWORD
        self.set_password(pw)

        if create:
            self.save()

Ein User hat drei zentrale Attribute, die zwingend erforderlich sind, um ihn sinnvoll verwenden zu können: E-Mail-Adresse, Passwort und einen Usernamen.

Für reproduzierbare Tests vergeben wir keine komplett zufälligen Usernamen, sondern arbeiten mit einer festen, vordefinierten Menge von 10 Namen. Die E-Mail-Adressen hingegen werden automatisch eindeutig generiert.

Factory Boy bringt fertige Basisklassen für verschiedene ORMs mit, unter anderem auch für Django. Damit unsere Factory sauber mit dem Django-ORM zusammenarbeitet, erben wir von factory.django.DjangoModelFactory.

In der Meta-Klasse der UserFactory referenzieren wir das aktuell konfigurierte User-Modell über get_user_model(). Dadurch bleibt die Factory flexibel, selbst wenn später ein eigenes User-Modell eingeführt wird.

Die Meta-Klasse kennen wir bereits aus dem Event-Model und werden sie später auch bei der Formularverarbeitung wiedersehen.

Der @factory.post_generation-Hook wird verwendet, um das Passwort nachträglich korrekt über set_password zu setzen, da es nicht wie ein normales Feld behandelt werden kann.

Passwörter dürfen niemals im Klartext gespeichert werden, sondern müssen gehasht werden. Durch die Verwendung von set_password wird automatisch ein sicherer Hash erzeugt.

Standardmäßig vergeben wir für Testzwecke ein einfaches Passwort (z. B. abc), das bei Bedarf in Tests überschrieben werden kann.

Beispielhafte Verwendung der UserFactory:

# 1. Mit expliziten Werten
user = UserFactory(user_name="max", password="geheim123")
# → erstellt einen User mit gegebenem Usernamen und Passwort

# 2. Ohne Angaben (Default-Verhalten)
user = UserFactory()

# → nutzt vordefinierte Usernamen (Iterator)
# → generiert automatisch eine eindeutige E-Mail
# → setzt Standard-Passwort (z. B. "abc")

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/users/management/commands/create_user.py mit folgenden Inhalt:

from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from users.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: manage.py create_user -n 10"

    def handle(self,  *args, **options):

        # alle User bis auf den Adminuser löschen, damit wir immer mit einem sauberen
        # System starten
        User.objects.filter(is_superuser=False).delete()

        # das geparste Kommandozeilenargument -n
        number = options["number"]

        if not 1 <= number <= 10:
            raise SystemExit("Bitte eine Zahl zwischen 1 und 10 eingeben.")

        for _ in range(number):
            user = UserFactory()
            print(f"=> {user}")

        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. Django implementiert für diesen Zweck das brilliante argparse-Modul aus der Python Standard-Bibliothek.

Wir können das Management-Kommando später mit uv run manage.py create_user -n 10 starten und damit 10 User erstellen. Mehr als 10 User können wir nicht anlegen, da unser UserFactory nur 10 verschiedene Usernamen in der Liste hat. Durch das django_get_or_create = ("user_name",) ist sichergestellt, dass wir keine doppelten Usernamen anlegen können, da die Factory automatisch prüft, ob es bereits einen User mit diesem Namen gibt und diesen dann zurückgibt, anstatt einen neuen anzulegen. Zudem fragen wir im Management-Command die Anzahl der User ab, die angelegt werden sollen, und prüfen, ob sie im erlaubten Bereich liegt.

In der Methode handle löschen wir alle bestehenden User-Objekte bis auf das Objekt mit dem Superuser-Status, also dem Adminuser. Danach legen wir die mit der übergebenen Anzahl festgelegten User an.

Kommando auf der Konsole ausführen

Jetzt können wir unser Kommando nochmal ausführen.

uv run manage.py create_user -n 4

Das Programm startet jetzt und erstellt uns 4 Testuser:

$ uv run manage.py create_user -n 4
=> Bob
=> Alice
=> Grumpy
=> Waldo
4 User erfolgreich angelegt!

Wir sollten jetzt 4 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 auch 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:

"""
Erzeugen von Event-Daten

Dieses Modul stellt ein Management-Kommando bereit, um zufällige Event-Daten zu generieren.
Dabei werden ``factory_boy`` und die Bibliothek ``Faker`` verwendet.
"""

import random

from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand

from events.factories import CategoryFactory, EventFactory
from events.models import Category, Event


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",
            required=True,
        )
        parser.add_argument(
            "-c",
            "--categories",
            type=int,
            help="Number of categories to be generated, max is 10",
            required=True,
        )
        parser.epilog = "Usage example: python manage.py create_events -e 10 -c 3"

    def handle(self, *args, **options):
        num_events: int = options["events"]
        num_categories: int = options["categories"]

        if num_events < 0 or not 0 <= num_categories <= 10:
            raise SystemExit(
                "Nur nicht-negative Werte und maximal 10 Kategorien erlaubt."
            )

        print(f"Generating events={num_events}, categories={num_categories}")

        User = get_user_model()
        users = list(User.objects.all())

        if not users:
            raise SystemExit(
                "Keine User vorhanden. Bitte zuerst: manage.py set_testusers"
            )

        print("Lösche vorhandene Daten...")
        Event.objects.all().delete()
        Category.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(users),
            )
            print(f"=> {event}")

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

from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand

from events.factories import CategoryFactory, EventFactory
from events.models import Category, Event

Das Kommando soll später mit Argumenten aufgerufen werden können, zum Beispiel python manage.py create_events events=100 categories=4.

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 manage.py create_events --help ausgeführt wird.

Dann erstellen wir zwei 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",
            required=True,
        )
        parser.add_argument(
            "-c",
            "--categories",
            type=int,
            help="Number of categories to be generated, max is 10",
            required=True,
        )
        parser.epilog = "Usage example: manage.py create_events -e 10 -c 3"

Wenn wir help für dieses Subkommando ausführen …

uv run 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: 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.

num_events: int = options["events"]
num_categories: int = options["categories"]

if num_events < 0 or not 0 <= num_categories <= 10:
    raise SystemExit(
        "Nur nicht-negative Werte und maximal 10 Kategorien erlaubt."
    )

print(f"Generating events={num_events}, categories={num_categories}")

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 users benötigen wir für das Erstellen eines Events, um aus ihr einen zufälligen User zu ziehen.

users = get_user_model().objects.all()

if not users:
    print("Es existieren keine User im System.")
    print("Bitte führe erst manage.py create_user aus")
    raise SystemExit(1)

Nun löschen wir alle bisherigen Events und Kategorien, damit wir mit einem sauberen System starten können.

print("Lösche vorhandene Daten...")
Event.objects.all().delete()
Category.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

event_manager
    ├── events
        ├── factories.py # << neue Datei
        ├── management
           └───commands
                  create_events.py

an und legen erstmal eine CategoryFactory an:

from datetime import timedelta

from django.contrib.auth import get_user_model
from django.utils import timezone
import factory

from users.factories import UserFactory
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
        django_get_or_create = ("name",)

    name = factory.Iterator(categories)
    sub_title = factory.Faker("sentence")
    description = factory.Faker("paragraph", nb_sentences=20)

Die CategoryFactory erzeugt Kategorien auf Basis einer vordefinierten Liste von Namen und ergänzt diese automatisch um generierte Untertitel und Beschreibungen mittels Faker. Durch django_get_or_create wird sichergestellt, dass keine doppelten Kategorien mit identischem Namen erstellt werden.

Erstellen wir nun die EventFactory in der selben Datei event_manager/events/factories.py:


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")
    description = factory.Faker("paragraph", nb_sentences=20)
    min_group = factory.Faker("random_element", elements=models.Event.Group)

    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(),
    )

Diese generiert Events mit zufälligen Titeln, Beschreibungen, Daten, Teilnehmerzahlen sowie Verknüpfungen zu bestehenden Kategorien und Usern. Das Attribut min_group wird zufällig aus den erlaubten Werten des Event-Modells gezogen, während das Datum innerhalb eines realistischen Zeitraums liegt (zwischen heute und 60 Tagen in der Zukunft).

Beziehungen wie author und category werden automatisch über SubFactory erstellt, sofern sie nicht explizit beim Aufruf gesetzt werden. Deshalb hatten wie die UserFactory importiert.

Jetzt können wir Testdaten generieren und prüfen, ob sie in der Datenbank vorhanden sind.

uv run 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:

../_images/events_created_db.png

Zugegeben, die Namen und Beschreibungen machen keinen Sinn, aber immerhin haben wir valide Testdaten, um unser System besser zu testen.

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:

uv sync
uv run manage.py migrate
uv run manage.py createsuperuser
uv run manage.py create_user -n 10
uv run manage.py create_events --events 20 --categories 5
uv run 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