hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Testing APIs with @hectoday/http

Why Test

  • What Testing Gives You
  • Types of Tests
  • Project Setup

Unit Testing

  • Testing Pure Functions
  • Testing Zod Schemas
  • Testing Business Logic

Integration Testing

  • Testing Route Handlers
  • Testing GET Endpoints
  • Testing POST Endpoints
  • Testing Error Responses
  • Testing Authentication

Test Helpers

  • Factories and Fixtures
  • Test Database Isolation
  • Request Helpers

Advanced Testing

  • Mocking External Services
  • Testing Background Jobs
  • Testing Edge Cases

Putting It All Together

  • Test Organization
  • Checklist and Capstone

Factories and Fixtures

The data problem

Every integration test needs data. The GET tests seed books. The POST tests seed authors. The review tests seed books AND authors AND reviews. Copy-pasting INSERT statements into every test file is tedious and fragile — change the schema and you update dozens of tests.

Factory functions

A factory creates test data with sensible defaults. Override only what you need:

// tests/helpers/factories.ts
import { testDb } from "../setup.js";

let counter = 0;
function nextId(prefix: string): string {
  counter++;
  return `${prefix}-${counter}`;
}

export function createAuthor(overrides: Partial<{ id: string; name: string; bio: string }> = {}) {
  const id = overrides.id ?? nextId("author");
  const name = overrides.name ?? `Author ${id}`;
  const bio = overrides.bio ?? null;

  testDb.prepare("INSERT INTO authors (id, name, bio) VALUES (?, ?, ?)").run(id, name, bio);
  return { id, name, bio };
}

export function createBook(
  overrides: Partial<{
    id: string;
    title: string;
    authorId: string;
    genre: string;
    description: string;
  }> = {},
) {
  // Create an author if none specified
  const authorId = overrides.authorId ?? createAuthor().id;
  const id = overrides.id ?? nextId("book");
  const title = overrides.title ?? `Book ${id}`;
  const genre = overrides.genre ?? "fiction";
  const description = overrides.description ?? null;

  testDb
    .prepare("INSERT INTO books (id, title, author_id, genre, description) VALUES (?, ?, ?, ?, ?)")
    .run(id, title, authorId, genre, description);
  return { id, title, authorId, genre, description };
}

export function createReview(
  overrides: Partial<{
    id: string;
    bookId: string;
    userId: string;
    rating: number;
    body: string;
  }> = {},
) {
  const bookId = overrides.bookId ?? createBook().id;
  const id = overrides.id ?? nextId("review");
  const userId = overrides.userId ?? nextId("user");
  const rating = overrides.rating ?? 4;
  const body = overrides.body ?? null;

  testDb
    .prepare("INSERT INTO reviews (id, book_id, user_id, rating, body) VALUES (?, ?, ?, ?, ?)")
    .run(id, bookId, userId, rating, body);
  return { id, bookId, userId, rating, body };
}

Notice: createBook automatically creates an author if none is specified. createReview automatically creates a book (which creates an author). Dependencies are handled automatically.

Using factories in tests

describe("GET /v2/books/:id", () => {
  test("returns book with ratings", async () => {
    const book = createBook({ title: "Kindred" });
    createReview({ bookId: book.id, rating: 5 });
    createReview({ bookId: book.id, rating: 3 });

    const response = await app.fetch(new Request(`http://localhost/v2/books/${book.id}`));
    const data = await response.json();

    expect(data.title).toBe("Kindred");
    expect(data.ratings.average).toBe(4);
    expect(data.ratings.count).toBe(2);
  });
});

Three lines of setup instead of six INSERT statements. The test reads clearly: create a book, add two reviews, check the result.

Resetting between tests

Reset the counter and database in beforeEach:

import { beforeEach } from "vitest";
import { testDb } from "../setup.js";

beforeEach(() => {
  testDb.exec("DELETE FROM reviews; DELETE FROM books; DELETE FROM authors;");
});

Each test starts with an empty database. Factories create only the data that test needs.

Fixtures for complex scenarios

For tests that need a specific complex state, create a fixture function:

export function seedCatalog() {
  const butler = createAuthor({ name: "Octavia Butler" });
  const borges = createAuthor({ name: "Jorge Luis Borges" });

  const kindred = createBook({ title: "Kindred", authorId: butler.id, genre: "science-fiction" });
  const ficciones = createBook({ title: "Ficciones", authorId: borges.id, genre: "fiction" });

  createReview({ bookId: kindred.id, rating: 5 });
  createReview({ bookId: kindred.id, rating: 4 });
  createReview({ bookId: ficciones.id, rating: 5 });

  return { butler, borges, kindred, ficciones };
}
test("returns books ordered by title", async () => {
  seedCatalog();
  const response = await app.fetch(new Request("http://localhost/v2/books"));
  const data = await response.json();

  expect(data[0].title).toBe("Ficciones");
  expect(data[1].title).toBe("Kindred");
});

Exercises

Exercise 1: Create factory functions for authors, books, and reviews. Use them in three tests.

Exercise 2: Create a seedCatalog fixture. Use it in tests that need a populated catalog.

Exercise 3: Add beforeEach to clear the database. Verify tests are independent — each passes alone and together.

Why should factories automatically create dependencies (e.g., createBook creates an author)?

← Testing Authentication Test Database Isolation →

© 2026 hectoday. All rights reserved.