A headless setup has more moving parts than a classic monolith: a frontend, a CMS with its own API, a database and something that ties it all together, such as caching and webhooks. That raises a practical question for a team working test-first: what do we test where, so the tests are fast enough to run all the time but still catch the bugs that actually happen?
The examples use the fictitious company Space Travel, whose website consists of Next.js and Strapi with PostgreSQL. The code in this post is excerpts from tests that run.
The test pyramid in practice
The pyramid is well known: many fast unit tests at the bottom, fewer integration tests in the middle and a few end-to-end tests at the top. In a headless setup the layers become quite concrete:
- Unit tests for logic and components, with all external dependencies replaced by mocks and stubs: no database, no network, no CMS. They run in seconds and can run on every change.
- Integration tests against the real Strapi and a real PostgreSQL database. They catch what mocks cannot: the schema, permissions, what the API actually returns.
- End-to-end tests for a few important flows through the whole chain.
The rule that keeps it in place is simple: a unit test must not need anything to be running. If a test needs a database, it is an integration test, and it belongs in its own folder with its own script.
Pure functions first
The easiest code to test is code without dependencies. The gallery layout is a good example: how many columns and how many visible images depend only on the number of images. By pulling the decision out into a pure function, it can be tested exhaustively in one table:
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);
});
});Components with a fake CMS response
Components that show CMS content are tested with data that looks like Strapi's response but lives in the test. There is no reason to start a CMS to see whether the right component is chosen for a block, or whether an unknown block type is skipped:
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);
});The data layer: test the request, not the network
The data layer builds URLs and parameters for Strapi and turns the response into the types the pages use. Here fetch is replaced by a mock, and the test checks the request that would have been sent:
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");
});Integration tests against a real database
Mocks can only test what you already know. What you do not know has to be revealed by a real Strapi. Permissions are a good example: can the public see drafts? A test against the running Strapi and PostgreSQL answers that. In Space Travel's case it showed that an anonymous request with ?status=draft returned unpublished articles, until a middleware closed the hole:
// 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([]);
});A test database that cannot be mistaken for another
The integration tests run against a separate PostgreSQL in Docker Compose, never against the development or production database. The data lives in memory (tmpfs), so a restart gives an empty database, and every test file starts by resetting the schema:
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: 10As an extra safeguard, the test helper refuses to use a database whose name does not end in _test. It costs one line and prevents the worst mistake you can make with an integration test: resetting the wrong database.
Few but important end-to-end tests
At the top of the pyramid the whole chain runs: Strapi, the built frontend and the browser's path through the pages. Tests like that are slow and brittle, so they should cover flows where bugs are expensive and that no other test can see. Caching and webhooks are an example: when an editor corrects a title, the new title has to appear on the page within a few seconds. That requires Strapi to call the webhook, Next.js to drop the right part of the cache, and the page to fetch again. No unit test can prove that.
Three pitfalls
- Mocks that lie. A mock of the CMS returns what you believe the CMS returns. If the assumption is wrong, the tests are green and the system is broken. So every important assumption about the API also has to be proven at least once by an integration test against the real Strapi, for example that images in a block are only included when the query names them explicitly.
- Shared state between tests. Tests that share a database or a global variable can pass in one order and fail in another. Clean up after every test, or start every test file from an empty schema.
- Tests that have never failed. A test that is green from its first run may be testing nothing. Temporarily break the code so the test should fail, and watch it do so. It takes a minute and surprisingly often reveals a test that was written wrong.
What the rhythm looks like
The test-driven cycle is the same in every layer: write the test, watch it fail for the right reason, write the smallest code that makes it pass, and clean up. What changes is which layer the test belongs to. A new field on an article typically starts with an integration test showing that the field exists and is returned; then a unit test of the function that maps the response, and finally a component test of the view. Each test is small, and together they cover the whole way.
In short
- A unit test must not need anything to be running: no database, no network, no CMS.
- Pull decisions out into pure functions and test them exhaustively in tables.
- Test the data layer's requests with a mock of fetch, not against the network.
- Prove every important assumption about the API at least once against a real Strapi and PostgreSQL.
- Use a separate test database that the helper refuses to mistake for another.
- A few end-to-end tests for the flows no other test can see, such as caching and webhooks.
Want a test strategy that fits your architecture?
We are happy to help you find the right balance between unit, integration and end-to-end tests, and to get started with test-driven development in an existing project. Let us have an informal chat.
- tdd
- unit-test
- integrationstest



