Tests fondamentaux - 07 - Tests de contrat d'API

Verifier que ton API respecte son contrat : status codes, shape des réponses, headers. Avec des exemples Elysia.

07 - Tests de contrat d'API : vérifier ce que ton API promet

Ce que tu vas apprendre

  • Ce qu'est un test de contrat et pourquoi c'est différent d'un test d'intégration
  • Tester des routes Elysia directement avec app.handle(), sans lancer de serveur
  • Verifier status codes, shape des réponses, headers et erreurs

Prerequisites

Connaitre les bases d'Elysia (ou n'importe quel framework HTTP). Avoir lu les articles précédents.


C'est quoi un test de contrat ?

Ton API fait des promesses. GET /api/articles promet de retourner un tableau d'articles avec un status 200. POST /api/orders promet de retourner 201 avec l'ordre créé, ou 400 si les donnees sont invalides. GET /api/admin/stats promet de retourner 401 sans authentification.

Un test de contrat vérifié ces promesses. Pas la logique interne, pas les queries SQL. Juste : "est-ce que l'API retourne ce qu'elle dit retourner ?"

C'est différent d'un test d'intégration qui vérifié que tout fonctionne de bout en bout (DB incluse). Le test de contrat est plus leger : il peut utiliser des fakes pour l'infrastructure et se concentrer sur la forme de la réponse.

Tester Elysia sans serveur HTTP

Un truc genial avec Elysia (le framework que j'utilise sur paltemps.fr) : tu peux tester les routes sans lancer de serveur. La méthode app.handle() prend un objet Request standard et retourne une Response. Pas de port, pas de fetch, pas de listen().

typescript// src/api/index.ts
import { Elysia } from "elysia";

export const app = new Elysia()
  .get("/api/health", () => ({ status: "ok", timestamp: Date.now() }))
  .get("/api/articles", async () => {
    const articles = await articleRepo.findAll();
    return articles;
  })
  .post("/api/articles", async ({ body, set }) => {
    const result = await createArticle(body);
    set.status = 201;
    return result;
  });

Et le test :

typescript// src/api/index.test.ts
import { describe, it, expect } from "bun:test";
import { app } from "./index";

describe("GET /api/health", () => {
  it("returns 200 with status ok", async () => {
    const res = await app.handle(
      new Request("http://localhost/api/health")
    );

    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body.status).toBe("ok");
    expect(typeof body.timestamp).toBe("number");
  });
});

Pas de beforeAll pour démarrer un serveur. Pas de port aleatoire. Pas de race condition. C'est instantane.

Verifier l'authentification

La plupart des routes admin exigent un token. Teste les deux cas : avec et sans auth.

typescriptdescribe("GET /api/admin/articles", () => {
  it("returns 401 without auth token", async () => {
    const res = await app.handle(
      new Request("http://localhost/api/admin/articles")
    );
    expect(res.status).toBe(401);
  });

  it("returns 200 with valid auth token", async () => {
    const token = await generateTestToken({ role: "admin" });

    const res = await app.handle(
      new Request("http://localhost/api/admin/articles", {
        headers: { Cookie: `admin_token=${token}` },
      })
    );

    expect(res.status).toBe(200);
    const data = await res.json();
    expect(Array.isArray(data)).toBe(true);
  });

  it("returns 403 with non-admin token", async () => {
    const token = await generateTestToken({ role: "user" });

    const res = await app.handle(
      new Request("http://localhost/api/admin/articles", {
        headers: { Cookie: `admin_token=${token}` },
      })
    );

    expect(res.status).toBe(403);
  });
});

Trois tests, trois scénarios d'auth différents. C'est le genre de truc qui se retrouve en bug bounty si tu ne le testes pas.

Verifier la shape des réponses

Le contrat ne se limite pas au status code. La forme de la réponse compte aussi. Si ton frontend attend { articles: [...], total: number } et que tu retournes juste un tableau, ca casse.

typescriptdescribe("GET /api/articles", () => {
  it("returns array of articles with expected shape", async () => {
    // Setup : inserer des donnees de test
    await seedTestArticles([
      { title: "Premier article", slug: "premier-article" },
      { title: "Deuxieme article", slug: "deuxieme-article" },
    ]);

    const res = await app.handle(
      new Request("http://localhost/api/articles")
    );

    expect(res.status).toBe(200);

    const data = await res.json();
    expect(Array.isArray(data)).toBe(true);
    expect(data).toHaveLength(2);

    // Verifier la shape de chaque article
    for (const article of data) {
      expect(article).toHaveProperty("id");
      expect(article).toHaveProperty("title");
      expect(article).toHaveProperty("slug");
      expect(article).toHaveProperty("createdAt");
      expect(typeof article.title).toBe("string");
      expect(typeof article.slug).toBe("string");
    }
  });
});

Tu peux aussi utiliser Zod pour valider la shape de manière plus robuste :

typescriptimport { z } from "zod";

const ArticleSchema = z.object({
  id: z.string().uuid(),
  title: z.string().min(1),
  slug: z.string().min(1),
  createdAt: z.string().datetime(),
});

const ArticlesResponseSchema = z.array(ArticleSchema);

it("response matches Zod schema", async () => {
  const res = await app.handle(new Request("http://localhost/api/articles"));
  const data = await res.json();

  const result = ArticlesResponseSchema.safeParse(data);
  expect(result.success).toBe(true);
});

Si le schema Zod est le meme que celui utilise dans ton code de production, tu as la garantie que l'API et le frontend partagent la meme définition.

Tester les erreurs de validation

Quand un utilisateur envoie des donnees invalides, ton API doit retourner un message clair, pas un 500 avec une stack trace.

typescriptdescribe("POST /api/articles", () => {
  it("returns 400 on missing title", async () => {
    const res = await app.handle(
      new Request("http://localhost/api/articles", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Cookie: `admin_token=${adminToken}`,
        },
        body: JSON.stringify({ slug: "test-article" }), // pas de title
      })
    );

    expect(res.status).toBe(400);
    const error = await res.json();
    expect(error.message).toContain("title");
  });

  it("returns 400 on duplicate slug", async () => {
    await seedTestArticles([{ title: "Existing", slug: "my-slug" }]);

    const res = await app.handle(
      new Request("http://localhost/api/articles", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Cookie: `admin_token=${adminToken}`,
        },
        body: JSON.stringify({ title: "New", slug: "my-slug" }),
      })
    );

    expect(res.status).toBe(409);
  });

  it("returns 201 with valid data", async () => {
    const res = await app.handle(
      new Request("http://localhost/api/articles", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Cookie: `admin_token=${adminToken}`,
        },
        body: JSON.stringify({
          title: "Mon article",
          slug: "mon-article",
          content: "Le contenu de l'article.",
        }),
      })
    );

    expect(res.status).toBe(201);
    const created = await res.json();
    expect(created.id).toBeDefined();
    expect(created.title).toBe("Mon article");
  });
});

Verifier les headers

Parfois le contrat inclut des headers spécifiques. Cache-Control, Content-Type, pagination.

typescriptit("returns correct Content-Type", async () => {
  const res = await app.handle(new Request("http://localhost/api/articles"));
  expect(res.headers.get("content-type")).toContain("application/json");
});

it("returns pagination headers", async () => {
  await seedTestArticles(generateArticles(25));

  const res = await app.handle(
    new Request("http://localhost/api/articles?page=1&limit=10")
  );

  expect(res.headers.get("X-Total-Count")).toBe("25");
  expect(res.headers.get("X-Page")).toBe("1");
  expect(res.headers.get("X-Per-Page")).toBe("10");
});

it("sets cache headers on public endpoints", async () => {
  const res = await app.handle(new Request("http://localhost/api/articles"));
  expect(res.headers.get("cache-control")).toContain("max-age=");
});

Un helper pour simplifier les tests

Quand tu as beaucoup de routes a tester, un petit helper réduit le boilerplate :

typescript// src/test/api-helper.ts
import { app } from "../api/index";

export async function apiGet(path: string, headers?: Record<string, string>) {
  return app.handle(
    new Request(`http://localhost${path}`, { headers })
  );
}

export async function apiPost(
  path: string,
  body: unknown,
  headers?: Record<string, string>
) {
  return app.handle(
    new Request(`http://localhost${path}`, {
      method: "POST",
      headers: { "Content-Type": "application/json", ...headers },
      body: JSON.stringify(body),
    })
  );
}

export function withAuth(token: string): Record<string, string> {
  return { Cookie: `admin_token=${token}` };
}
typescript// Usage dans les tests
it("returns articles", async () => {
  const res = await apiGet("/api/articles");
  expect(res.status).toBe(200);
});

it("creates article with auth", async () => {
  const res = await apiPost(
    "/api/articles",
    { title: "Test", slug: "test" },
    withAuth(adminToken)
  );
  expect(res.status).toBe(201);
});

Moins de code, plus lisible, et la logique de construction des requêtes est centralisee.


Article précédent : 06 - Tests e2e avec Playwright Article suivant : 08 - Snapshot testing

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.