Skip to content

Tutorial: Test-Driven API Development

This adds a search filter to the blog API from Build Your First API — GET /posts?search=term — entirely test-first: a failing test, the minimal code to pass it, then the next failing test. This is the same loop the framework’s own test suite (165 tests, 100% coverage) was built with.

Tests use Vitest. If you haven’t set it up in your app yet, see Installation for the required Oxc decorator config.

Start below the HTTP layer, at the plain class that does the actual work:

src/posts/posts.service.test.ts
import { describe, expect, it } from "vitest";
import { PostsService } from "./posts.service.js";
describe("PostsService.list", () => {
it("returns every post when no search term is given", () => {
const service = new PostsService();
service.create({ title: "Hello World", body: "" });
service.create({ title: "Second Post", body: "" });
expect(service.list()).toHaveLength(2);
});
it("filters by a case-insensitive substring match on the title", () => {
const service = new PostsService();
service.create({ title: "Hello World", body: "" });
service.create({ title: "Second Post", body: "" });
const results = service.list("hello");
expect(results).toHaveLength(1);
expect(results[0]?.title).toBe("Hello World");
});
});

Run it:

Terminal window
pnpm vitest run

Both fail — list() doesn’t accept an argument yet, and even ignoring that, it doesn’t filter. That’s expected; the test describes behavior that doesn’t exist yet.

src/posts/posts.service.ts
list(search?: string): Post[] {
const posts = [...this.#posts.values()];
if (!search) {
return posts;
}
const term = search.toLowerCase();
return posts.filter((post) => post.title.toLowerCase().includes(term));
}

Run the tests again — both pass. Nothing else in PostsService needed to change.

Red: an end-to-end test through the real HTTP layer

Section titled “Red: an end-to-end test through the real HTTP layer”

The unit test proves the service filters correctly. It doesn’t prove a request with ?search= actually reaches it — that’s a controller/routing concern, and it deserves its own test, through the real application:

src/posts/posts.e2e.test.ts
import { Test } from "@blixis-io/testing";
import { describe, expect, it } from "vitest";
import { PostsModule } from "./posts.module.js";
describe("GET /posts?search=", () => {
it("filters results by the search query param", async () => {
const app = await Test.createModule({ imports: [PostsModule] }).compile();
await app.request("/posts", { method: "POST", json: { title: "Hello World" } });
await app.request("/posts", { method: "POST", json: { title: "Second Post" } });
const res = await app.request("/posts?search=hello");
expect(res.status).toBe(200);
const posts = (await res.json()) as Array<{ title: string }>;
expect(posts).toHaveLength(1);
expect(posts[0]?.title).toBe("Hello World");
await app.close();
});
});

This fails too, for a different reason than the unit test did: PostsController.list() doesn’t read ?search= from the query string at all yet, so it always returns everything.

src/posts/posts.controller.ts
import { Query } from "@blixis-io/http";
import { z } from "zod";
@Get()
list(@Query(z.object({ search: z.string().optional() })) query: { search?: string }) {
return this.posts.list(query.search);
}

@Query(schema) parses and validates req.url’s search params into an object — see Request Validation — so query.search is already the right type by the time it reaches this.posts.list(). Run both test files again; everything’s green.

Testing PostsService directly first — no Test.createModule(), no HTTP, just new PostsService() — caught the filtering logic with the fastest possible feedback loop: no DI container, no router, nothing to build. The end-to-end test through Test.createModule() then proved the wiring — that a real ?search= query string actually reaches that logic through real routing and real param parsing. Both matter, and they catch different classes of mistake: a unit test can’t catch “the query param is never read”; an end-to-end test alone would make you debug through three layers to find a filtering bug that a two-line unit test would have pinpointed immediately.

  • What Test.createModule().compile() builds and why it’s a real application, not a mock: Testing.
  • Swap in a fake dependency instead of exercising the real one: Overriding Providers in Tests.