| name | testing |
| description | Use when writing, reviewing, or improving tests for any package in the Anticapture monorepo. Covers unit, integration, and E2E tests. |
Testing Guide
Use This Skill When
- You are writing new tests for services, repositories, controllers, or utilities.
- You are reviewing existing tests for quality or correctness.
- You are increasing test coverage in any package.
- You are debugging a failing test.
Test Runners by Package
The monorepo uses two runners — check which one before writing test code:
| Package | Runner | Globals |
|---|
apps/api, apps/gateful | Vitest | vi.fn(), vi.useFakeTimers(), ... |
apps/dashboard | Jest | jest.fn(), jest.useFakeTimers(), ... |
apps/indexer | — | no test suite; verify with typecheck + lint |
Code examples below use Vitest (vi.*) syntax; in the dashboard, swap vi for jest
— the APIs are otherwise identical for everything this guide uses.
Rule 1: Test Pyramid
Follow the test pyramid strategy with three test types.
Target ratio: ~70% unit, ~20% integration, ~10% E2E
-
Unit Tests (majority)
- Fast, cheap, isolated
- Test business logic in services and utilities
- Mock external dependencies (database, APIs)
-
Integration Tests (moderate)
- Test component interactions
- Verify API endpoints with real HTTP calls
- May use test database
-
E2E Tests (few)
- Test critical user flows end-to-end
- Slow and expensive to maintain
- Reserve for high-value scenarios only
Rule 2: Test Doubles Strategy
Prefer stubs and fakes over mocks to avoid brittle tests.
| Type | Use When | Example |
|---|
| Stub | Need fixed return values | { findAll: () => [mockData] } |
| Fake | Need working simplified implementation | InMemoryRepository |
| Mock | Need to verify a call was made (use sparingly) | vi.fn() with toHaveBeenCalled |
Why?
- Mocks verify implementation → tests break on refactor
- Stubs/Fakes verify behavior → tests survive refactor
Example
expect(repository.findById).toHaveBeenCalledWith("123");
const stub = { findById: () => mockAccount };
const result = await service.getAccount("123");
expect(result.name).toBe("vitalik.eth");
Exception: Use mocks when the call itself IS the behavior (e.g., verifying an event was emitted, an email was sent).
Rule 3: Arrange-Act-Assert (AAA) Pattern
Structure every test using AAA for readability and consistency. Do not write // Arrange, // Act, // Assert comments — use blank lines to separate sections visually.
it("should calculate delegation percentage", async () => {
const mockData = [createMockRow({ delegated: 100n, total: 1000n })];
stubRepository.getDelegationPercentage.mockResolvedValue(mockData);
const result = await service.execute({ dao: "uni" });
expect(result[0].percentage).toBe(10);
});
Rule 4: Test Coverage Policy
Write tests for all new business logic. There is no minimum coverage enforced in CI.
What must have tests:
- Services and their business rules
- Utility functions (
lib/)
- Calculations (voting power, percentages, etc.)
What may not have tests:
- Infrastructure/boilerplate code
- UI components (nice-to-have, not required)
- Configuration and wiring
Rule 5: Test Happy Paths and Break Your Code
Cover the happy path first, then actively try to break your code.
Common edge cases to consider:
| Category | Examples |
|---|
| Empty values | null, undefined, [], "", {} |
| Zeros and boundaries | 0, 1, -1, MAX_INT, overflow |
| Divisions | Division by zero, percentages > 100% |
| Dates | Future timestamps, distant past, midnight edge cases |
| Strings | Unicode, whitespace, special characters |
| Concurrency | Missing data, unexpected order |
Rule 6: Deterministic Tests
Write tests that produce the same result every time, in any environment, in any order.
Never depend on:
- Execution order between tests
- System date/time (
Date.now())
- Data generated by other tests
- Randomness without a fixed seed
- Shared global state
How to handle dates
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-15T00:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
Rule 7: Always Assert the Full Object
Never use expect to check if a property exists or to validate only part of an object. Always assert the entire object using toEqual.
expect(result).toHaveProperty("interactionCount");
expect(result).toMatchObject({ interactions: [] });
expect(result).toEqual({ interactionCount: 0, interactions: [] });
When the same expected value is used across multiple tests, extract it as a shared const:
const EMPTY_INTERACTIONS = { interactionCount: 0, interactions: [] };
Rule 8: Controller Tests Are Always Integration Tests
Controller tests must use the real application stack — no mocks for services, repositories, or database layers. External HTTP calls (third-party APIs) are intercepted and controlled via MSW.
import { testClient } from "hono/testing";
import { setupServer } from "msw/node";
import { app } from "../app";
const server = setupServer();
const client = testClient(app);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
it("should return interactions for an account", async () => {
const res = await client.account[":address"].interactions.$get({
param: { address: "0x123" },
query: { dao: "uni" },
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ interactionCount: 1, interactions: [...] });
});
Never inject fake services or repositories into the controller for testing — only use MSW to control external boundaries.
Rule 9: Type Everything — No Unsafe Casts
All test code must be correctly typed. Never use as any, as unknown, or other unsafe casts.
In unit tests for services and repositories, implement the actual TypeScript interface to build fakes and stubs:
const fakeRepo = { findAll: () => [] } as any;
import type { AccountRepository } from "../repositories/account";
const fakeRepo: AccountRepository = {
findAll: async () => [],
findById: async () => null,
};
Useful Tools
1. Testing HTTP (Hono)
import { testClient } from "hono/testing";
import { app } from "../app";
const client = testClient(app);
it("should return proposals", async () => {
const res = await client.proposals.$get({
query: { dao: "uni", limit: "10" },
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.proposals).toHaveLength(10);
});
2. Test Database
Use PGLite for integration tests.
3. External APIs
Use MSW (Mock Service Worker) to intercept HTTP requests:
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
const server = setupServer(
http.get("https://api.coingecko.com/simple/price", () => {
return HttpResponse.json({ uniswap: { usd: 7.5 } });
}),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Important Patterns
1. Test Data Builders / Factories
export function createProposal(overrides?: Partial<Proposal>): Proposal {
return {
id: randomId(),
dao: "uni",
title: "Test Proposal",
status: "active",
createdAt: new Date(),
...overrides,
};
}
2. Database Seeding
async function seedTestData() {
const account = createAccount({ address: "0x123" });
const proposal = createProposal({ dao: "uni" });
const vote = createVote({ proposalId: proposal.id, voter: account.address });
await db.insert(accounts).values(account);
await db.insert(proposals).values(proposal);
await db.insert(votes).values(vote);
return { account, proposal, vote };
}