Version: 2.0.0
Last Updated: 2025-01-05
Status: Active
This document defines testing standards for the Tome project, aligned with the Constitution's principle of "Trust but Verify."
- Philosophy
- Test Environment Setup
- Test Structure
- Naming Conventions
- Writing Tests
- Test Types
- Best Practices
- Common Patterns
- Vitest-Specific Patterns
- Troubleshooting
Tome's testing philosophy derives from the Constitution:
- "Trust but Verify" → Comprehensive testing with real databases
- "Protect User Data Above All" → Test data integrity and migrations rigorously
- "Make Complexity Invisible" → Test user-facing behavior, not implementation details
/\
/ \ E2E (Future)
/____\ 5% - Critical user flows
/ \
/________\ Integration Tests
/ \ 35% - API → Service → Repository
/____________\
/ \ Unit Tests
/________________\ 40% - Services, Lib, Hooks
Component Tests 20% - UI with mocked deps
Distribution Target: 40% Unit | 35% Integration | 20% Component | 5% E2E
✅ Always Test:
- API endpoints (every route must have tests)
- Service layer methods (business logic)
- Data transformations (calculations, aggregations)
- Critical user flows (progress logging, status changes)
- Data migrations
- UI components (focus on complex state, user interactions)
- Utility functions (test only non-trivial logic)
- Repository methods (basic CRUD covered by integration tests)
❌ Don't Test:
- Type definitions
- Configuration files
- Simple pass-through functions
- Third-party library functionality
All tests MUST run with TZ=UTC to ensure consistent date/time behavior across environments.
Many tests involve date and time operations (reading sessions, streaks, journal entries, progress tracking). Without TZ=UTC, tests will run in your local timezone, causing:
- Flaky tests - Pass on one machine but fail on another
- Date boundary issues - A timestamp like
2024-01-05T23:00:00is Jan 5 in UTC but Jan 6 in Tokyo - Inconsistent test data - Test fixtures assume UTC, but comparisons use local time
# Running without TZ=UTC
$ NODE_ENV=test bunx vitest run __tests__/api/session-edit.test.ts
# ❌ FAIL: expected '2025-11-04' to be '2025-11-05'
# 9 tests failed due to timezone mismatch
# Running with TZ=UTC
$ TZ=UTC NODE_ENV=test bunx vitest run __tests__/api/session-edit.test.ts
# ✅ PASS: All 28 tests passedRecommended: Use npm scripts (automatically sets TZ=UTC)
npm test # Run all tests
npm run test:watch # Watch modeManual execution:
TZ=UTC NODE_ENV=test bunx vitest run # All tests
TZ=UTC NODE_ENV=test bunx vitest run path/to/test.ts # Specific file
TZ=UTC NODE_ENV=test bunx vitest # Watch modeTZ=UTC will cause date-related tests to fail intermittently depending on your system timezone.
Tome uses Vitest as its test runner, chosen for:
- Fast: Native ESM support, parallel execution
- Compatible: Jest-like API, works with existing test patterns
- Cross-platform: Runs in both Bun and Node.js environments
- Better tooling: Built-in coverage, watch mode, UI
For SQLite operations in tests, we use better-sqlite3:
- Cross-platform: Works in Node.js and Bun
- Synchronous API: Simpler test code, no await overhead for DB operations
- Compatible: Similar API to bun:sqlite for easy migration
__tests__/
├── api/ # API route integration tests
│ ├── books.test.ts
│ ├── progress.test.ts
│ └── ...
├── services/ # Service layer unit tests
│ ├── book.service.test.ts
│ └── session.service.test.ts
├── lib/ # Library/utility tests
│ ├── streaks.test.ts
│ ├── sync-service.test.ts
│ └── ...
├── hooks/ # React hook tests
│ ├── useBookDetail.test.ts
│ └── useBookProgress.test.ts
├── components/ # Component tests
│ ├── BookHeader.test.tsx
│ └── BookMetadata.test.tsx
├── pages/ # Page-level tests
│ └── book-detail-page.test.tsx
├── integration/ # Cross-layer integration tests
│ └── library-service-api.test.ts
├── helpers/ # Test utilities
│ ├── db-setup.ts
│ └── performance.ts
└── fixtures/ # Shared test data
└── test-data.ts
Pattern: <feature-name>.test.ts or <ComponentName>.test.tsx
✅ Good:
books.test.ts(matches API route/api/books)book.service.test.ts(matchesbook.service.ts)useBookDetail.test.ts(matchesuseBookDetail.ts)BookHeader.test.tsx(matchesBookHeader.tsx)
❌ Bad:
test-books.ts(wrong prefix)books-spec.ts(wrong suffix)book_service.test.ts(inconsistent naming)
Use behavioral descriptions that explain what happens, not how:
✅ Good:
describe("Book Status Management", () => {
test("should mark book as read when progress reaches 100%", () => {
// Test auto-transition logic
});
test("should prevent status change when session is archived", () => {
// Test validation logic
});
});❌ Bad:
describe("updateStatus", () => {
test("test status update", () => {
// Vague description
});
test("should call sessionRepository.update", () => {
// Tests implementation, not behavior
});
});Structure: should [action] when [condition] or should [action]
Examples:
should return 404 when book not foundshould calculate streak correctly for consecutive daysshould archive previous session when starting re-readshould filter books by multiple tags
Avoid:
- Generic: "should work correctly"
- Implementation-focused: "should call X method"
- Redundant: "should test that..." (all tests test things!)
import { describe, test, expect, beforeAll, afterAll, beforeEach } from "vitest";
import { setupTestDatabase, teardownTestDatabase, clearTestDatabase } from "@/__tests__/helpers/db-setup";
describe("Feature Name", () => {
// ✅ API, Service, Integration tests: Always use database setup
beforeAll(async () => await setupTestDatabase(__filename));
afterAll(async () => await teardownTestDatabase(__filename));
beforeEach(async () => await clearTestDatabase(__filename));
// ⚠️ Library/utility tests: Skip database if testing pure functions
// ⚠️ React hooks/components: Add afterEach(() => cleanup()) from @testing-library/react
test("should do X when Y happens", async () => {
// Arrange, Act, Assert
const book = await bookRepository.create({ calibreId: 1, title: "Test" });
const result = await someFunction(book.id);
expect(result).toBe(expectedValue);
});
});| Type | ✅ Use | ❌ Avoid |
|---|---|---|
| Primitives | expect(id).toBe(123) |
expect(id).toEqual(123) |
| Objects | expect(obj).toEqual({...}) |
expect(obj).toBe({...}) |
| Partial match | expect(obj).toMatchObject({id: 1}) |
Full object comparison |
| Null/undefined | expect(x).toBeNull() |
expect(x).toBe(null) |
| Arrays | expect(arr).toHaveLength(3) |
expect(arr.length).toBe(3) |
| Contains | expect(arr).toContain("x") |
Manual array search |
HTTP Response Pattern:
const response = await GET(request);
expect(response.status).toBe(200); // Check status first
const data = await response.json(); // Then parse body
expect(data.books).toHaveLength(5); // Then assert on dataPurpose: Test HTTP endpoints with real database
Setup: Use standard test structure template
Example:
describe("GET /api/books", () => {
test("should return books with status filter", async () => {
const book = await bookRepository.create({ ... });
const response = await GET(createMockRequest("GET", "/api/books?status=reading"));
const data = await response.json();
expect(response.status).toBe(200);
expect(data.books[0].status).toBe("reading");
});
});Focus: HTTP contract, business logic, error cases (404, 400, 500)
Purpose: Test business logic in isolation
Setup: Use standard test structure template + instantiate service in beforeAll
Example:
describe("BookService", () => {
let bookService: BookService;
beforeAll(async () => {
await setupTestDatabase(__filename);
bookService = new BookService();
});
test("should throw error for invalid rating", async () => {
await expect(bookService.updateRating(1, 6))
.rejects.toThrow("Rating must be between 1 and 5");
});
});Focus: Business rules, validations, error handling. Mock external services (Calibre, file I/O).
Purpose: Test pure functions and utilities
Setup: No database needed for pure functions
Example:
describe("Streak Calculations", () => {
test("should detect consecutive days", () => {
const result = calculateStreakDays(yesterday, today);
expect(result).toBe(1);
});
});Focus: Mathematical correctness, edge cases, no side effects });
test("should detect broken streak", () => { const lastActivity = startOfDay(new Date("2025-11-14")); const today = startOfDay(new Date("2025-11-17"));
const result = calculateStreakDays(lastActivity, today);
expect(result).toBe(3); // 3 days difference = broken
}); });
**Key Principles**:
- Test pure functions without database
- Test edge cases (boundaries, null, empty)
- Test mathematical correctness
- Use descriptive test data
### 4. React Hook Tests (`__tests__/hooks/`)
**Purpose**: Test React hooks in isolation
**Setup**: Mock `global.fetch`, restore in `afterEach`
**Example**:
```typescript
import { vi } from "vitest";
describe("useBookDetail", () => {
const originalFetch = global.fetch;
beforeEach(() => {
global.fetch = vi.fn(() => Promise.resolve({
ok: true,
json: () => Promise.resolve({ id: 123, title: "Test" }),
})) as any;
});
afterEach(() => {
global.fetch = originalFetch;
});
test("should fetch book data on mount", async () => {
const { result } = renderHook(() => useBookDetail("123"));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(global.fetch).toHaveBeenCalledWith("/api/books/123");
});
});
Focus: Loading states, error handling, API interactions. Always restore mocks.
Purpose: Test React components in isolation
Setup: Mock Next.js components, call cleanup() in afterEach
Example:
import { vi } from "vitest";
vi.mock("next/image", () => ({
default: ({ alt, ...props }: any) => <img alt={alt} {...props} />
}));
describe("BookHeader", () => {
afterEach(() => cleanup());
test("should render status dropdown", () => {
render(<BookHeader selectedStatus="to-read" {...props} />);
expect(screen.getByText("Want to Read")).toBeInTheDocument();
});
});Focus: Rendering with different props, user interactions, conditional rendering
- Use Real Database - Integration tests use
setupTestDatabase(__filename) - Test Behavior, Not Implementation - Assert outcomes, not internal method calls
- Use Descriptive Test Data -
bookWithoutPages>book(with null pages) - Test Error Cases - Every endpoint tests 404, 400, validation errors
- Isolate Tests -
clearTestDatabase(__filename)inbeforeEach
- Don't Mock Application Code - Use real services with test database. Exception: Mock at service boundaries for external I/O (see Service Layer Pattern)
- Don't Use
as any- Use proper types or helpers:createTestBook() - Don't Test Third-Party Libraries - Test your usage, not
parseISO()itself - Don't Share State Between Tests - Each test creates its own data
- Don't Leave Commented-Out Code - Remove or explain with comments
Always use the @ alias for imports - both for source code and test utilities.
// Source code imports
import { bookRepository } from "@/lib/repositories";
import { LibraryFilters } from "@/components/Library/LibraryFilters";
import { GET, POST } from "@/app/api/books/route";
// Test utility imports
import { setupTestDatabase, clearTestDatabase } from "@/__tests__/helpers/db-setup";
import { createMockRequest, createTestBook } from "@/__tests__/fixtures/test-data";
import { toProgressDate, toSessionDate } from "@/__tests__/test-utils";// ❌ Bad - Fragile relative paths
import { setupTestDatabase } from "../../../helpers/db-setup";
import { createMockRequest } from "../../fixtures/test-data";
import { toProgressDate } from '../test-utils';- Maintainability - Tests can be moved without breaking imports
- Consistency - Matches project-wide import conventions
- Readability - Clear where imports come from (
@/= source,@/__tests__/= test utils) - IDE Support - Better autocomplete and refactoring support
- No Path Counting - No need to count
../levels
When testing code that interacts with external dependencies (file systems, external databases, third-party APIs), use a service layer abstraction to enable clean mocking at integration boundaries while keeping unit tests pure.
This pattern solves two problems:
- Avoids module mock pollution - Mocking implementation modules can leak to other tests
- Enables dependency injection - Services can accept mock implementations for testing
┌─────────────────────────────────────────────────────────┐
│ API Routes / Application Code │
│ └─> Uses: bookService.updateRating() │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ BookService (business logic) │
│ └─> Depends on: calibreService (injected) │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ CalibreService (external dependency abstraction) │
│ └─> Wraps: calibre-write module functions │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ calibre-write module (low-level implementation) │
│ └─> Direct SQLite file I/O to Calibre database │
└─────────────────────────────────────────────────────────┘
Testing Strategy:
- Unit tests (
__tests__/lib/calibre-write.test.ts): Test actual implementation with injected test database - Integration tests (
__tests__/api/rating.test.ts): Mock service layer to isolate from file system
// lib/services/calibre.service.ts
/**
* Interface for Calibre operations
* Makes it easy to create test mocks
*/
export interface ICalibreService {
updateRating(calibreId: number, rating: number | null): void;
updateTags(calibreId: number, tags: string[]): void;
}
/**
* Service class wraps low-level module functions
*/
export class CalibreService implements ICalibreService {
updateRating(calibreId: number, rating: number | null): void {
return updateCalibreRatingImpl(calibreId, rating);
}
updateTags(calibreId: number, tags: string[]): void {
return updateCalibreTagsImpl(calibreId, tags);
}
}
// Default singleton for production use
export const calibreService = new CalibreService();// lib/services/book.service.ts
export class BookService {
private calibre?: ICalibreService;
constructor(calibre?: ICalibreService) {
this.calibre = calibre;
}
/**
* Lazy load calibre service to support test mocking
* Always re-imports to ensure test mocks are applied
*/
private getCalibreService(): ICalibreService {
if (this.calibre) {
return this.calibre;
}
// Lazy import - don't cache to support test mocking
const { calibreService } = require("@/lib/services/calibre.service");
return calibreService;
}
async updateRating(bookId: number, rating: number | null): Promise<Book> {
const book = await bookRepository.findById(bookId);
// Use injected or lazy-loaded service
await this.getCalibreService().updateRating(book.calibreId, rating);
return bookRepository.update(bookId, { rating });
}
}
// Default singleton for production use
export const bookService = new BookService();// __tests__/lib/calibre-write.test.ts
import { Database } from "bun:sqlite";
import { updateCalibreRating, readCalibreRating } from "@/lib/db/calibre-write";
describe("Calibre Write Operations", () => {
let testDb: Database;
beforeAll(() => {
// Create in-memory test database with Calibre schema
testDb = new Database(":memory:");
createCalibreSchema(testDb);
});
test("should update rating in Calibre database", () => {
// Test actual implementation by injecting test database
updateCalibreRating(1, 5, testDb);
const rating = readCalibreRating(1, testDb);
expect(rating).toBe(5);
});
});// __tests__/api/rating.test.ts
import { vi } from "vitest";
// Mock the service layer (not the implementation)
const { mockUpdateRating } = vi.hoisted(() => ({
mockUpdateRating: vi.fn(),
}));
vi.mock("@/lib/services/calibre.service", () => ({
calibreService: {
updateRating: (calibreId: number, rating: number | null) => {
mockUpdateRating(calibreId, rating);
},
},
CalibreService: class {},
}));
// Import after mock is set up
import { POST } from "@/app/api/books/[id]/rating/route";
test("should update book rating and sync to Calibre", async () => {
const book = await bookRepository.create({ calibreId: 123, ... });
const response = await POST(createMockRequest("POST", "/api/books/123/rating", {
rating: 5
}));
// Verify API updated Tome database
expect(response.status).toBe(200);
// Verify API called Calibre service
expect(mockUpdateRating).toHaveBeenCalledWith(123, 5);
});✅ Use service layer abstraction when:
- Interacting with external file systems (Calibre database)
- Calling third-party APIs (webhooks, email services)
- Performing expensive I/O operations (image processing, file uploads)
- Testing code that depends on these operations
❌ Don't use service layer when:
- Working with your own database (use test database instead)
- Testing pure functions (no external dependencies)
- Mocking would hide important business logic
- No Test Pollution: Mocking service layer doesn't affect implementation tests
- Clean Boundaries: Clear separation between business logic and external I/O
- Testable: Can test both implementation (with test DB) and integration (with mocks)
- Flexible: Easy to swap implementations (production vs. test vs. mock)
- Type-Safe: Interfaces ensure mock compatibility
Problem: Tests for calibre-write module were being polluted by module mocks in API tests.
Solution:
- Created
CalibreServiceto wrapcalibre-writefunctions BookServiceuses lazy-loadedcalibreService- API tests mock
CalibreService(service layer) - Unit tests test
calibre-writedirectly (implementation layer)
Result:
calibre-write.test.tstests actual Calibre database operationsrating.test.tsverifies API behavior without file I/O- No mock leakage between test files
test("should auto-calculate percentage from pages", async () => {
const { book, session } = await createTestBookWithSession({ totalPages: 300 });
const response = await POST(
createMockRequest("POST", `/api/books/${book.id}/progress`, {
sessionId: session.id,
currentPage: 150,
})
);
const data = await response.json();
expect(data.currentPercentage).toBe(50); // 150/300 = 50%
});test("should archive previous session when starting re-read", async () => {
const { book, session } = await createTestBookWithSession({
status: "read",
completedDate: new Date("2024-01-01")
});
const response = await POST(createMockRequest("POST", "/api/books/reread", { bookId: book.id }));
expect((await sessionRepository.findById(session.id))?.isActive).toBe(false);
expect((await response.json()).sessionNumber).toBe(2);
});test("should filter books by multiple criteria", async () => {
const book1 = await createTestBook({ title: "Fantasy", tags: ["fantasy"] });
const book2 = await createTestBook({ title: "Sci-Fi", tags: ["sci-fi"] });
await sessionRepository.create({ bookId: book1.id, status: "reading" });
const response = await GET(createMockRequest("GET", "/api/books?status=reading&tags=fantasy"));
const data = await response.json();
expect(data.books).toHaveLength(1);
expect(data.books[0].title).toBe("Fantasy");
});test("should increment streak for consecutive days", async () => {
await streakRepository.upsertStreak({
currentStreak: 5,
lastActivityDate: new Date("2025-11-16"),
});
const { book, session } = await createTestBookWithSession();
await POST(createMockRequest("POST", `/api/books/${book.id}/progress`, {
sessionId: session.id,
currentPage: 50,
}));
const streak = await streakRepository.getActiveStreak();
expect(streak?.currentStreak).toBe(6);
});test("should return 400 for invalid input", async () => {
const response = await POST(createMockRequest("POST", "/api/books", { title: "Test" }));
expect(response.status).toBe(400);
});
test("should return 404 for non-existent book", async () => {
const response = await GET(createMockRequest("GET", "/api/books/99999"));
expect(response.status).toBe(404);
});
test("should handle database constraint violations", async () => {
const book = await createTestBook();
await expect(createTestBook({ calibreId: book.calibreId })).rejects.toThrow();
});See Service Layer Testing Pattern for full architecture and implementation details.
This pattern uses two complementary approaches:
Approach A: Unit Tests - Test implementation with injected test database
- File:
__tests__/lib/calibre-write.test.ts - Method: Inject in-memory Calibre database into production functions
- Tests: Business logic, scale conversion, database constraints
Approach B: Integration Tests - Mock service layer to isolate from file system
- File:
__tests__/api/rating.test.ts,__tests__/api/tags.test.ts - Method: Mock
CalibreServiceat service boundary - Tests: API contract, error handling, best-effort sync behavior
Comparison:
| Aspect | Unit Tests (Approach A) | Integration Tests (Approach B) |
|---|---|---|
| What's tested | Low-level implementation | End-to-end API flow |
| Database | Calibre test schema | Tome test database |
| Mocking | None (real functions) | Service layer only |
| Coverage | Business logic, edge cases | API contract, error handling |
Result: Both approaches together provide confidence in implementation correctness and integration behavior without file system dependencies or test pollution.
Problem: Variables referenced in vi.mock() factory functions must be hoisted to module scope.
Solution: Use vi.hoisted() to create variables accessible in mock factories:
import { vi } from "vitest";
// ✅ CORRECT: Use vi.hoisted() for shared mock state
const { mockCalibres, resetMockCalibres } = vi.hoisted(() => {
const calls: Array<{ id: number; rating: number }> = [];
return {
mockCalibres: calls,
resetMockCalibres: () => calls.splice(0, calls.length),
};
});
vi.mock("@/lib/services/calibre.service", () => ({
calibreService: {
updateRating: (id: number, rating: number) => {
mockCalibres.push({ id, rating }); // Now accessible!
},
},
}));
// Import AFTER mock is defined
import { updateBookRating } from "@/lib/services/book.service";
describe("Book Rating", () => {
beforeEach(() => {
resetMockCalibres();
});
test("should sync rating to Calibre", async () => {
await updateBookRating(1, 5);
expect(mockCalibres).toHaveLength(1);
expect(mockCalibres[0]).toEqual({ id: 1, rating: 5 });
});
});Key Points:
vi.hoisted()ensures variables are available during mock factory execution- Must be called before
vi.mock() - Returns the values that will be accessible in the mock
When working with SQLite in tests, use better-sqlite3 (cross-platform) instead of bun:sqlite:
| Operation | better-sqlite3 | Notes |
|---|---|---|
| Import | import Database from "better-sqlite3" |
Default export |
| Type | Database.Database |
Namespace type |
| DDL statements | db.exec(sql) |
For CREATE, ALTER, etc. |
| Prepared statements | db.prepare(sql).run() |
✅ Same API |
| No results | Returns undefined |
Use toBeFalsy() in assertions |
| Readonly mode | new Database(path, { readonly: true }) |
✅ Same API |
Example:
import Database from "better-sqlite3";
describe("Calibre Integration", () => {
let testDb: Database.Database;
beforeAll(() => {
testDb = new Database(":memory:");
// DDL: Use exec()
testDb.exec(`CREATE TABLE books (id INTEGER PRIMARY KEY, title TEXT)`);
// DML: Use prepare().run()
testDb.prepare("INSERT INTO books (id, title) VALUES (?, ?)").run(1, "Test");
});
test("should query book", () => {
const book = testDb.prepare("SELECT * FROM books WHERE id = ?").get(1);
expect(book).toBeDefined();
// No result returns undefined (not null)
const missing = testDb.prepare("SELECT * FROM books WHERE id = ?").get(999);
expect(missing).toBeFalsy(); // Works for both null and undefined
});
});Waiting for state changes:
import { waitFor } from "@testing-library/react";
test("should update after async operation", async () => {
const { result } = renderHook(() => useBookDetail("123"));
// Wait for loading to finish
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBeDefined();
});Testing error states:
test("should handle fetch errors", async () => {
global.fetch = vi.fn(() => Promise.reject(new Error("Network error")));
const { result } = renderHook(() => useBookDetail("123"));
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.error?.message).toBe("Network error");
});Setting environment variables:
describe("Environment Config", () => {
const originalEnv = process.env.CALIBRE_DB_PATH;
beforeAll(() => {
process.env.CALIBRE_DB_PATH = "/test/path/metadata.db";
});
afterAll(() => {
if (originalEnv) {
process.env.CALIBRE_DB_PATH = originalEnv;
} else {
delete process.env.CALIBRE_DB_PATH;
}
});
test("should use test database path", () => {
expect(process.env.CALIBRE_DB_PATH).toBe("/test/path/metadata.db");
});
});Spy - Track calls to real function:
const spy = vi.spyOn(bookRepository, 'findById');
await bookService.getBook(123);
expect(spy).toHaveBeenCalledWith(123);
spy.mockRestore(); // Restore originalMock - Replace entire module:
vi.mock("@/lib/db/calibre", () => ({
getCalibreDB: vi.fn(() => mockDb),
}));Stub - Replace function implementation:
const stub = vi.fn(() => ({ id: 1, title: "Test" }));
bookRepository.findById = stub;Cause: Forgot to call setupTestDatabase(__filename)
Solution:
beforeAll(async () => {
await setupTestDatabase(__filename);
});Cause: Missing clearTestDatabase() in beforeEach
Solution:
beforeEach(async () => {
await clearTestDatabase(__filename);
});Cause: Import path is incorrect after test reorganization
Solution: Update import paths. Use @/ for absolute imports from project root:
import { bookRepository } from "@/lib/repositories";Cause: Using vi.mock() without proper hoisting or cleanup
Symptoms:
- Test file A mocks a module, but Test file B (which doesn't mock) is affected
- Functions return undefined or behave unexpectedly in unrelated tests
- Tests pass individually but fail when run together
Solutions (in order of preference):
Create a service layer to wrap external dependencies, then mock the service instead of the implementation:
// ✅ Good - Mock service layer (CalibreService pattern)
// __tests__/api/rating.test.ts
import { vi } from "vitest";
vi.mock("@/lib/services/calibre.service", () => ({
calibreService: {
updateRating: vi.fn(() => {}),
},
}));
// __tests__/lib/calibre-write.test.ts - NOT AFFECTED
// Tests actual implementation with injected test database
import { updateCalibreRating } from "@/lib/db/calibre-write";Why this works:
- Service layer and implementation are separate modules
- Mocking service doesn't affect implementation tests
- Clean separation of concerns
- See "Service Layer Testing Pattern" section for full details
Real-world example:
- Created
CalibreServiceto wrapcalibre-writefunctions - API tests mock
CalibreService(no leakage to implementation tests) - Unit tests test
calibre-writedirectly with test database
// ✅ Correct: Use vi.hoisted() for shared mock state
const { mockData } = vi.hoisted(() => {
return {
mockData: { id: 1, title: "Test" }
};
});
vi.mock("@/lib/services/book.service", () => ({
bookService: {
getBook: vi.fn(() => mockData),
},
}));// Production code accepts optional dependencies
export class BookService {
constructor(private calibre?: ICalibreService) {}
private getCalibreService() {
if (this.calibre) return this.calibre;
return require("@/lib/services/calibre.service").calibreService;
}
}
// Tests inject mocks
const mockCalibre = { updateRating: vi.fn(() => {}) };
const bookService = new BookService(mockCalibre);// lib/db/calibre-write.ts
function getLoggerSafe() {
if (process.env.NODE_ENV === 'test') {
return { info: () => {}, error: () => {} }; // No-op logger
}
return getLogger();
}Related sections:
- See "Service Layer Testing Pattern" for architecture details
- See Pattern 6 for testing external dependencies examples
- See "Vitest-Specific Patterns" for vi.hoisted() usage
Cause: Missing test setup for DOM environment
Solution: Ensure test-setup.ts is configured in vitest.config.ts:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
setupFiles: ['./test-setup.ts'],
environment: 'jsdom',
globals: true,
},
});Cause: Missing await on async operations
Solution: Ensure all async operations are awaited:
// ❌ Bad
test("should update book", () => {
bookRepository.update(1, { title: "New Title" }); // Missing await!
});
// ✅ Good
test("should update book", async () => {
await bookRepository.update(1, { title: "New Title" });
});Cause: Test data doesn't match schema types exactly
Solution: Create properly typed test data or use type helpers:
// Option 1: Inline with all required fields
const book = await bookRepository.create({
calibreId: 1,
title: "Test",
authors: ["Author"],
tags: [],
path: "Author/Test (1)",
// Include all required fields
});
// Option 2: Create a type helper (in fixtures/test-data.ts)
function createTestBook(overrides?: Partial<NewBook>): NewBook {
return {
calibreId: 1,
title: "Test Book",
authors: ["Test Author"],
tags: [],
path: "Test Author/Test Book (1)",
orphaned: false,
...overrides,
};
}import { describe, test, expect, beforeAll, afterAll, beforeEach } from "vitest";
import { setupTestDatabase, teardownTestDatabase, clearTestDatabase } from "@/__tests__/helpers/db-setup";
describe("Feature Name", () => {
beforeAll(async () => {
await setupTestDatabase(__filename);
});
afterAll(async () => {
await teardownTestDatabase(__filename);
});
beforeEach(async () => {
await clearTestDatabase(__filename);
});
test("should do something", async () => {
// Arrange
// Act
// Assert
});
});// Test framework
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from "vitest";
// Database helpers
import { setupTestDatabase, teardownTestDatabase, clearTestDatabase } from "@/__tests__/helpers/db-setup";
// Repositories
import { bookRepository, sessionRepository, progressRepository, streakRepository } from "@/lib/repositories";
// Test data
import { createMockRequest } from "@/__tests__/fixtures/test-data";
// React testing
import { render, screen, cleanup, waitFor } from "@testing-library/react";
import { renderHook, act } from "@testing-library/react";
import "@testing-library/jest-dom";# Run all tests (automatically sets TZ=UTC via package.json script)
npm test
# Run tests in watch mode
npm run test:watch
# Run specific test file
TZ=UTC NODE_ENV=test bunx vitest run __tests__/api/books.test.ts
# Run tests matching pattern
TZ=UTC NODE_ENV=test bunx vitest run -t "should filter books"
# Run with UI
TZ=UTC NODE_ENV=test bunx vitest --ui
# Run with coverage
npm run test:coverage
# Debug a specific test
TZ=UTC NODE_ENV=test bunx vitest run __tests__/api/books.test.ts --reporter=verbose- 2.0.0 (2025-01-05): Vitest migration
- Migrated from Bun Test to Vitest test runner
- Added TZ=UTC environment requirement and explanation in "Test Environment Setup" section
- Updated all code examples to use Vitest syntax (
vi.mock,vi.hoisted,vi.fn) - Replaced
bun:sqlitewithbetter-sqlite3in examples and actual tests - Added comprehensive "Vitest-Specific Patterns" section covering:
- Mock hoisting with
vi.hoisted() - better-sqlite3 API differences table
- Async test patterns with
waitFor() - Environment variable handling
- Spy vs Mock vs Stub patterns
- Mock hoisting with
- Removed Bun-specific troubleshooting (module caching issue - lines 961-1053)
- Updated test commands to use npm scripts with automatic TZ=UTC handling
- Updated test setup references from
bunfig.tomltovitest.config.ts - Updated mock leakage troubleshooting with
vi.hoisted()patterns - All 103 test files passing in Vitest with cross-platform compatibility
- 1.2.0 (2025-12-28): Optimized for AI consumption (19% reduction)
- Condensed Pattern 6 to cross-reference Service Layer section (removed 140 lines of duplication)
- Consolidated test structure templates into single authoritative template (removed 90 lines)
- Converted Assertion Style Guide to table format (removed 35 lines)
- Streamlined Test Types section with references to main template (removed 60 lines)
- Created
test-data-helpers.tsto support condensed examples - Reduced from 1,578 to 1,275 lines (303 lines saved)
- 1.1.0 (2025-12-28): Added service layer testing pattern
- Documented CalibreService abstraction pattern for external dependencies
- Added Pattern 6: Testing External Dependencies (Calibre Database)
- Updated mock leakage troubleshooting with service layer solution
- Clarified when to mock at service boundaries vs. testing with real databases
- Added lazy loading pattern for dependency injection in services
- 1.0.0 (2025-11-26): Initial version based on codebase analysis
Questions or Suggestions?
Open an issue with the testing label on GitHub.