Skills
testing-patterns
testing-patterns
npx @loomcraft/cli add skill testing-patternsFrontmatter
Name
testing-patterns
Description
Vitest testing strategy with role-based patterns, seed data factories, and CI/CD integration. Use when writing tests, creating test data, or setting up continuous integration pipelines.
Content
# Testing Patterns
## Critical Rules
- **Test every role** — PUBLIC, USER, ADMIN for every feature.
- **Test services, not routes** — service layer is the source of truth.
- **Use seed factories** — deterministic, reusable test data.
- **Clean up after each test** — truncate tables, no test pollution.
- **CI runs tests + build** — every PR must pass before merge.
- **No mocking the database** — test against a real PostgreSQL instance.
## Role-Based Test Matrix
Every feature must be tested from 3 perspectives:
| Role | What to test |
|------|-------------|
| **PUBLIC** | Unauthenticated access — redirects, public pages, API 401s |
| **USER** | Standard user — CRUD own resources, forbidden on others |
| **ADMIN** | Admin user — manage all resources, admin-only features |
```ts
describe("deletePost", () => {
it("should return 401 for public (unauthenticated)", async () => {
const result = await deletePost(null, postId);
expect(result.error).toBe("Unauthorized");
});
it("should allow USER to delete own post", async () => {
const result = await deletePost(userSession, ownPostId);
expect(result.success).toBe(true);
});
it("should forbid USER from deleting another's post", async () => {
const result = await deletePost(userSession, otherPostId);
expect(result.error).toBe("Forbidden");
});
it("should allow ADMIN to delete any post", async () => {
const result = await deletePost(adminSession, otherPostId);
expect(result.success).toBe(true);
});
});
```
## Vitest Configuration
```ts
// vitest.config.ts
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
test: {
globals: true,
environment: "node",
setupFiles: ["./tests/setup.ts"],
include: ["**/*.test.ts", "**/*.test.tsx"],
coverage: {
provider: "v8",
reporter: ["text", "lcov"],
exclude: ["node_modules", "tests/setup.ts"],
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});
```
## Seed Data
### Seed Users
```ts
// tests/seed/users.ts
export const SEED_USERS = {
public: null, // no session
user: {
id: "user-1",
email: "user@test.com",
name: "Test User",
role: "USER" as const,
},
admin: {
id: "admin-1",
email: "admin@test.com",
name: "Test Admin",
role: "ADMIN" as const,
},
} as const;
```
### Seed Data Factory
```ts
// tests/seed/factories.ts
import { db } from "@/lib/db";
import { users, posts } from "@/schema";
export async function seedUser(overrides?: Partial<NewUser>) {
const [user] = await db.insert(users).values({
name: "Test User",
email: `test-${Date.now()}@test.com`,
role: "USER",
...overrides,
}).returning();
return user;
}
export async function seedPost(authorId: string, overrides?: Partial<NewPost>) {
const [post] = await db.insert(posts).values({
title: "Test Post",
content: "Test content",
authorId,
published: true,
...overrides,
}).returning();
return post;
}
```
## Test Setup
```ts
// tests/setup.ts
import { beforeAll, afterAll, afterEach } from "vitest";
import { db } from "@/lib/db";
import { migrate } from "drizzle-orm/node-postgres/migrator";
beforeAll(async () => {
await migrate(db, { migrationsFolder: "./drizzle" });
});
afterEach(async () => {
// Clean up test data — truncate in reverse FK order
await db.execute(sql`TRUNCATE posts, users CASCADE`);
});
```
## Service Layer Testing
```ts
// src/services/__tests__/user.service.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { listUsers, deleteUser } from "@/services/user.service";
import { SEED_USERS } from "tests/seed/users";
import { seedUser } from "tests/seed/factories";
describe("user.service", () => {
let testUser: User;
beforeEach(async () => {
testUser = await seedUser();
});
describe("listUsers", () => {
it("should allow ADMIN to list all users", async () => {
const result = await listUsers(SEED_USERS.admin);
expect(result).toHaveLength(1);
});
it("should forbid USER from listing users", async () => {
await expect(listUsers(SEED_USERS.user)).rejects.toThrow("Forbidden");
});
});
});
```
## CI/CD Integration
```yaml
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: test
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports: ["5432:5432"]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: "pnpm" }
- run: pnpm install --frozen-lockfile
- run: pnpm test
- run: pnpm build
```
## Do
- Test every feature from all three role perspectives: PUBLIC, USER, and ADMIN.
- Use seed factories to create deterministic, isolated test data for each test.
- Truncate tables in reverse foreign-key order in `afterEach` to prevent test pollution.
- Test the service layer directly; it is the single source of truth for business logic.
- Run tests against a real PostgreSQL instance to catch real query and constraint issues.
- Keep each test case focused on a single behavior; one assertion per logical concern.
- Use `describe` blocks to group tests by feature or method, and nested `describe` for sub-scenarios.
- Run the full test suite in CI on every push and pull request before allowing merge.
## Don't
- Don't mock the database; real queries catch constraint violations and type mismatches that mocks hide.
- Don't share mutable state between tests; always set up fresh data in `beforeEach`.
- Don't test implementation details like internal function calls; test observable behavior and outputs.
- Don't rely on test execution order; each test must be independent and idempotent.
- Don't skip the ADMIN or PUBLIC role tests because "they probably work"; permission bugs are critical.
- Don't hardcode IDs or timestamps; use factories that generate unique values.
- Don't write tests that pass when the feature is broken (tautological assertions like `expect(true).toBe(true)`).
- Don't leave `console.log` debugging in committed test files; use proper assertions.
## Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
| **Database Mocking** | Mocking `db.query` hides real SQL errors, constraint violations, and type mismatches. | Use a real PostgreSQL test database with proper seed data and truncation. |
| **Shared Mutable Fixtures** | One test modifies a shared object, causing the next test to fail intermittently. | Create fresh data in `beforeEach` using seed factories; truncate in `afterEach`. |
| **Single-Role Testing** | Only testing the happy path as USER; missing that PUBLIC can access protected resources. | Use the role-based test matrix: always test PUBLIC, USER, and ADMIN for each feature. |
| **Testing Routes Instead of Services** | Writing integration tests against HTTP routes, which are slow and couple tests to transport details. | Test the service layer directly; only add a thin integration test for route wiring. |
| **Snapshot Overuse** | Using `toMatchSnapshot()` for dynamic data, causing constant snapshot updates. | Use explicit assertions on specific fields; reserve snapshots for stable UI output. |
| **Ignoring CI Failures** | Merging PRs with failing tests by re-running CI until it passes. | Fix flaky tests immediately; treat CI red as a blocking issue. |
| **God Test Files** | Putting hundreds of tests in one file, making it slow to run and hard to navigate. | Split tests by feature or service into separate files under `__tests__/`. |Files
No additional files