EKSING
Test & Kvalitet

TDD i et headless setup: hvad tester vi hvor?

Testpyramiden i praksis for Next.js og Strapi: unit-tests med mocks og stubs, integrationstests mod en rigtig Strapi og PostgreSQL, en testdatabase der ikke kan forveksles, og få, men vigtige end-to-end-tests.

Claus Eksing
15. oktober 2026 · 5 min læsning
En hånd sætter flueben på en tjekliste på en tablet

Et headless setup har flere bevægelige dele end en klassisk monolit: en frontend, et CMS med sit eget API, en database og noget, der binder det sammen, som cache og webhooks. Det rejser et praktisk spørgsmål for et team, der arbejder testdrevet: hvad tester vi hvor, så testene er hurtige nok til at køre hele tiden, men stadig fanger de fejl, der faktisk opstår?

Eksemplerne bruger den fiktive virksomhed Space Travel, hvis website består af Next.js og Strapi med PostgreSQL. Koden i indlægget er uddrag af tests, der kører.

Testpyramiden i praksis

Pyramiden er velkendt: mange hurtige unit-tests nederst, færre integrationstests i midten og få end-to-end-tests øverst. I et headless setup bliver lagene ret konkrete:

  • Unit-tests for logik og komponenter, med alle eksterne afhængigheder erstattet af mocks og stubs: ingen database, intet netværk, intet CMS. De kører på sekunder og kan køre ved hver ændring.
  • Integrationstests mod den rigtige Strapi og en rigtig PostgreSQL-database. De fanger det, mocks ikke kan: skemaet, rettigheder, hvad API'et rent faktisk returnerer.
  • End-to-end-tests for nogle få vigtige forløb gennem hele kæden.

Reglen, der holder det på plads, er enkel: en unit-test må ikke kræve, at noget kører. Kræver testen en database, er det en integrationstest, og den hører hjemme i sin egen mappe med sit eget script.

Rene funktioner først

Den nemmeste kode at teste er kode uden afhængigheder. Galleri-layoutet er et godt eksempel: hvor mange kolonner og hvor mange synlige billeder afhænger kun af antallet af billeder. Ved at trække beslutningen ud i en ren funktion kan den testes udtømmende i én tabel:

src/lib/blog/gallery.test.ts
import { describe, expect, it } from "vitest";
import { getGalleryLayout } from "@/lib/blog/gallery";

describe("getGalleryLayout", () => {
  it.each([
    [0, { variant: "empty", visible: 0, hidden: 0 }],
    [1, { variant: "single", visible: 1, hidden: 0 }],
    [2, { variant: "two-columns", visible: 2, hidden: 0 }],
    [3, { variant: "feature", visible: 3, hidden: 0 }],
    [4, { variant: "grid-2x2", visible: 4, hidden: 0 }],
    [5, { variant: "grid-3", visible: 5, hidden: 0 }],
    [6, { variant: "grid-3", visible: 5, hidden: 1 }],
    [7, { variant: "grid-3", visible: 5, hidden: 2 }],
    [8, { variant: "grid-3", visible: 5, hidden: 3 }],
    [9, { variant: "grid-3", visible: 5, hidden: 4 }],
  ])("%i images -> %j", (count, expected) => {
    expect(getGalleryLayout(count)).toEqual(expected);
  });
});

Komponenter med et falsk CMS-svar

Komponenter, der viser CMS-indhold, testes med data, der ligner Strapis svar, men ligger i testen. Der er ingen grund til at starte et CMS for at se, om den rigtige komponent bliver valgt for en blok, eller om en ukendt bloktype bliver sprunget over:

src/components/blog/BlockRenderer.test.tsx (excerpt)
it("renders the right component for each block and skips unknown ones", () => {
  const blocks = [
    { __component: "blog.quote", id: 1, text: "Et citat" },
    { __component: "blog.unknown", id: 2 },
  ] as Block[];

  const { container } = render(<BlockRenderer blocks={blocks} locale="da" />);

  expect(container.querySelector("blockquote")?.textContent).toContain("Et citat");
  expect(container.querySelectorAll("figure")).toHaveLength(1);
});

Datalaget: test forespørgslen, ikke netværket

Datalaget bygger URL'er og parametre til Strapi og omsætter svaret til typer, som siderne bruger. Her erstattes fetch af en mock, og testen kontrollerer den forespørgsel, der ville være blevet sendt:

src/lib/blog/api.test.ts (excerpt)
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => new Response(JSON.stringify({ data: [] })));

beforeEach(() => {
  fetchMock.mockClear();
  vi.stubGlobal("fetch", fetchMock); // no real Strapi: the test checks the request, not the network
});

it("sends the search to Strapi as a case-insensitive filter", async () => {
  await getArticles({ locale: "da", query: "  docker  " });

  const url = new URL(fetchMock.mock.calls[0][0]);
  expect(url.searchParams.get("filters[$or][0][title][$containsi]")).toBe("docker");
  expect(url.searchParams.get("filters[$or][1][excerpt][$containsi]")).toBe("docker");
});

Integrationstests mod en rigtig database

Mocks kan kun teste det, man allerede ved. Det, man ikke ved, skal en rigtig Strapi afsløre. Et godt eksempel er rettigheder: må offentligheden se kladder? En test mod den kørende Strapi og PostgreSQL svarer på det. I Space Travels tilfælde viste den, at en anonym forespørgsel med ?status=draft returnerede upublicerede artikler, indtil et middleware lukkede hullet:

tests/integration/article-api.test.ts (excerpt)
// createArticle and findBySlug are small helpers in the test file; Strapi and PostgreSQL are real.
it('does not return drafts through the public API', async () => {
  const draft = await createArticle('kladde', { publish: false });

  const list = await findBySlug('kladde');
  const one = await api().get(`/api/articles/${draft.documentId}`).query({ locale: LOCALE });
  const askedForDrafts = await findBySlug('kladde', { status: 'draft' });

  expect(list.body.data).toEqual([]);
  expect(one.status).toBe(404);
  expect(askedForDrafts.body.data ?? []).toEqual([]);
});

En testdatabase, der ikke kan forveksles

Integrationstestene kører mod en separat PostgreSQL i Docker Compose, aldrig mod udviklings- eller produktionsdatabasen. Data ligger i hukommelsen (tmpfs), så en genstart giver en tom database, og hver testfil starter med at nulstille skemaet:

docker-compose.dev.yml (excerpt)
services:
  # Throwaway database for integration tests. Data lives in memory (tmpfs) and is gone on restart.
  space-travel-db-test:
    image: postgres:16.15-alpine3.24
    environment:
      POSTGRES_DB: space_travel_test
      POSTGRES_USER: space_travel_test
      POSTGRES_PASSWORD: space_travel_test
    ports:
      - "127.0.0.1:5434:5432"
    tmpfs:
      - /var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U space_travel_test -d space_travel_test"]
      interval: 5s
      timeout: 5s
      retries: 10

Som ekstra sikkerhed nægter test-hjælperen at bruge en database, hvis navn ikke ender på _test. Det koster én linje og forhindrer den værste fejl, man kan lave med en integrationstest: at nulstille den forkerte database.

Få, men vigtige end-to-end-tests

Øverst i pyramiden kører hele kæden: Strapi, den byggede frontend og browserens vej gennem siderne. Den slags tests er langsomme og skrøbelige, så de bør dække forløb, hvor fejl er dyre, og som ingen anden test kan se. Et eksempel er cache og webhooks: når en redaktør retter en titel, skal den nye titel stå på siden inden for få sekunder. Det kræver, at Strapi kalder webhooket, at Next.js fjerner det rigtige fra cachen, og at siden henter igen. Ingen unit-test kan bevise det.

Tre faldgruber

  • Mocks, der lyver. En mock af CMS'et returnerer det, man tror, CMS'et returnerer. Hvis antagelsen er forkert, er testene grønne og systemet i stykker. Derfor skal hver vigtig antagelse om API'et også være bevist mindst én gang af en integrationstest mod den rigtige Strapi, f.eks. at billeder i en blok kun kommer med, når de nævnes eksplicit i forespørgslen.
  • Delt tilstand mellem tests. Tests, der deler en database eller en global variabel, kan bestå i én rækkefølge og fejle i en anden. Ryd op efter hver test, eller start hver testfil fra et tomt skema.
  • Tests, der aldrig har fejlet. En test, der er grøn fra første kørsel, kan teste ingenting. Ret koden midlertidigt, så testen burde fejle, og se, at den gør det. Det tager et minut og afslører overraskende ofte en test, der var skrevet forkert.

Sådan ser rytmen ud

Det testdrevne forløb er det samme i alle lag: skriv testen, se den fejle af den rigtige grund, skriv den mindste kode, der får den til at bestå, og ryd op. Det, der ændrer sig, er hvilket lag testen hører til. Et nyt felt i en artikel starter typisk med en integrationstest, der viser, at feltet findes og returneres; derefter en unit-test af den funktion, der omsætter svaret, og til sidst en komponenttest af visningen. Hver test er lille, og tilsammen dækker de hele vejen.

Kort sagt

  • En unit-test må ikke kræve, at noget kører: ingen database, intet netværk, intet CMS.
  • Træk beslutninger ud i rene funktioner, og test dem udtømmende i tabeller.
  • Test datalagets forespørgsler med en mock af fetch, ikke mod netværket.
  • Bevis hver vigtig antagelse om API'et mindst én gang mod en rigtig Strapi og PostgreSQL.
  • Brug en separat testdatabase, som hjælperen nægter at forveksle med andre.
  • Få end-to-end-tests for de forløb, ingen anden test kan se, f.eks. cache og webhooks.

Vil I have en teststrategi, der passer til jeres arkitektur?

Vi hjælper gerne med at finde den rette fordeling mellem unit-, integrations- og end-to-end-tests, og med at komme i gang med testdrevet udvikling i et eksisterende projekt. Tag en uforpligtende snak.

Book en uforpligtende samtale
  • tdd
  • unit-test
  • integrationstest
Claus Eksing
Senior softwarearkitekt med 25+ års erfaring i systemdesign, API-udvikling og AI-understøttet udvikling.
Book en samtale

Relaterede indlæg

Se alle indlæg →
En robothånd og en menneskehånd rækker ud mod hinanden på en turkis baggrund
AI & Vibe Coding

AI-understøttet udvikling uden at gå på kompromis med kvaliteten

Hvad AI-kodeværktøjer og vibe coding er gode til, hvor både agenten og vi mennesker fejler, og de rammer, der giver farten uden at betale med kvalitet og sikkerhed: tests først, små ændringer, menneskelig gennemgang og klare regler for agenten.

23. oktober 2026 · 7 min læsning