Testing Patterns Skill
This skill provides guidance for writing comprehensive tests in Splits Network.
Purpose
Help developers write maintainable, reliable tests following Splits Network standards:
- •Unit Tests: Repository and service layer testing
- •Integration Tests: API endpoint testing
- •Mocking: Supabase and external service mocks
- •Test Data: Fixture management
- •Coverage: What and how to test
When to Use This Skill
Use this skill when:
- •Writing unit tests for repositories or services
- •Writing integration tests for API endpoints
- •Mocking Supabase or external APIs
- •Creating test fixtures
- •Debugging test failures
Core Testing Principles
1. Repository Unit Tests
Test repository methods with mocked Supabase client:
import { describe, it, expect, vi, beforeEach } from "vitest";
import { JobRepository } from "../repository";
describe("JobRepository", () => {
let mockSupabase: any;
let repository: JobRepository;
beforeEach(() => {
// Mock Supabase client
mockSupabase = {
from: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
in: vi.fn().mockReturnThis(),
single: vi.fn(),
};
repository = new JobRepository(mockSupabase);
});
it("should fetch job by ID", async () => {
const mockJob = { id: "123", title: "Engineer" };
mockSupabase.single.mockResolvedValue({ data: mockJob, error: null });
const result = await repository.getById("123");
expect(result).toEqual(mockJob);
expect(mockSupabase.from).toHaveBeenCalledWith("jobs");
expect(mockSupabase.eq).toHaveBeenCalledWith("id", "123");
});
it("should throw error when job not found", async () => {
mockSupabase.single.mockResolvedValue({
data: null,
error: { code: "PGRST116" },
});
await expect(repository.getById("999")).rejects.toThrow(
"Job not found",
);
});
});
See examples/repository-test.spec.ts.
2. Service Layer Tests
Test business logic with mocked repository:
import { describe, it, expect, vi } from "vitest";
import { JobServiceV2 } from "../service";
describe("JobServiceV2", () => {
let mockRepository: any;
let mockEventPublisher: any;
let service: JobServiceV2;
beforeEach(() => {
mockRepository = {
create: vi.fn(),
getById: vi.fn(),
update: vi.fn(),
};
mockEventPublisher = {
publish: vi.fn(),
};
service = new JobServiceV2(mockRepository, mockEventPublisher);
});
it("should create job and publish event", async () => {
const jobData = { title: "Engineer", company_id: "123" };
const createdJob = { id: "456", ...jobData };
mockRepository.create.mockResolvedValue(createdJob);
const result = await service.create("clerk_123", jobData);
expect(result).toEqual(createdJob);
expect(mockRepository.create).toHaveBeenCalledWith(
"clerk_123",
jobData,
);
expect(mockEventPublisher.publish).toHaveBeenCalledWith(
"job.created",
expect.objectContaining({ jobId: "456" }),
);
});
it("should validate job data before creation", async () => {
const invalidData = { title: "" }; // Missing required fields
await expect(service.create("clerk_123", invalidData)).rejects.toThrow(
"Validation error",
);
expect(mockRepository.create).not.toHaveBeenCalled();
});
});
See examples/service-test.spec.ts.
3. API Integration Tests
Test complete request/response cycle:
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { buildServer } from "../index";
import { FastifyInstance } from "fastify";
describe("Jobs API", () => {
let app: FastifyInstance;
beforeAll(async () => {
app = await buildServer();
await app.ready();
});
afterAll(async () => {
await app.close();
});
it("should list jobs with pagination", async () => {
const response = await app.inject({
method: "GET",
url: "/api/v2/jobs?page=1&limit=25",
headers: {
"x-clerk-user-id": "test_user_123",
},
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body).toHaveProperty("data");
expect(body).toHaveProperty("pagination");
expect(Array.isArray(body.data)).toBe(true);
});
it("should return 404 for non-existent job", async () => {
const response = await app.inject({
method: "GET",
url: "/api/v2/jobs/non-existent-id",
headers: {
"x-clerk-user-id": "test_user_123",
},
});
expect(response.statusCode).toBe(404);
const body = JSON.parse(response.body);
expect(body).toHaveProperty("error");
});
it("should create job with valid data", async () => {
const jobData = {
title: "Senior Engineer",
company_id: "company_123",
location: "San Francisco",
};
const response = await app.inject({
method: "POST",
url: "/api/v2/jobs",
headers: {
"x-clerk-user-id": "test_user_123",
},
payload: jobData,
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.data).toMatchObject(jobData);
});
});
See examples/api-integration-test.spec.ts.
4. Test Fixtures
Create reusable test data:
// tests/fixtures/jobs.ts
export const mockJobs = {
engineer: {
id: "job_123",
title: "Senior Software Engineer",
company_id: "company_123",
status: "active",
location: "San Francisco, CA",
created_at: "2026-01-01T00:00:00Z",
},
designer: {
id: "job_456",
title: "Product Designer",
company_id: "company_123",
status: "active",
location: "Remote",
created_at: "2026-01-02T00:00:00Z",
},
};
export const mockApplications = {
pending: {
id: "app_123",
candidate_id: "candidate_123",
job_id: "job_123",
stage: "screen",
created_at: "2026-01-01T00:00:00Z",
},
};
See examples/test-fixtures.ts.
5. Mocking External Services
Mock Clerk, Stripe, Resend, etc.:
// Mock Clerk auth
vi.mock("@clerk/nextjs", () => ({
auth: () => ({
userId: "test_user_123",
sessionId: "test_session",
}),
currentUser: () => ({
id: "test_user_123",
emailAddresses: [{ emailAddress: "test@example.com" }],
}),
}));
// Mock Stripe
vi.mock("stripe", () => ({
default: vi.fn().mockImplementation(() => ({
subscriptions: {
create: vi.fn().mockResolvedValue({ id: "sub_123" }),
retrieve: vi.fn().mockResolvedValue({ status: "active" }),
},
})),
}));
// Mock Resend
vi.mock("resend", () => ({
Resend: vi.fn().mockImplementation(() => ({
emails: {
send: vi.fn().mockResolvedValue({ id: "email_123" }),
},
})),
}));
See examples/external-service-mocks.ts.
Test Organization
File Structure
services/ats-service/
├── src/
│ └── v2/
│ └── jobs/
│ ├── repository.ts
│ ├── service.ts
│ └── __tests__/
│ ├── repository.spec.ts
│ ├── service.spec.ts
│ └── integration.spec.ts
└── tests/
├── fixtures/
│ ├── jobs.ts
│ └── applications.ts
└── helpers/
└── test-utils.ts
Naming Conventions
- •Unit tests:
repository.spec.ts,service.spec.ts - •Integration tests:
integration.spec.ts,api.spec.ts - •Test fixtures:
fixtures/*.ts - •Test helpers:
helpers/*.ts
See references/test-organization.md.
Coverage Guidelines
What to Test
✅ Always Test:
- •Repository methods (all CRUD operations)
- •Service business logic
- •Validation rules
- •Error handling
- •Access control logic
- •Event publishing
⚠️ Consider Testing:
- •Complex utility functions
- •Data transformations
- •State machines
❌ Don't Test:
- •Third-party library internals
- •Simple getters/setters
- •Type definitions
Coverage Targets
- •Repository Layer: 90%+ coverage
- •Service Layer: 85%+ coverage
- •API Routes: 80%+ coverage
- •Overall: 80%+ coverage
See references/coverage-guidelines.md.
Test Utilities
Custom Matchers
// tests/helpers/matchers.ts
expect.extend({
toBeUUID(received: string) {
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const pass = uuidRegex.test(received);
return {
pass,
message: () => `expected ${received} to be a valid UUID`,
};
},
});
// Usage
expect(job.id).toBeUUID();
Test Helpers
// tests/helpers/test-utils.ts
export function createMockSupabase() {
return {
from: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
single: vi.fn(),
};
}
export function createMockAccessContext(role: string) {
return {
userId: "user_123",
role,
accessibleCompanyIds: ["company_123"],
isCompanyUser: role !== "recruiter",
};
}
See examples/test-utilities.ts.
Running Tests
# Run all tests pnpm test # Run tests in watch mode pnpm test:watch # Run tests with coverage pnpm test:coverage # Run specific test file pnpm test repository.spec.ts # Run tests matching pattern pnpm test --grep "JobRepository"
See references/running-tests.md.
Common Testing Patterns
Testing Async Functions
it("should handle async operations", async () => {
const result = await service.create(data);
expect(result).toBeDefined();
});
Testing Error Cases
it("should throw error for invalid data", async () => {
await expect(service.create(invalidData)).rejects.toThrow(
"Validation error",
);
});
Testing Event Publishing
it("should publish event after creation", async () => {
await service.create(data);
expect(mockEventPublisher.publish).toHaveBeenCalledWith(
"job.created",
expect.objectContaining({ jobId: expect.any(String) }),
);
});
See examples/common-test-patterns.ts.
Anti-Patterns to Avoid
❌ Testing Implementation Details
// WRONG - Tests internal implementation
it("should call private method", () => {
expect(service["privateMethod"]).toHaveBeenCalled();
});
// CORRECT - Tests public behavior
it("should return processed data", () => {
expect(service.process(data)).toEqual(expected);
});
❌ Brittle Tests
// WRONG - Too specific, breaks easily
expect(result).toEqual({
id: "123",
created_at: "2026-01-13T10:00:00.000Z", // Exact timestamp
title: "Engineer",
});
// CORRECT - Flexible assertions
expect(result).toMatchObject({
id: expect.any(String),
created_at: expect.any(String),
title: "Engineer",
});
❌ Not Cleaning Up
// WRONG - State leaks between tests
let sharedState = {};
it("test 1", () => {
sharedState.value = 1;
});
it("test 2", () => {
// Fails if test 1 didn't run
expect(sharedState.value).toBe(1);
});
// CORRECT - Clean state for each test
beforeEach(() => {
sharedState = {};
});
References
- •Repository Test Example
- •Service Test Example
- •API Integration Test
- •Test Fixtures
- •Test Utilities
- •Coverage Guidelines
- •Running Tests Guide
Related Skills
- •
database-patterns- Repository layer patterns - •
api-specifications- API endpoint patterns - •
error-handling- Error handling to test