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

Test Database Isolation

The pollution problem

Test A creates a book. Test B expects an empty database. If B runs after A, it finds a book it did not create. B fails — not because of a bug, but because of leftover data from A. This is test pollution.

Strategy 1: DELETE before each test

The simplest approach — clear all tables before each test:

beforeEach(() => {
  // Order matters: delete children before parents (foreign keys)
  testDb.exec("DELETE FROM reviews");
  testDb.exec("DELETE FROM books");
  testDb.exec("DELETE FROM authors");
});

This is what the Factories lesson used. It works well for small schemas. For large schemas with many tables, the DELETE list grows long.

Strategy 2: Drop and recreate

For larger schemas, drop all tables and recreate:

function resetDatabase() {
  testDb.exec("DROP TABLE IF EXISTS reviews");
  testDb.exec("DROP TABLE IF EXISTS books");
  testDb.exec("DROP TABLE IF EXISTS authors");

  testDb.exec(`
    CREATE TABLE authors (...);
    CREATE TABLE books (...);
    CREATE TABLE reviews (...);
  `);
}

beforeEach(() => {
  resetDatabase();
});

Heavier but guarantees a completely fresh state — no stale indexes, no orphaned data.

Strategy 3: Transaction rollback

The most elegant approach: wrap each test in a transaction and roll it back:

import { beforeEach, afterEach } from "vitest";

beforeEach(() => {
  testDb.exec("BEGIN");
});

afterEach(() => {
  testDb.exec("ROLLBACK");
});

Every INSERT, UPDATE, DELETE within the test happens inside a transaction. After the test, ROLLBACK undoes everything — the database returns to its pre-test state. No DELETE statements needed. Very fast.

[!NOTE] The Database Design course’s Transactions lesson covered BEGIN, COMMIT, and ROLLBACK. Here, ROLLBACK is used as a cleanup mechanism — every change the test makes is undone automatically.

The limitation: if your application code uses transactions internally (e.g., db.transaction(() => { ... })), nesting transactions in SQLite requires savepoints. For most test cases, the DELETE approach is simpler.

Parallel test isolation

Vitest runs test files in parallel by default. Each file gets its own worker. If all files share the same in-memory database, they can interfere with each other.

Two solutions:

One database per file: Create the in-memory database in each test file, not in a shared setup:

// At the top of each test file
const db = new Database(":memory:");
// ... create schema, use this db

Sequential execution: Run tests sequentially with vitest --no-threads:

{
  "test": "vitest run --no-threads"
}

For API tests with a shared database, sequential execution is simpler. The tests are fast enough (in-memory SQLite) that parallelism is not necessary.

Exercises

Exercise 1: Implement DELETE-based cleanup. Run two tests that create data. Verify each starts clean.

Exercise 2: Implement transaction-based cleanup. Compare the speed to DELETE-based.

Exercise 3: Run tests in parallel. Observe test pollution. Fix it with sequential execution or per-file databases.

Why is transaction rollback the most elegant test isolation strategy?

← Factories and Fixtures Request Helpers →

© 2026 hectoday. All rights reserved.