api-testing
Write and maintain Playwright API and integration tests for this codebase using ApiClient, TestContext, TestDataManager, authentication helpers, and fluent request/response assertions.
SKILL.md.agents/skills/api-testing/SKILL.md
GitHubAPI Testing Skill
When writing API tests for this codebase, follow the established patterns using the ApiClient helper and supertest-style assertions.
Overview
- Framework: Playwright API tests (
tests/api/*.api.spec.ts) - Helper:
ApiClient- fluent, type-safe API client with chainable assertions - Test Context:
TestContext- centralized resource management with automatic cleanup - Data Manager:
TestDataManager- direct Supabase operations for test data
Directory Structure
tests/
├── api/ # API route tests (.api.spec.ts)
├── integration/ # Integration tests (.integration.spec.ts)
├── unit/api/ # API-related unit tests (.unit.spec.ts)
├── helpers/
│ ├── ApiClient.ts # Fluent API testing client
│ ├── TestContext.ts # Centralized resource management
│ └── TestDataManager.ts # Direct database operations
ApiClient: Basic Usage
Simple Request
import { test, expect } from '@tests/e2e/fixtures';
import { ApiClient } from '@tests/helpers/ApiClient';
test.describe('API: Health Check', () => {
test('should return 200 OK with status', async ({ request }) => {
const api = new ApiClient(request);
const response = await api.get('/api/health');
response.expectStatus(200);
response.expectData({ status: 'ok' });
});
});
Fluent Assertions
const response = await api.post('/api/upscale', payload);
// Chain multiple expectations
response
.expectStatus(200)
.expectSuccess()
.expectData({ url: expect.any(String) })
.expectHeader('content-type', /json/);
// Check error codes
response.expectErrorCode('INSUFFICIENT_CREDITS');
// Async data checks
await response.expectData(async data => {
expect(data.url).toMatch(/^https:\/\//);
});
All HTTP Methods
await api.get('/api/users');
await api.post('/api/users', { name: 'John' });
await api.put('/api/users/123', { name: 'Jane' });
await api.patch('/api/users/123', { name: 'Jane' });
await api.delete('/api/users/123');
Authentication Patterns
Unauthenticated Request
test('should reject access without authentication', async ({ request }) => {
const api = new ApiClient(request);
const response = await api.post('/api/upscale', testData);
response.expectStatus(401);
});
Authenticated Request
test('should allow access with valid authentication', async ({ request }) => {
const user = await ctx.createUser();
const api = new ApiClient(request).withAuth(user.token);
const response = await api.post('/api/upscale', testData);
expect([401, 403]).not.toContain(response.status);
});
Reusing Authenticated Client
let api: ApiClient;
test.beforeAll(async () => {
const user = await ctx.createUser();
api = new ApiClient(request).withAuth(user.token);
});
test('should work with authenticated request', async () => {
const response = await api.get('/api/user/profile');
response.expectStatus(200);
});
TestContext for Resource Management
Basic Setup
import { TestContext } from '@tests/helpers/TestContext';
let ctx: TestContext;
test.beforeAll(async () => {
ctx = new TestContext();
});
test.afterAll(async () => {
await ctx.cleanup();
});
User Factory Patterns
// Simple user
const user = await ctx.createUser();
// Fluent builder API
const user = await ctx
.createUser()
.withSubscription('active', 'pro')
.withCredits(500)
.withEmail('test@example.com')
.build();
// Preset configurations
const proUser = await ctx.userFactory.proUser(500);
const businessUser = await ctx.userFactory.businessUser(1000);
const trialingUser = await ctx.userFactory.trialingUser('business');
// Multiple users
const users = await ctx.userFactory.createMany('pro', 5);
Direct Supabase Access
// TestContext provides admin Supabase client
const { data: profile } = await ctx.supabase.from('profiles').select('*').eq('id', userId).single();
TestDataManager for Database Operations
import { TestDataManager } from '@tests/helpers/TestDataManager';
const dataManager = new TestDataManager();
// Create users
const user = await dataManager.createTestUser();
const userWithPass = await dataManager.createTestUser({ email, password });
// Manage subscriptions
await dataManager.setSubscriptionStatus(userId, 'active', 'pro');
// Credit management
await dataManager.addCredits(userId, 100, 'bonus');
await dataManager.deductCredits(userId, 50, 'processing');
// Cleanup
await dataManager.cleanupUser(userId);
await dataManager.cleanupAllUsers();
Common API Test Patterns
Rate Limiting Tests
test('should apply rate limiting to protected routes', async ({ request }) => {
const user = await ctx.createUser();
const api = new ApiClient(request).withAuth(user.token);
const responses = [];
for (let i = 0; i < 15; i++) {
const response = await api.post('/api/upscale', testData);
responses.push(response);
}
const rateLimitedCount = responses.filter(r => r.status === 429).length;
if (rateLimitedCount > 0) {
expect(responses.find(r => r.status === 429)?.raw.headers()['retry-after']).toBeDefined();
}
});
Validation Tests
test('should reject invalid input', async ({ request }) => {
const user = await ctx.createUser();
const api = new ApiClient(request).withAuth(user.token);
const response = await api.post('/api/upscale', {
imageData: 'invalid',
mimeType: 'not-a-mime-type',
});
response.expectStatus(400);
response.expectErrorCode('VALIDATION_ERROR');
});
Error Code Tests
test('should return insufficient credits error', async ({ request }) => {
const user = await ctx.createUser({ credits: 0 });
const api = new ApiClient(request).withAuth(user.token);
const response = await api.post('/api/upscale', testData);
response.expectStatus(402);
response.expectErrorCode('INSUFFICIENT_CREDITS');
});
Integration Workflow Tests
test.describe('Authentication Flow Integration', () => {
test('should handle login with valid credentials', async ({ request }) => {
const api = new ApiClient(request);
const testUser = await ctx.createUser();
const loginResponse = await api.post('/api/auth/login', {
email: testUser.email,
password: 'test-password-123',
});
expect(loginResponse.ok).toBeTruthy();
const { session } = await loginResponse.json();
// Test token usage
const authenticatedApi = api.withAuth(session.access_token);
const protectedResponse = await authenticatedApi.get('/api/protected/example');
expect(protectedResponse.ok).toBeTruthy();
});
});
Unit Testing API Handlers
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
describe('Stripe Webhook Handler', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
test('should handle invoice.payment_succeeded', async () => {
// Mock dependencies
vi.mock('@server/stripe', () => ({
stripe: {
webhooks: {
constructEventAsync: vi.fn(),
},
},
}));
// Use dynamic import for fresh module
const { handleInvoicePaymentSucceeded } = await import('./handler');
const result = await handleInvoicePaymentSucceeded(mockEvent);
expect(result.success).toBeTruthy();
});
});
Common Pitfalls to Avoid
1. Hardcoded Configuration Values
Bad: Tests fail when config changes
const response = await api.post('/api/subscription', {
priceId: 'price_123abc', // Hardcoded - will break when config changes
});
Good: Import actual config values
import { stripeConfig } from '@server/config/stripe';
const response = await api.post('/api/subscription', {
priceId: stripeConfig.prices.pro.monthly,
});
2. Outdated Payload Schemas
APIs evolve - keep test payloads in sync with current validation:
// Old format
const payload = { quality: 'high', scale: 2 };
// New format (check actual API schema!)
const payload = { qualityTier: 'premium', scale: 2 };
3. Missing Cleanup
Always clean up test resources to avoid flaky tests:
let ctx: TestContext;
test.beforeAll(async () => {
ctx = new TestContext();
});
test.afterAll(async () => {
await ctx.cleanup(); // Don't forget this!
});
4. Not Testing Error Paths
Don't only test happy paths - test error scenarios:
test('should handle errors properly', async ({ request }) => {
const api = new ApiClient(request);
const response = await api.post('/api/endpoint', invalidData);
response.expectStatus(400);
response.expectErrorCode('VALIDATION_ERROR');
});
Best Practices
Test Naming
- Use descriptive names:
"should return 200 OK with status" - Group by feature:
test.describe('API: Health Check') - Cover error scenarios:
"should reject invalid credentials"
Test Structure
test.describe('Feature Name', () => {
let ctx: TestContext;
test.beforeAll(async () => {
ctx = new TestContext();
});
test.afterAll(async () => {
await ctx.cleanup();
});
test.describe('Sub-feature', () => {
test('specific behavior', async ({ request }) => {
const api = new ApiClient(request);
// Test implementation
});
});
});
Code Quality Standards
- 80%+ coverage target
- Mock all external dependencies in unit tests
- Comprehensive error testing
- Test both success and failure paths
Testing Commands
# Run all API tests
yarn test:api
# Run specific test file
yarn test tests/api/upscale.api.spec.ts
# Run integration tests
yarn test:integration
# Run with coverage
yarn test:coverage
# Full verification suite
yarn verify
Before Finishing
- Write tests for your changes following the patterns above
- Run tests on affected areas:
yarn test:apioryarn test:integration - Run verify:
yarn verify(required before completing any task)
Key Files to Reference
tests/helpers/ApiClient.ts- Fluent API testing clienttests/helpers/TestContext.ts- Centralized test resource managementtests/helpers/TestDataManager.ts- Direct database operationsplaywright.config.ts- Playwright configuration (api, integration projects)