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

Pytest · Playwright · GitHub Actions · AI-Driven development

Lorsque j'ai été onboardé sur le projet, la culture du test n'était pas encore présente. J'ai construit le framework de zéro et instauré les bonnes pratiques : conventions de nommage, isolation des données, couverture par type de comportement, intégration dans la CI.

Ce case study raconte comment j'ai abordé le projet : les décisions que j'ai prises, pourquoi, et ce que ça a donné.

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 interne qui centralise toutes les données de l'agence et fait tourner des agents IA sur les missions.

Concrètement, Portal 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. Ce niveau de clientèle impose un niveau d'exigence sur la fiabilité du système. Un bug sur la gestion des proposals ou des missions, c'est pas anodin.

Ce qui change par rapport à un CRM classique, c'est que les agents IA peuvent exécuter des missions entières. 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. Ça peut être une campagne outbound, un composant à implémenter, un rapport à produire. L'idée c'est que l'équipe pilote et les agents exécutent.

C'est ce système que j'ai eu à tester.

Lire la spec avant de coder

La première chose que j'ai faite, c'est lire la spec OpenAPI de bout en bout avant d'écrire quoi que ce soit. Modèle de données, relations entre ressources, codes de statut attendus, règles de validation. C'est là que j'ai commencé à voir où les risques étaient, ce qui méritait d'être testé en priorité, et comment organiser le framework avant de toucher au code.

Mais lire la spec ne suffisait pas. Pour bien tester un produit, il faut comprendre comment il est utilisé. Je me suis construit des scénarios de bout en bout : 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. Comprendre ces enchaînements m'a permis d'identifier les ressources critiques et les endroits où un bug aurait le plus d'impact.

Un repo séparé

J'ai créé un repo à part, s37-tests, plutôt que d'intégrer les tests dans le repo principal de Portal. La raison principale : Portal est en TypeScript, et mélanger Pytest dans la même codebase aurait compliqué la gestion des dépendances et de la CI sans vraiment apporter quelque chose.

Avec un repo séparé, le framework de test a son propre cycle de vie. Si quelque chose casse côté Portal, les tests continuent de tourner de leur côté. Et ça force aussi une bonne discipline : les tests passent uniquement par l'API publique, pas par le code source de l'application. On teste ce que l'API expose, pas comment elle est construite en interne.

Structure de la codebase

Le repo est découpé en deux parties. tests/ pour les tests API, avec un dossier par ressource et un fichier par type de comportement. e2e/ pour les tests Playwright sur les interfaces publiques, avec les pages organisées en Page Object Model. Les deux partagent un conftest.py racine pour les fixtures communes :

bash
s37-tests/
├── conftest.py          # fixtures globales (url, auth, teardown)
├── requirements.txt
├── .env.example
├── pytest.ini
├── tests/
   ├── companies/
   ├── test_list.py
   ├── test_create.py
   └── test_delete.py
   ├── contacts/
   ├── deals/
   ├── proposals/
   └── missions/
└── e2e/
    ├── conftest.py      # fixtures Playwright
    ├── pages/           # Page Object Model
   └── proposal_page.py
    └── tests/
        └── test_proposal.py

Le découpage par ressource, c'est surtout pour la lisibilité des rapports. Quand un test échoue, on sait tout de suite dans quel dossier regarder. Et quand une nouvelle ressource est ajoutée à l'API, on ajoute un dossier. C'est simple à suivre.

Le conftest.py gère trois niveaux de fixtures. La première, portal_url, 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

Ensuite admin_client, qui ouvre une session HTTP avec requests.Session() pour garder le cookie d'auth entre les requêtes, sans avoir à se reconnecter à chaque test :

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

Et les fixtures de données, qui créent une ressource avant le test et la suppriment après via yield, même si le test échoue. C'est ce qui évite de laisser des données de test traîner en base :

python
@pytest.fixture
def test_company(admin_client, portal_url):
    create_company = admin_client.post(
        f"{portal_url}/companies",
        json={"name": "[TEST] fixture"}
    )
    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), 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. C'est ce qui rend les rapports lisibles quand quelque chose casse :

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 endroits où l'API se comportait différemment de ce que la spec décrivait. Des cas où la ressource était bien créée, mais le statut retourné ne correspondait pas au contrat OpenAPI. Fonctionnellement ça marchait, mais le contrat n'était pas respecté.

Plutôt que de supprimer ces tests ou de les ignorer, j'ai utilisé @pytest.mark.xfail(strict=True) pour les documenter. Le test tourne dans la CI, il est censé échouer pour l'instant, et si l'API est corrigée un jour il passera en XPASS, ce qui remonte une alerte :

python
@pytest.mark.xfail(
    reason="Statut de retour non conforme à la spec REST",
    strict=True
)
def test_create_company_returns_expected_status(test_company):
    assert test_company["status_code"] == EXPECTED_STATUS_CODE

J'ai remonté chaque anomalie au CTO avec le contexte et la référence à la spec. Elles sont toutes tracées dans GitHub Issues.

Pipeline CI/CD

Une fois la couverture API en place, j'ai branché GitHub Actions pour déclencher les tests automatiquement sur chaque PR. Le rapport HTML part en artifact à chaque run, et si un test échoue le merge est bloqué :

yaml
name: QA Tests

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  api-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: pip install -r requirements.txt
      - run: pytest --html=report.html --self-contained-html
        env:
          PORTAL_URL: ${{ secrets.PORTAL_URL }}
          ADMIN_EMAIL: ${{ secrets.ADMIN_EMAIL }}
          ADMIN_PASSWORD: ${{ secrets.ADMIN_PASSWORD }}
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-report
          path: report.html

Ce que ça change concrètement : le CTO n'a plus besoin de lancer les tests en local pour savoir si quelque chose est cassé. C'est visible directement sur la PR.

Tests E2E avec Playwright

Après les tests API, j'ai couvert les interfaces publiques avec Playwright : le site S37 Studio et les pages de proposal client. J'ai organisé les tests avec le Page Object Model, où chaque page est une classe qui expose ses éléments. Si le DOM change, on modifie la classe, pas chaque test individuellement :

python
class ProposalPage:
    def __init__(self, page: Page):
        self.page = page
        self.title = page.locator("h1")
        self.content = page.locator(".proposal-content")
        self.cta = page.locator("a.proposal-cta")

    def goto(self, proposal_id: str):
        self.page.goto(f"{BASE_URL}/proposals/{proposal_id}")
        return self

@pytest.fixture
def proposal_page(page: Page) -> ProposalPage:
    return ProposalPage(page)

def test_proposal_renders_correctly(proposal_page: ProposalPage):
    proposal_page.goto(TEST_PROPOSAL_ID)
    expect(proposal_page.title).to_be_visible()
    expect(proposal_page.content).to_be_visible()
    expect(proposal_page.cta).to_be_enabled()

AI-Driven development

J'ai aussi construit un Cursor Skill generate-tests pour accélérer la génération de tests à partir de la spec OpenAPI. L'idée : on lui donne un endpoint, il génère la structure de tests correspondante en respectant les conventions du framework. C'est un fichier SKILL.md qui donne les instructions à l'agent :

markdown
---
name: generate-tests
description: Generates Pytest API tests from an OpenAPI endpoint definition.
  Covers happy path, error cases, and authentication. Use when the user
  provides an endpoint spec and wants the corresponding test suite generated.
disable-model-invocation: true
---

# Generate API Tests from OpenAPI spec

## Input

Provide the endpoint details:
- HTTP method and path
- Request body schema
- Expected response codes and shapes

## Output

Generate a Pytest test file covering:
1. Happy path — expected status + response shape
2. Unauthenticated request — expect 401
3. Invalid body — expect 422
4. Not found — expect 404 (if applicable)

## Conventions

- Use fixtures from conftest.py: `admin_client`, `portal_url`
- One assertion per test
- Test function names: `test_<resource>_<scenario>`
- Data fixtures use yield + teardown pattern

Sur une API avec une vingtaine d'endpoints, ça fait gagner du temps sur les tests de base et ça laisse plus de place pour les cas qui demandent vraiment de la réflexion : les règles métier, les cas limites, les interactions entre ressources.

Bilan

Ce projet m'a permis de construire un framework de test complet de zéro, sur un système qui était déjà en production. Tests API, E2E, CI/CD. Chaque décision a été prise pour que ça reste maintenable dans le temps, pas juste pour que ça tourne aujourd'hui.

Ce que j'en retiens : quand la qualité est intégrée tôt dans le processus, elle ne ralentit pas l'équipe. Elle lui donne la confiance pour avancer sans se retourner à chaque deploy.