Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
289 changes: 289 additions & 0 deletions .claude/rules/testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
# Testing Guidelines

Follow these rules when writing tests for the Anticapture project.

---

## Rule 1: Test Pyramid

Follow the test pyramid strategy with three test types.
**Target ratio:** ~70% unit, ~20% integration, ~10% E2E

1. **Unit Tests** (majority)
- Fast, cheap, isolated
- Test business logic in services and utilities
- Mock external dependencies (database, APIs)

2. **Integration Tests** (moderate)
- Test component interactions
- Verify API endpoints with real HTTP calls
- May use test database

3. **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) | `jest.fn()` with `toHaveBeenCalled` |

### Why?

- **Mocks verify implementation** → tests break on refactor
- **Stubs/Fakes verify behavior** → tests survive refactor

### Example

```typescript
// ❌ Avoid: mock that verifies implementation
expect(repository.findById).toHaveBeenCalledWith("123");

// ✅ Prefer: stub that enables behavior testing
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.

### Structure

```typescript
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.)
- Data transformations (mappers)

### What may not have tests:

- Infrastructure/boilerplate code
- UI components (nice-to-have, not required)
- Configuration and wiring

> **Philosophy:** High coverage doesn't mean well-tested code. 100% coverage with bad tests is worse than 60% coverage with meaningful tests.

---

## Rule 5: Test Happy Paths and Break Your Code

Cover the **happy path** first, then actively try to break your code.

### Mindset

Don't just prove the code works — **try to break it**. Think like a malicious user or an unstable system.

### 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 |

### Example

```typescript
// ❌ Happy path only
it("should calculate percentage", () => {
expect(calcPercentage(50, 100)).toBe(50);
});

// ✅ Happy path + trying to break it
it("should calculate percentage", () => {
expect(calcPercentage(50, 100)).toBe(50);
});

it("should handle division by zero", () => {
expect(calcPercentage(50, 0)).toBe(0); // or throw?
});

it("should handle when value exceeds total", () => {
expect(calcPercentage(150, 100)).toBe(100); // cap? or 150?
});
```

---

## 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

```typescript
// ❌ Non-deterministic
const result = service.getRecentItems(); // uses Date.now() internally

// ✅ Deterministic - mock the time
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date("2025-01-15T00:00:00Z"));
});

afterEach(() => {
jest.useRealTimers();
});
```

### How to ensure isolation

```typescript
// ✅ Each test creates its own data
it("should find account by id", () => {
const account = createAccount({ id: "test-123" });
// ...
});

// ❌ Depends on data created in another test
it("should find the account", () => {
const account = repository.findById("test-123"); // created where?
});
```

---

## Useful Tools

### 1. Testing HTTP (Hono)

Use Hono's native test client:

```ts
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 Testcontainers for real PostgreSQL in Docker:

```ts
import { PostgreSqlContainer } from "@testcontainers/postgresql";

let container: PostgreSqlContainer;

beforeAll(async () => {
container = await new PostgreSqlContainer().start();
process.env.DATABASE_URL = container.getConnectionUri();
await runMigrations();
});

afterAll(async () => {
await container.stop();
});
```

### 3. External APIs

Use MSW (Mock Service Worker) to intercept HTTP requests:

```ts
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";

const server = setupServer(
http.get("https://api.coingecko.com/api/v3/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

```ts
// factories/proposal.ts
export function createProposal(overrides?: Partial<Proposal>): Proposal {
return {
id: randomId(),
dao: "uni",
title: "Test Proposal",
status: "active",
createdAt: new Date(),
...overrides,
};
}

// In the test
const proposal = createProposal({ status: "executed" });
await db.insert(proposals).values(proposal);
```

### 2. Database Seeding

```ts
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 };
}
```
41 changes: 41 additions & 0 deletions .github/workflows/api-tests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: API Tests

on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- "apps/api/**"
- ".github/workflows/api-tests.yaml"

jobs:
test:
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.10.0

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Run API tests
run: pnpm test

- name: Upload coverage
uses: codecov/codecov-action@v4
if: always()
with:
files: ./apps/api/coverage/coverage-final.json
flags: api
fail_ci_if_error: false
14 changes: 14 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@
},
"outFiles": ["${workspaceFolder}/**/*.js", "!**/node_modules/**"],
"cwd": "${workspaceFolder}/apps/api"
},
{
"type": "node",
"request": "launch",
"name": "api test",
"skipFiles": ["<node_internals>/**"],
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "test:watch"],
"env": {
"TS_NODE_PROJECT": "${workspaceFolder}/tsconfig.json",
"ENV_FILE": "${workspaceFolder}/apps/api/.env"
},
"outFiles": ["${workspaceFolder}/**/*.js", "!**/node_modules/**"],
"cwd": "${workspaceFolder}/apps/api"
}
]
}
Loading
Loading