Skip to main content

Génération de fixtures et tests d'API auto-générés pour Django.

Project description

Forge Test

Génération de fixtures et tests d'API auto-générés pour Django + DRF.

Forge Test élimine le boilerplate des tests d'endpoints REST. Au lieu d'écrire une méthode test_* par cas, vous décrivez une configuration déclarative — Forge Test génère, exécute et nomme les tests pour vous.

Sommaire


Composants

Composant Rôle
ForgeModelFactory Génère des instances de modèles Django avec des données fake cohérentes
ForgeCase Classe de base qui transforme une config en suite de tests TestCase
forge_types Types (TypedDict) qui documentent et valident la config dans l'IDE

ForgeModelFactory

Installation rapide

from forge_test.public.helpers import ForgeModelFactory

factory = ForgeModelFactory(
    max_depth=5,
    create_m2m=True,
    m2m_count=2,
    max_retries=3,
)

Paramètres disponibles

Paramètre Type Défaut Description
max_depth int 5 Profondeur max de résolution des relations (anti-récursion infinie)
create_m2m bool True Crée des objets liés pour les champs ManyToManyField
m2m_count int 2 Nombre d'objets créés par relation M2M
max_retries int 3 Tentatives avant abandon sur IntegrityError
person_name_fields List[str] [] Champs remplis avec un nom de personne (Faker.name())
establishment_name_fields List[str] [] Champs remplis avec un nom d'entreprise (Faker.company())
text_description_fields List[str] [] Champs remplis avec une phrase de longueur contrôlée
text_word_range Tuple[int, int] (10, 50) Bornes (min, max) du nombre de mots pour text_description_fields
fill_images bool False Génère une image pour les ImageField optionnels. Les champs requis sont toujours remplis, indépendamment de ce flag
image_dimensions Tuple[int, int] (800, 600) Dimensions des images générées

Exemple — personnaliser la génération de noms

factory = ForgeModelFactory(
    person_name_fields=["full_name", "contact_name"],
    establishment_name_fields=["shop_name"],
    text_description_fields=["bio", "description"],
    text_word_range=(15, 30),
)

shop = factory.create(Shop)
# shop.full_name      -> "Claire Dupont"   (Faker.name)
# shop.shop_name       -> "Dupont & Fils"   (Faker.company)
# shop.description    -> phrase de 15 à 30 mots

Méthodes principales

create(model, **overrides) -> M

Crée et persiste une instance en base, avec gestion automatique des FK/O2O/M2M.

user = factory.create(User)
# user est un User réellement sauvegardé en base, avec toutes ses FK résolues

user_custom = factory.create(User, email="custom@test.com", first_name="Alice")
# les valeurs passées en kwargs écrasent celles générées par la factory

Exemple avec relations imbriquées :

# Si Order a une FK vers Customer, et Customer une FK vers Address,
# create() résout toute la chaîne automatiquement (jusqu'à max_depth)
order = factory.create(Order)
print(order.customer.address.city)  # une vraie valeur générée, pas None

build(model, **overrides) -> M

Construit une instance sans la sauvegarder.

draft = factory.build(User)
print(draft.pk)  # None — rien n'est en base

draft.email = "verifie@avant-save.com"
draft.save()

generate_fields_dict(model, fields=None, **overrides) -> Dict[str, Any]

Retourne un simple dict de valeurs — utile pour le body d'une requête POST/PATCH, sans toucher la base.

payload = factory.generate_fields_dict(
    User,
    fields=["first_name", "last_name", "email"],
)
# {'first_name': 'Alice', 'last_name': 'Dupont', 'email': 'alice123@test.com'}

response = client.post(url, payload, content_type="application/json")

Exemple — tous les champs, avec un override :

payload = factory.generate_fields_dict(User, email="forced@test.com")
# tous les champs du modèle sont générés, sauf 'email' qui vaut 'forced@test.com'

Gestion des fichiers et images

Cas Comportement
ImageField requis (null=False, blank=False) Image générée systématiquement, peu importe fill_images
ImageField optionnel Dépend de fill_images (True -> image, False -> None)
FileField requis, extensions non-image (pdf, csv, etc.) Fichier généré selon l'extension acceptée
FileField requis, extensions image uniquement Image générée, même si fill_images=False
FileField optionnel, extensions image uniquement Dépend de fill_images

Règle générale : un champ requis est toujours rempli. fill_images ne s'applique qu'aux champs optionnels.

factory = ForgeModelFactory(fill_images=False)

# avatar = models.ImageField(null=True, blank=True)   -> None
# photo_identite = models.ImageField()                -> image générée (requis)

user = factory.create(User)
print(user.avatar)           # None
print(user.photo_identite)   # InMemoryUploadedFile

ForgeCase

Principe

from forge_test.public.helpers import ForgeCase, ConfigForgeCase

class MyEndpointTests(ForgeCase):
    config: ConfigForgeCase = {
        "factory_params": {"max_depth": 7, "create_m2m": True},
        "tests": [
            {
                "path_name": "api:resource-detail",
                "method": "GET",
                "fixture": {"model": MyModel, "object_name": "obj"},
                "reverse_params": {"kwargs": {"pk": lambda t: t.obj.pk}},
                "expected_responses": {
                    200: {"authenticated": True, "expected_fields": ["id", "name"]},
                    401: {"authenticated": False},
                },
            },
        ],
    }

Pas de méthode test_* à écrire : ForgeCase les génère automatiquement à la définition de la classe, un test par scénario par status code.

Anatomie d'une entrée tests

Clé Type Description
path_name str Nom de la route Django (reverse())
method "GET" | "POST" | "PUT" | "PATCH" | "DELETE" Méthode HTTP
test_name str (optionnel) Override du nom généré (sinon dérivé de path_name)
fixture Fixture (optionnel) Instance créée avant la requête, accessible via object_name
reverse_params ReverseParams (optionnel) kwargs/args/query pour reverse()
http_client_params HTTPClientParams (optionnel) Paramètres transmis au client de test
expected_responses Dict[int, Scenario | List[Scenario]] Cœur de la config — voir ci-dessous

expected_responses

Chaque status code est une clé. La valeur est soit un scénario unique, soit une liste de scénarios, pour couvrir plusieurs chemins menant au même code.

Exemple — scénario unique par status code

"expected_responses": {
    200: {"authenticated": True, "expected_fields": ["id", "name"]},
    401: {"authenticated": False},
}

Génère deux tests : un pour 200, un pour 401.

Exemple — plusieurs scénarios pour un même status code

"expected_responses": {
    404: [
        {"authenticated": True, "reverse_params": {"kwargs": {"pk": 999999}}},
        {"authenticated": False},
    ],
}

Génère deux tests distincts, tous deux attendant un 404, mais via deux chemins différents : un pk inexistant pour le premier, l'absence d'authentification pour le second.

Clés disponibles dans un scénario (ResponseValidationParams)

Clé Type Effet
authenticated bool True -> client connecté ; False -> client anonyme
reverse_params ReverseParams Fusionné par-dessus celui du test (priorité au scénario)
http_client_params HTTPClientParams Fusionné par-dessus celui du test
expected_fields List[str] Vérifie la présence de chaque champ
expected_value_of_fields Dict[str, Any] Vérifie la valeur exacte d'un champ
expected_type_of_fields Dict[str, type] Vérifie le type Python d'un champ
forbidden_fields List[str] Vérifie l'absence d'un champ
expected_response Type[Any] Vérifie le type du body entier

Exemple — toutes les clés combinées

"expected_responses": {
    200: {
        "authenticated": True,
        "expected_fields": ["pk", "first_name", "email"],
        "expected_value_of_fields": {"is_active": True},
        "expected_type_of_fields": {"pk": int},
        "forbidden_fields": ["password", "raw_token"],
        "expected_response": dict,
    },
}

Notation pointée — champs imbriqués et listes

expected_fields, expected_value_of_fields, etc. acceptent des chemins imbriqués.

"expected_fields": [
    "results",
    "results.0.pk",
    "results.0.author.email",
]

Fonctionne sur les dict et les list : les segments numériques sont interprétés comme des index de liste.

Exemple — réponse paginée

# Réponse réelle de l'API :
# {"count": 12, "results": [{"pk": 1, "name": "Alice"}, {"pk": 2, "name": "Bob"}]}

"expected_responses": {
    200: {
        "expected_fields": ["count", "results.0.pk", "results.0.name", "results.1.pk"],
        "expected_value_of_fields": {"results.0.name": "Alice"},
    }
}

Référencer une fixture dans l'URL — lambdas

Pour injecter le pk (ou autre) d'une fixture créée dans reverse_params.kwargs, utilisez une lambda recevant l'instance du test en argument.

"fixture": {"model": Group, "object_name": "group"},
"reverse_params": {"kwargs": {"pk": lambda t: t.group.pk}},

Exemple — référence imbriquée

"fixture": {"model": Order, "object_name": "order"},
"reverse_params": {"kwargs": {"pk": lambda t: t.order.customer.pk}},

Exemple — mélange de lambda et de valeur fixe

"reverse_params": {
    "kwargs": {
        "pk": lambda t: t.user.pk,
        "version": "v2",
    }
}

Si la lambda référence un attribut inexistant (fixture absente, faute de frappe dans le nom de l'attribut), ForgeCase lève une AttributeError explicite indiquant la clé de kwargs concernée et rappelant de vérifier object_name dans fixture.

# Erreur typique si "group" n'a pas été créé via une fixture nommée "group" :
# AttributeError: reverse_params.kwargs['pk'] : la lambda a levé une AttributeError :
# 'GroupTestCase' object has no attribute 'group'. Vérifiez que l'objet référencé
# existe bien (object_name correct dans fixture).

fixture — créer une instance et la rendre accessible

"fixture": {
    "model": User,
    "object_name": "user",
    "kwargs": {"email": "x@y.com"},
}

object_name est obligatoire dès qu'une fixture est définie. C'est validé au chargement de la classe (avant l'exécution de tout test), pas seulement au runtime.

Exemple — fixture avec données fixes plutôt que générées

"fixture": {
    "object_name": "admin_user",
    "data": existing_admin_instance,
}

Si data est fourni, il prime sur model + kwargs : aucune création n'a lieu, l'instance fournie est simplement stockée sous self.admin_user.

Exemple — utiliser la fixture dans une méthode de test manuelle

class OrderTests(ForgeCase):
    config: ConfigForgeCase = {
        "tests": [
            {
                "path_name": "api:order-detail",
                "method": "GET",
                "fixture": {"model": Order, "object_name": "order"},
                "reverse_params": {"kwargs": {"pk": lambda t: t.order.pk}},
                "expected_responses": {200: {"authenticated": True}},
            }
        ]
    }

    def test_order_total_is_positive(self):
        order = self.factory.create(Order)
        self.assertGreater(order.total, 0)

Envoyer un body — http_client_params.fixture

Pour un POST/PATCH, le body est généré via une FixtureJson (différente de Fixture — pas de persistance en base).

Exemple — body généré automatiquement

"http_client_params": {
    "fixture": {
        "model": User,
        "fields": ["first_name", "last_name", "email"],
    },
    "content_type": "application/json",
}

Exemple — body avec des données fixes

"http_client_params": {
    "fixture": {"data": {"first_name": "Alice", "last_name": "Dupont"}}
}

Exemple — body différent selon le status code attendu

"expected_responses": {
    201: {
        "http_client_params": {
            "fixture": {"model": User, "fields": ["first_name", "last_name", "email"]}
        },
        "expected_fields": ["pk", "first_name"],
    },
    400: {
        "http_client_params": {
            "fixture": {"model": User, "fields": ["first_name"]}  # email manquant -> 400
        },
    },
}

Convention de nommage des tests générés

test_{method}_{base}_{status_suffix}_{scenario_index}
  • base correspond à test_name si fourni, sinon à path_name slugifié
  • status_suffix est un libellé lisible (success, not_found, bad_request, etc.) ou le code brut si inconnu
  • scenario_index correspond à la position dans la liste de scénarios (0 si scénario unique)

Exemple — noms générés

{
    "test_name": "users_detail",
    "method": "GET",
    "expected_responses": {
        200: {"authenticated": True},
        404: [
            {"authenticated": True, "reverse_params": {"kwargs": {"pk": 999999}}},
            {"authenticated": False},
        ],
    },
}

# Génère :
# test_get_users_detail_success_0
# test_get_users_detail_not_found_0
# test_get_users_detail_not_found_1

Combiner config déclarative et tests manuels

ForgeCase reste une TestCase Django classique — ajoutez des méthodes test_* normales pour les scénarios trop spécifiques (login, refresh token, etc.).

class UserTestCase(ForgeCase):
    config: ConfigForgeCase = {
        "tests": [
            {
                "path_name": "forge_auth:users-detail",
                "method": "GET",
                "fixture": {"model": User, "object_name": "user"},
                "reverse_params": {"kwargs": {"pk": lambda t: t.user.pk}},
                "expected_responses": {200: {"authenticated": True}},
            },
        ]
    }

    def test_login_success(self):
        url = reverse("forge_auth:users-login")
        response = self.client.post(url, {"username": self.user.username, "password": "qwerty123"})
        self.assertEqual(response.status_code, 200)

    def test_refresh_token_returns_new_access(self):
        from rest_framework_simplejwt.tokens import RefreshToken
        token = RefreshToken.for_user(self.user)
        response = self.client.post(reverse("forge_auth:users-refresh"), {"refresh": str(token)})
        self.assertEqual(response.status_code, 200)
        self.assertIn("access", response.data)

self.user et self.factory sont disponibles dans setUp(), donc accessibles dans toute méthode manuelle.


Référence des types

ConfigForgeCase = {
    "user": Optional[User],
    "factory_params": Optional[ForgeModelFactoryParams],
    "tests": Optional[List[TestCaseConfig]],
}

TestCaseConfig = {
    "user": Optional[User],
    "test_name": str,
    "path_name": str,
    "method": Literal["GET", "POST", "PUT", "PATCH", "DELETE"],
    "reverse_params": ReverseParams,
    "http_client_params": HTTPClientParams,
    "fixture": Fixture,
    "expected_responses": Dict[int, Union[ResponseValidationParams, List[ResponseValidationParams]]],
}

Fixture = {
    "object_name": str,            # requis si fixture présente
    "model": Type[models.Model],
    "kwargs": Dict[str, Any],
    "data": Any,                    # si fourni, prime sur model+kwargs
}

ResponseValidationParams = {
    "reverse_params": ReverseParams,
    "http_client_params": HTTPClientParams,
    "authenticated": bool,
    "expected_response": Type[Any],
    "expected_fields": List[str],
    "expected_value_of_fields": Dict[str, Any],
    "expected_type_of_fields": Dict[str, Type[Any]],
    "forbidden_fields": List[str],
}

Exemples complets

Exemple 1 — CRUD complet sur une ressource

from forge_test.public.helpers import ForgeCase, ConfigForgeCase
from myapp.models import Article

class ArticleTestCase(ForgeCase):
    config: ConfigForgeCase = {
        "factory_params": {"max_depth": 3},
        "tests": [
            # LIST
            {
                "test_name": "articles_list",
                "path_name": "api:article-list",
                "method": "GET",
                "fixture": {"model": Article, "object_name": "article"},
                "expected_responses": {
                    200: {"authenticated": True, "expected_fields": ["results.0.pk", "results.0.title"]},
                    401: {"authenticated": False},
                },
            },
            # DETAIL
            {
                "test_name": "articles_detail",
                "path_name": "api:article-detail",
                "method": "GET",
                "fixture": {"model": Article, "object_name": "article"},
                "reverse_params": {"kwargs": {"pk": lambda t: t.article.pk}},
                "expected_responses": {
                    200: {"authenticated": True, "expected_fields": ["pk", "title", "body"]},
                    404: {"authenticated": True, "reverse_params": {"kwargs": {"pk": 999999}}},
                },
            },
            # CREATE
            {
                "test_name": "articles_create",
                "path_name": "api:article-list",
                "method": "POST",
                "http_client_params": {
                    "fixture": {"model": Article, "fields": ["title", "body"]},
                    "content_type": "application/json",
                },
                "expected_responses": {
                    201: {"authenticated": True, "expected_fields": ["pk", "title"]},
                    401: {"authenticated": False},
                },
            },
            # UPDATE
            {
                "test_name": "articles_update",
                "path_name": "api:article-detail",
                "method": "PATCH",
                "fixture": {"model": Article, "object_name": "article"},
                "reverse_params": {"kwargs": {"pk": lambda t: t.article.pk}},
                "expected_responses": {
                    200: {
                        "authenticated": True,
                        "http_client_params": {"fixture": {"data": {"title": "Titre modifié"}}},
                        "expected_value_of_fields": {"title": "Titre modifié"},
                    },
                },
            },
            # DELETE
            {
                "test_name": "articles_delete",
                "path_name": "api:article-detail",
                "method": "DELETE",
                "fixture": {"model": Article, "object_name": "article"},
                "reverse_params": {"kwargs": {"pk": lambda t: t.article.pk}},
                "expected_responses": {
                    204: {"authenticated": True},
                    401: {"authenticated": False},
                },
            },
        ],
    }

Exemple 2 — endpoint avec relation imbriquée

from forge_test.public.helpers import ForgeCase, ConfigForgeCase
from myapp.models import Comment

class CommentTestCase(ForgeCase):
    config: ConfigForgeCase = {
        "tests": [
            {
                "path_name": "api:article-comment-detail",
                "method": "GET",
                "fixture": {"model": Comment, "object_name": "comment"},
                "reverse_params": {
                    "kwargs": {
                        "article_pk": lambda t: t.comment.article.pk,
                        "pk": lambda t: t.comment.pk,
                    }
                },
                "expected_responses": {
                    200: {
                        "authenticated": True,
                        "expected_fields": ["pk", "body", "author.pk", "author.username"],
                        "forbidden_fields": ["author.password"],
                    },
                },
            },
        ],
    }

Exemple 3 — endpoint d'action personnalisée (non-CRUD)

from forge_test.public.helpers import ForgeCase, ConfigForgeCase
from myapp.models import User

class VerifyEmailTestCase(ForgeCase):
    config: ConfigForgeCase = {
        "tests": [
            {
                "path_name": "api:users-verify-email",
                "method": "POST",
                "fixture": {
                    "model": User,
                    "object_name": "user_test",
                    "kwargs": {"email": "test@test.com"},
                },
                "expected_responses": {
                    200: {
                        "authenticated": True,
                        "http_client_params": {"fixture": {"data": {"verify": "test@test.com"}}},
                        "expected_type_of_fields": {"exists": bool},
                        "expected_value_of_fields": {"exists": True},
                    },
                    400: {"authenticated": True},
                },
            },
        ],
    }

Erreurs fréquentes

Symptôme Cause probable Comportement
TypeError: object_name is required in fixture fixture sans object_name Levé à la définition de la classe, avant tout test
AttributeError: ... la lambda a levé une AttributeError Lambda référence un attribut inexistant (fixture non créée, typo) Précise la clé kwargs concernée
AssertionError: Status attendu 200, reçu 404 Mauvais code retourné par l'API Inclut le body complet de la réponse
AssertionError: Champ attendu 'x.y' absent de la réponse Chemin imbriqué incorrect ou champ manquant côté API Précise le chemin exact testé

Bonnes pratiques

  • Un scénario correspond à une cause d'échec précise. Si un même status code peut être atteint par deux chemins différents (mauvais pk contre absence d'authentification), utilisez une liste de scénarios plutôt qu'un seul scénario fourre-tout.
  • Vérifiez systématiquement forbidden_fields sur les endpoints sensibles (password, token) pour éviter les fuites silencieuses.
  • Donnez un test_name explicite dès que path_name ne suffit pas à distinguer deux tests sur la même route.
  • Limitez max_depth sur les modèles avec relations profondes pour éviter des temps de génération excessifs.
  • Préférez les lambdas aux chemins de chaînes pour toute référence à une fixture dans reverse_params.kwargs — l'IDE détecte les fautes de frappe avant l'exécution.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_forge_test-0.3.0.tar.gz (64.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

django_forge_test-0.3.0-py3-none-any.whl (33.8 kB view details)

Uploaded Python 3

File details

Details for the file django_forge_test-0.3.0.tar.gz.

File metadata

  • Download URL: django_forge_test-0.3.0.tar.gz
  • Upload date:
  • Size: 64.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.13 {"installer":{"name":"uv","version":"0.11.13","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for django_forge_test-0.3.0.tar.gz
Algorithm Hash digest
SHA256 12473a4d6ef54eda3440b3eacd74d387458c08b951e1c284d588d30693eb167b
MD5 360b395516d7379e95dd6792c3fe7b7b
BLAKE2b-256 ca5cd376f635999dfb2ca5d900e08aae61f651c8ec1bc76983443063c2d46ec6

See more details on using hashes here.

File details

Details for the file django_forge_test-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: django_forge_test-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 33.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.13 {"installer":{"name":"uv","version":"0.11.13","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for django_forge_test-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 cdd63020506e38ff60f2a6b3861a9e4917a6a36319070da7fffc61c705a1a57a
MD5 9c3053159bbbda59a3479078f3cc1fe4
BLAKE2b-256 f9f8a4d82276816bd786b49586432e5add9c0be10b5c774659f935e3fd211f2f

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page