S37 Portal : mise en place d'un framework de test sur une API en production

Pytest · GitHub Actions · AI-Driven development

Je suis arrivé sur S37 Portal sans aucun test en place. Pas de convention, pas de CI, rien. Le produit tournait, des clients l'utilisaient, et ma mission c'était de construire tout ça de zéro.

Le produit

S37 Studio est une agence digitale AI-native. Le problème de toutes les agences, c'est que le scaling est limité par le temps des personnes qui travaillent là. S37 a construit S37 Portal pour changer ça : une plateforme qui centralise toutes les données de l'agence et fait tourner des agents IA sur les missions.

J'ai découvert un produit qui remplace un tas d'outils dispersés (CRM, gestion de projet, docs clients, facturation) par une seule base. Companies, contacts, deals, missions, tâches, documents, factures : tout au même endroit. Les clients ont accès à leur espace pour suivre leurs missions et leurs livrables.

S37 a travaillé avec des boîtes comme Swan, HappyPal ou Flex AI. Un bug sur la gestion des proposals ou des missions, c'est pas anodin.

Ce qui m'a frappé par rapport à un CRM classique, c'est la couche agents. Un agent reçoit tout le contexte d'un coup (historique de la company, tâches en cours, documents, outils disponibles), fait le travail, et l'équipe valide. L'équipe pilote. Les agents exécutent.

Lire la spec avant de coder

Avant d'écrire quoi que ce soit, j'ai lu la spec OpenAPI de bout en bout. Modèle de données, relations entre ressources, codes de statut attendus, règles de validation. C'est là que j'ai vu où les risques étaient et ce qui méritait d'être couvert en priorité.

J'ai aussi construit des scénarios de bout en bout pour comprendre comment le produit était vraiment utilisé : un lead contacte l'agence, une company est créée, un deal est ouvert, une proposal est envoyée, le client l'accepte, une mission démarre. J'ai cartographié ces enchaînements pour identifier les ressources critiques et les endroits où un bug aurait le plus d'impact sur le business.

Un repo séparé

J'ai créé s37-tests comme repo indépendant plutôt que d'intégrer les tests dans le repo principal de Portal. La raison : Portal est en TypeScript. Mélanger Pytest dans la même codebase aurait compliqué la gestion des dépendances et de la CI sans rien apporter.

J'ai aussi forcé une discipline utile en séparant les repos : les tests ne passent que par l'API publique, pas par le code source de l'application. Je teste ce que l'API expose, pas comment elle est construite en interne. C'est une contrainte qui protège contre les mauvaises habitudes à long terme.

Structure de la codebase

J'ai organisé le repo en un seul dossier tests/api/, un fichier par ressource. Toutes les fixtures partagées (auth, données, teardown) sont dans tests/conftest.py :

bash
s37-tests/
├── tests/
   ├── conftest.py          # fixtures (url, auth, données, teardown)
   └── api/
       ├── test_companies.py
       ├── test_contacts.py
       ├── test_deals.py
       ├── test_proposals.py
       ├── test_missions.py
       └── test_mission_tasks.py
├── requirements.txt
└── .env.example

J'ai découpé par ressource parce que quand un test échoue, je sais immédiatement où regarder. Quand une nouvelle ressource est ajoutée à l'API, j'ajoute un fichier. La structure suit le produit.

J'ai commencé par portal_url, qui lit l'URL depuis les variables d'environnement et plante proprement si elle est absente :

python
@pytest.fixture(scope="session")
def portal_url():
    url = os.getenv("S37_PORTAL_URL")
    assert url is not None, "S37_PORTAL_URL est manquant dans le fichier .env"
    return url

J'ai ensuite créé admin_client pour ouvrir une session HTTP avec requests.Session() et garder le cookie d'auth entre les requêtes. Sans ça, je devais me reconnecter à chaque test, et les premiers runs prenaient trois fois plus longtemps :

python
@pytest.fixture(scope="session")
def admin_client(portal_url):
    email = os.getenv("TEST_ADMIN_EMAIL")
    password = os.getenv("TEST_ADMIN_PASSWORD")
    session = requests.Session()
    login = session.post(
        f"{portal_url}/auth/sign-in",
        json={"email": email, "password": password},
    )
    assert login.status_code == 200, "Login admin échoué"
    return session

J'ai aussi écrit des fixtures de données qui créent la ressource avant le test et la suppriment après via yield, même si le test plante. J'ai découvert ça à la dure : mes premiers tests laissaient des données orphelines en base à chaque run raté. Avec yield, le nettoyage se fait quoi qu'il arrive :

python
@pytest.fixture
def test_company(admin_client, portal_url):
    create_company = admin_client.post(
        f"{portal_url}/companies",
        json={"name": "[TEST] fixture"},
    )
    assert (
        create_company.status_code == 200
    ), f"Création de company échouée (status: {create_company.status_code})"
    company = create_company.json()

    yield company

    admin_client.delete(f"{portal_url}/companies/{company['id']}")

Couverture API

Sur chaque ressource (Companies, Contacts, Deals, Proposals, Missions, Mission Tasks), je couvre quatre cas : le happy path, les erreurs de validation, les requêtes non authentifiées, et les règles métier. Un test, un comportement :

python
def test_list_companies(admin_client, portal_url):
    response = admin_client.get(f"{portal_url}/companies")
    assert response.status_code == 200
    assert isinstance(response.json()["data"], list)

def test_list_companies_unauthenticated(portal_url):
    session = requests.Session()
    response = session.get(f"{portal_url}/companies")
    assert response.status_code == 401

def test_get_company_not_found(admin_client, portal_url):
    response = admin_client.get(f"{portal_url}/companies/invalid-id")
    assert response.status_code == 404

def test_create_company_invalid_body(admin_client, portal_url):
    response = admin_client.post(f"{portal_url}/companies", json={})
    assert response.status_code == 422

Documenter les anomalies

En écrivant les tests, j'ai trouvé des endpoints où l'API se comportait différemment de ce que la spec décrivait. La ressource était bien créée, mais le statut retourné ne correspondait pas au contrat OpenAPI. Fonctionnellement ça marchait. Le contrat, non.

Plutôt que d'ignorer ces cas, j'ai utilisé @pytest.mark.xfail(strict=True) pour les tracer. Je garde le test actif dans la CI, il est censé échouer pour l'instant. Si l'API est corrigée, il passe en XPASS et je reçois une alerte :

python
@pytest.mark.xfail(
    reason="POST /companies retourne 200 au lieu de 201 - non conforme REST",
    strict=True,
)
def test_create_company_returns_201(test_company):
    assert test_company["status_code"] == 201

J'ai remonté chaque anomalie au CTO avec le contexte et la référence à la spec. Au total, on a identifié plusieurs comportements non conformes avant qu'ils ne causent de vrais problèmes en production. Tout est tracé dans GitHub Issues.

Pipeline CI/CD

J'ai branché GitHub Actions sur chaque PR. La CI vérifie le formatage avec Black, le linting avec Flake8, et s'assure que tous les tests se collectent sans erreur de syntaxe. Les tests eux-mêmes tournent en local ou sur l'environnement de staging. La CI garantit que le repo reste propre à chaque merge :

yaml
name: CI - code quality

on:
  pull_request:
    branches: [main]

jobs:
  lint:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - run: pip install -r requirements.txt
      - name: Black
        run: black --check .
      - name: Flake8
        run: flake8 tests/
      - name: pytest collect
        run: pytest --collect-only

J'ai montré ça au CTO. Un coup de `git push`, et la PR montre immédiatement si le code est formaté et si les tests peuvent être collectés. Il l'a intégré dans son process de review dès le premier jour.

AI-Driven development

J'ai construit un Cursor Skill generate-api-tests pour accélérer la génération de tests à partir de la spec OpenAPI. Je lui donne un endpoint, il génère la structure de tests en respectant les conventions du framework :

markdown
---
name: generate-api-tests
description: Génère des tests API Python (pytest/requests) à partir de la
  documentation Swagger/OpenAPI. Applique les conventions s37-tests : fixtures
  conftest avec teardown, préfixe [TEST], assertions précises, suite CRUD +
  401/404/422 + xfail companies.
disable-model-invocation: true
---

## Suite minimale par module

| Test | Méthode | Attendu |
|------|---------|---------|
| test_list_<module> | GET | 200 + isinstance list |
| test_create_<module> | via fixture | champs body exacts |
| test_delete_<module> | DELETE | 200 |
| test_list_<module>_unauthenticated | GET sans cookie | 401 |
| test_get_<module>_not_found | GET id fictif | 404 |
| test_create_<module>_invalid_body | POST {} | 422 |

## Conventions

- Fixtures dans conftest.py — pattern setup → yield → teardown DELETE
- Toute donnée créée porte le préfixe [TEST]
- Auth 401 : toujours une session requests.Session() explicite sans auth

Sur une vingtaine d'endpoints, je génère les tests de base en quelques secondes. Je garde le temps restant pour les cas qui demandent du jugement : les règles métier, les interactions entre ressources, les cas limites que la spec ne documente pas.

Bilan

J'ai livré les tests API sur les six ressources principales (companies, contacts, deals, proposals, missions, mission tasks) et le pipeline CI. J'ai aussi remonté plusieurs comportements non conformes à la spec que personne n'avait repérés avant. Le CTO a intégré les tests dans son process de review dès les premiers PR.

Sur ce projet, j'ai passé plus de temps à lire et comprendre le système qu'à écrire des tests. J'ai cartographié des dizaines de scénarios métier avant de toucher à une seule ligne de code. C'est ce travail en amont qui a rendu chaque test utile plutôt qu'anecdotique.