- Next.js 14+ with App Router and TypeScript - Tailwind CSS and ShadCN UI styling - Zustand state management - Dexie.js for IndexedDB (local-first data) - Auth.js v5 for authentication - BMAD framework integration Co-Authored-By: Claude <noreply@anthropic.com>
844 lines
23 KiB
Markdown
844 lines
23 KiB
Markdown
# API Testing Patterns
|
|
|
|
## Principle
|
|
|
|
Test APIs and backend services directly without browser overhead. Use Playwright's `request` context for HTTP operations, `apiRequest` utility for enhanced features, and `recurse` for async operations. Pure API tests run faster, are more stable, and provide better coverage for service-layer logic.
|
|
|
|
## Rationale
|
|
|
|
Many teams over-rely on E2E/browser tests when API tests would be more appropriate:
|
|
|
|
- **Slower feedback**: Browser tests take seconds, API tests take milliseconds
|
|
- **More brittle**: UI changes break tests even when API works correctly
|
|
- **Wrong abstraction**: Testing business logic through UI layers adds noise
|
|
- **Resource heavy**: Browsers consume memory and CPU
|
|
|
|
API-first testing provides:
|
|
|
|
- **Fast execution**: No browser startup, no rendering, no JavaScript execution
|
|
- **Direct validation**: Test exactly what the service returns
|
|
- **Better isolation**: Test service logic independent of UI
|
|
- **Easier debugging**: Clear request/response without DOM noise
|
|
- **Contract validation**: Verify API contracts explicitly
|
|
|
|
## When to Use API Tests vs E2E Tests
|
|
|
|
| Scenario | API Test | E2E Test |
|
|
|----------|----------|----------|
|
|
| CRUD operations | ✅ Primary | ❌ Overkill |
|
|
| Business logic validation | ✅ Primary | ❌ Overkill |
|
|
| Error handling (4xx, 5xx) | ✅ Primary | ⚠️ Supplement |
|
|
| Authentication flows | ✅ Primary | ⚠️ Supplement |
|
|
| Data transformation | ✅ Primary | ❌ Overkill |
|
|
| User journeys | ❌ Can't test | ✅ Primary |
|
|
| Visual regression | ❌ Can't test | ✅ Primary |
|
|
| Cross-browser issues | ❌ Can't test | ✅ Primary |
|
|
|
|
**Rule of thumb**: If you're testing what the server returns (not how it looks), use API tests.
|
|
|
|
## Pattern Examples
|
|
|
|
### Example 1: Pure API Test (No Browser)
|
|
|
|
**Context**: Test REST API endpoints directly without any browser context.
|
|
|
|
**Implementation**:
|
|
|
|
```typescript
|
|
// tests/api/users.spec.ts
|
|
import { test, expect } from '@playwright/test';
|
|
|
|
// No page, no browser - just API
|
|
test.describe('Users API', () => {
|
|
test('should create user', async ({ request }) => {
|
|
const response = await request.post('/api/users', {
|
|
data: {
|
|
name: 'John Doe',
|
|
email: 'john@example.com',
|
|
role: 'user',
|
|
},
|
|
});
|
|
|
|
expect(response.status()).toBe(201);
|
|
|
|
const user = await response.json();
|
|
expect(user.id).toBeDefined();
|
|
expect(user.name).toBe('John Doe');
|
|
expect(user.email).toBe('john@example.com');
|
|
});
|
|
|
|
test('should get user by ID', async ({ request }) => {
|
|
// Create user first
|
|
const createResponse = await request.post('/api/users', {
|
|
data: { name: 'Jane Doe', email: 'jane@example.com' },
|
|
});
|
|
const { id } = await createResponse.json();
|
|
|
|
// Get user
|
|
const getResponse = await request.get(`/api/users/${id}`);
|
|
expect(getResponse.status()).toBe(200);
|
|
|
|
const user = await getResponse.json();
|
|
expect(user.id).toBe(id);
|
|
expect(user.name).toBe('Jane Doe');
|
|
});
|
|
|
|
test('should return 404 for non-existent user', async ({ request }) => {
|
|
const response = await request.get('/api/users/non-existent-id');
|
|
expect(response.status()).toBe(404);
|
|
|
|
const error = await response.json();
|
|
expect(error.code).toBe('USER_NOT_FOUND');
|
|
});
|
|
|
|
test('should validate required fields', async ({ request }) => {
|
|
const response = await request.post('/api/users', {
|
|
data: { name: 'Missing Email' }, // email is required
|
|
});
|
|
|
|
expect(response.status()).toBe(400);
|
|
|
|
const error = await response.json();
|
|
expect(error.code).toBe('VALIDATION_ERROR');
|
|
expect(error.details).toContainEqual(
|
|
expect.objectContaining({ field: 'email', message: expect.any(String) })
|
|
);
|
|
});
|
|
});
|
|
```
|
|
|
|
**Key Points**:
|
|
|
|
- No `page` fixture needed - only `request`
|
|
- Tests run without browser overhead
|
|
- Direct HTTP assertions
|
|
- Clear error handling tests
|
|
|
|
### Example 2: API Test with apiRequest Utility
|
|
|
|
**Context**: Use enhanced apiRequest for schema validation, retry, and type safety.
|
|
|
|
**Implementation**:
|
|
|
|
```typescript
|
|
// tests/api/orders.spec.ts
|
|
import { test, expect } from '@seontechnologies/playwright-utils/api-request/fixtures';
|
|
import { z } from 'zod';
|
|
|
|
// Define schema for type safety and validation
|
|
const OrderSchema = z.object({
|
|
id: z.string().uuid(),
|
|
userId: z.string(),
|
|
items: z.array(
|
|
z.object({
|
|
productId: z.string(),
|
|
quantity: z.number().positive(),
|
|
price: z.number().positive(),
|
|
})
|
|
),
|
|
total: z.number().positive(),
|
|
status: z.enum(['pending', 'processing', 'shipped', 'delivered']),
|
|
createdAt: z.string().datetime(),
|
|
});
|
|
|
|
type Order = z.infer<typeof OrderSchema>;
|
|
|
|
test.describe('Orders API', () => {
|
|
test('should create order with schema validation', async ({ apiRequest }) => {
|
|
const { status, body } = await apiRequest<Order>({
|
|
method: 'POST',
|
|
path: '/api/orders',
|
|
body: {
|
|
userId: 'user-123',
|
|
items: [
|
|
{ productId: 'prod-1', quantity: 2, price: 29.99 },
|
|
{ productId: 'prod-2', quantity: 1, price: 49.99 },
|
|
],
|
|
},
|
|
validateSchema: OrderSchema, // Validates response matches schema
|
|
});
|
|
|
|
expect(status).toBe(201);
|
|
expect(body.id).toBeDefined();
|
|
expect(body.status).toBe('pending');
|
|
expect(body.total).toBe(109.97); // 2*29.99 + 49.99
|
|
});
|
|
|
|
test('should handle server errors with retry', async ({ apiRequest }) => {
|
|
// apiRequest retries 5xx errors by default
|
|
const { status, body } = await apiRequest({
|
|
method: 'GET',
|
|
path: '/api/orders/order-123',
|
|
retryConfig: {
|
|
maxRetries: 3,
|
|
retryDelay: 1000,
|
|
},
|
|
});
|
|
|
|
expect(status).toBe(200);
|
|
});
|
|
|
|
test('should list orders with pagination', async ({ apiRequest }) => {
|
|
const { status, body } = await apiRequest<{ orders: Order[]; total: number; page: number }>({
|
|
method: 'GET',
|
|
path: '/api/orders',
|
|
params: { page: 1, limit: 10, status: 'pending' },
|
|
});
|
|
|
|
expect(status).toBe(200);
|
|
expect(body.orders).toHaveLength(10);
|
|
expect(body.total).toBeGreaterThan(10);
|
|
expect(body.page).toBe(1);
|
|
});
|
|
});
|
|
```
|
|
|
|
**Key Points**:
|
|
|
|
- Zod schema for runtime validation AND TypeScript types
|
|
- `validateSchema` throws if response doesn't match
|
|
- Built-in retry for transient failures
|
|
- Type-safe `body` access
|
|
|
|
### Example 3: Microservice-to-Microservice Testing
|
|
|
|
**Context**: Test service interactions without browser - validate API contracts between services.
|
|
|
|
**Implementation**:
|
|
|
|
```typescript
|
|
// tests/api/service-integration.spec.ts
|
|
import { test, expect } from '@seontechnologies/playwright-utils/fixtures';
|
|
|
|
test.describe('Service Integration', () => {
|
|
const USER_SERVICE_URL = process.env.USER_SERVICE_URL || 'http://localhost:3001';
|
|
const ORDER_SERVICE_URL = process.env.ORDER_SERVICE_URL || 'http://localhost:3002';
|
|
const INVENTORY_SERVICE_URL = process.env.INVENTORY_SERVICE_URL || 'http://localhost:3003';
|
|
|
|
test('order service should validate user exists', async ({ apiRequest }) => {
|
|
// Create user in user-service
|
|
const { body: user } = await apiRequest({
|
|
method: 'POST',
|
|
path: '/api/users',
|
|
baseUrl: USER_SERVICE_URL,
|
|
body: { name: 'Test User', email: 'test@example.com' },
|
|
});
|
|
|
|
// Create order in order-service (should validate user via user-service)
|
|
const { status, body: order } = await apiRequest({
|
|
method: 'POST',
|
|
path: '/api/orders',
|
|
baseUrl: ORDER_SERVICE_URL,
|
|
body: {
|
|
userId: user.id,
|
|
items: [{ productId: 'prod-1', quantity: 1 }],
|
|
},
|
|
});
|
|
|
|
expect(status).toBe(201);
|
|
expect(order.userId).toBe(user.id);
|
|
});
|
|
|
|
test('order service should reject invalid user', async ({ apiRequest }) => {
|
|
const { status, body } = await apiRequest({
|
|
method: 'POST',
|
|
path: '/api/orders',
|
|
baseUrl: ORDER_SERVICE_URL,
|
|
body: {
|
|
userId: 'non-existent-user',
|
|
items: [{ productId: 'prod-1', quantity: 1 }],
|
|
},
|
|
});
|
|
|
|
expect(status).toBe(400);
|
|
expect(body.code).toBe('INVALID_USER');
|
|
});
|
|
|
|
test('order should decrease inventory', async ({ apiRequest, recurse }) => {
|
|
// Get initial inventory
|
|
const { body: initialInventory } = await apiRequest({
|
|
method: 'GET',
|
|
path: '/api/inventory/prod-1',
|
|
baseUrl: INVENTORY_SERVICE_URL,
|
|
});
|
|
|
|
// Create order
|
|
await apiRequest({
|
|
method: 'POST',
|
|
path: '/api/orders',
|
|
baseUrl: ORDER_SERVICE_URL,
|
|
body: {
|
|
userId: 'user-123',
|
|
items: [{ productId: 'prod-1', quantity: 2 }],
|
|
},
|
|
});
|
|
|
|
// Poll for inventory update (eventual consistency)
|
|
const { body: updatedInventory } = await recurse(
|
|
() =>
|
|
apiRequest({
|
|
method: 'GET',
|
|
path: '/api/inventory/prod-1',
|
|
baseUrl: INVENTORY_SERVICE_URL,
|
|
}),
|
|
(response) => response.body.quantity === initialInventory.quantity - 2,
|
|
{ timeout: 10000, interval: 500 }
|
|
);
|
|
|
|
expect(updatedInventory.quantity).toBe(initialInventory.quantity - 2);
|
|
});
|
|
});
|
|
```
|
|
|
|
**Key Points**:
|
|
|
|
- Multiple service URLs for microservice testing
|
|
- Tests service-to-service communication
|
|
- Uses `recurse` for eventual consistency
|
|
- No browser needed for full integration testing
|
|
|
|
### Example 4: GraphQL API Testing
|
|
|
|
**Context**: Test GraphQL endpoints with queries and mutations.
|
|
|
|
**Implementation**:
|
|
|
|
```typescript
|
|
// tests/api/graphql.spec.ts
|
|
import { test, expect } from '@seontechnologies/playwright-utils/api-request/fixtures';
|
|
|
|
const GRAPHQL_ENDPOINT = '/graphql';
|
|
|
|
test.describe('GraphQL API', () => {
|
|
test('should query users', async ({ apiRequest }) => {
|
|
const query = `
|
|
query GetUsers($limit: Int) {
|
|
users(limit: $limit) {
|
|
id
|
|
name
|
|
email
|
|
role
|
|
}
|
|
}
|
|
`;
|
|
|
|
const { status, body } = await apiRequest({
|
|
method: 'POST',
|
|
path: GRAPHQL_ENDPOINT,
|
|
body: {
|
|
query,
|
|
variables: { limit: 10 },
|
|
},
|
|
});
|
|
|
|
expect(status).toBe(200);
|
|
expect(body.errors).toBeUndefined();
|
|
expect(body.data.users).toHaveLength(10);
|
|
expect(body.data.users[0]).toHaveProperty('id');
|
|
expect(body.data.users[0]).toHaveProperty('name');
|
|
});
|
|
|
|
test('should create user via mutation', async ({ apiRequest }) => {
|
|
const mutation = `
|
|
mutation CreateUser($input: CreateUserInput!) {
|
|
createUser(input: $input) {
|
|
id
|
|
name
|
|
email
|
|
}
|
|
}
|
|
`;
|
|
|
|
const { status, body } = await apiRequest({
|
|
method: 'POST',
|
|
path: GRAPHQL_ENDPOINT,
|
|
body: {
|
|
query: mutation,
|
|
variables: {
|
|
input: {
|
|
name: 'GraphQL User',
|
|
email: 'graphql@example.com',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(status).toBe(200);
|
|
expect(body.errors).toBeUndefined();
|
|
expect(body.data.createUser.id).toBeDefined();
|
|
expect(body.data.createUser.name).toBe('GraphQL User');
|
|
});
|
|
|
|
test('should handle GraphQL errors', async ({ apiRequest }) => {
|
|
const query = `
|
|
query GetUser($id: ID!) {
|
|
user(id: $id) {
|
|
id
|
|
name
|
|
}
|
|
}
|
|
`;
|
|
|
|
const { status, body } = await apiRequest({
|
|
method: 'POST',
|
|
path: GRAPHQL_ENDPOINT,
|
|
body: {
|
|
query,
|
|
variables: { id: 'non-existent' },
|
|
},
|
|
});
|
|
|
|
expect(status).toBe(200); // GraphQL returns 200 even for errors
|
|
expect(body.errors).toBeDefined();
|
|
expect(body.errors[0].message).toContain('not found');
|
|
expect(body.data.user).toBeNull();
|
|
});
|
|
|
|
test('should handle validation errors', async ({ apiRequest }) => {
|
|
const mutation = `
|
|
mutation CreateUser($input: CreateUserInput!) {
|
|
createUser(input: $input) {
|
|
id
|
|
}
|
|
}
|
|
`;
|
|
|
|
const { status, body } = await apiRequest({
|
|
method: 'POST',
|
|
path: GRAPHQL_ENDPOINT,
|
|
body: {
|
|
query: mutation,
|
|
variables: {
|
|
input: {
|
|
name: '', // Invalid: empty name
|
|
email: 'invalid-email', // Invalid: bad format
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(status).toBe(200);
|
|
expect(body.errors).toBeDefined();
|
|
expect(body.errors[0].extensions.code).toBe('BAD_USER_INPUT');
|
|
});
|
|
});
|
|
```
|
|
|
|
**Key Points**:
|
|
|
|
- GraphQL queries and mutations via POST
|
|
- Variables passed in request body
|
|
- GraphQL returns 200 even for errors (check `body.errors`)
|
|
- Test validation and business logic errors
|
|
|
|
### Example 5: Database Seeding and Cleanup via API
|
|
|
|
**Context**: Use API calls to set up and tear down test data without direct database access.
|
|
|
|
**Implementation**:
|
|
|
|
```typescript
|
|
// tests/api/with-data-setup.spec.ts
|
|
import { test, expect } from '@seontechnologies/playwright-utils/fixtures';
|
|
|
|
test.describe('Orders with Data Setup', () => {
|
|
let testUser: { id: string; email: string };
|
|
let testProducts: Array<{ id: string; name: string; price: number }>;
|
|
|
|
test.beforeAll(async ({ request }) => {
|
|
// Seed user via API
|
|
const userResponse = await request.post('/api/users', {
|
|
data: {
|
|
name: 'Test User',
|
|
email: `test-${Date.now()}@example.com`,
|
|
},
|
|
});
|
|
testUser = await userResponse.json();
|
|
|
|
// Seed products via API
|
|
testProducts = [];
|
|
for (const product of [
|
|
{ name: 'Widget A', price: 29.99 },
|
|
{ name: 'Widget B', price: 49.99 },
|
|
{ name: 'Widget C', price: 99.99 },
|
|
]) {
|
|
const productResponse = await request.post('/api/products', {
|
|
data: product,
|
|
});
|
|
testProducts.push(await productResponse.json());
|
|
}
|
|
});
|
|
|
|
test.afterAll(async ({ request }) => {
|
|
// Cleanup via API
|
|
if (testUser?.id) {
|
|
await request.delete(`/api/users/${testUser.id}`);
|
|
}
|
|
for (const product of testProducts) {
|
|
await request.delete(`/api/products/${product.id}`);
|
|
}
|
|
});
|
|
|
|
test('should create order with seeded data', async ({ apiRequest }) => {
|
|
const { status, body } = await apiRequest({
|
|
method: 'POST',
|
|
path: '/api/orders',
|
|
body: {
|
|
userId: testUser.id,
|
|
items: [
|
|
{ productId: testProducts[0].id, quantity: 2 },
|
|
{ productId: testProducts[1].id, quantity: 1 },
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(status).toBe(201);
|
|
expect(body.userId).toBe(testUser.id);
|
|
expect(body.items).toHaveLength(2);
|
|
expect(body.total).toBe(2 * 29.99 + 49.99);
|
|
});
|
|
|
|
test('should list user orders', async ({ apiRequest }) => {
|
|
// Create an order first
|
|
await apiRequest({
|
|
method: 'POST',
|
|
path: '/api/orders',
|
|
body: {
|
|
userId: testUser.id,
|
|
items: [{ productId: testProducts[2].id, quantity: 1 }],
|
|
},
|
|
});
|
|
|
|
// List orders for user
|
|
const { status, body } = await apiRequest({
|
|
method: 'GET',
|
|
path: '/api/orders',
|
|
params: { userId: testUser.id },
|
|
});
|
|
|
|
expect(status).toBe(200);
|
|
expect(body.orders.length).toBeGreaterThanOrEqual(1);
|
|
expect(body.orders.every((o: any) => o.userId === testUser.id)).toBe(true);
|
|
});
|
|
});
|
|
```
|
|
|
|
**Key Points**:
|
|
|
|
- `beforeAll`/`afterAll` for test data setup/cleanup
|
|
- API-based seeding (no direct DB access needed)
|
|
- Unique emails to prevent conflicts in parallel runs
|
|
- Cleanup after all tests complete
|
|
|
|
### Example 6: Background Job Testing with Recurse
|
|
|
|
**Context**: Test async operations like background jobs, webhooks, and eventual consistency.
|
|
|
|
**Implementation**:
|
|
|
|
```typescript
|
|
// tests/api/background-jobs.spec.ts
|
|
import { test, expect } from '@seontechnologies/playwright-utils/fixtures';
|
|
|
|
test.describe('Background Jobs', () => {
|
|
test('should process export job', async ({ apiRequest, recurse }) => {
|
|
// Trigger export job
|
|
const { body: job } = await apiRequest({
|
|
method: 'POST',
|
|
path: '/api/exports',
|
|
body: {
|
|
type: 'users',
|
|
format: 'csv',
|
|
filters: { createdAfter: '2024-01-01' },
|
|
},
|
|
});
|
|
|
|
expect(job.id).toBeDefined();
|
|
expect(job.status).toBe('pending');
|
|
|
|
// Poll until job completes
|
|
const { body: completedJob } = await recurse(
|
|
() => apiRequest({ method: 'GET', path: `/api/exports/${job.id}` }),
|
|
(response) => response.body.status === 'completed',
|
|
{
|
|
timeout: 60000,
|
|
interval: 2000,
|
|
log: `Waiting for export job ${job.id} to complete`,
|
|
}
|
|
);
|
|
|
|
expect(completedJob.status).toBe('completed');
|
|
expect(completedJob.downloadUrl).toBeDefined();
|
|
expect(completedJob.recordCount).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('should handle job failure gracefully', async ({ apiRequest, recurse }) => {
|
|
// Trigger job that will fail
|
|
const { body: job } = await apiRequest({
|
|
method: 'POST',
|
|
path: '/api/exports',
|
|
body: {
|
|
type: 'invalid-type', // This will cause failure
|
|
format: 'csv',
|
|
},
|
|
});
|
|
|
|
// Poll until job fails
|
|
const { body: failedJob } = await recurse(
|
|
() => apiRequest({ method: 'GET', path: `/api/exports/${job.id}` }),
|
|
(response) => ['completed', 'failed'].includes(response.body.status),
|
|
{ timeout: 30000 }
|
|
);
|
|
|
|
expect(failedJob.status).toBe('failed');
|
|
expect(failedJob.error).toBeDefined();
|
|
expect(failedJob.error.code).toBe('INVALID_EXPORT_TYPE');
|
|
});
|
|
|
|
test('should process webhook delivery', async ({ apiRequest, recurse }) => {
|
|
// Trigger action that sends webhook
|
|
const { body: order } = await apiRequest({
|
|
method: 'POST',
|
|
path: '/api/orders',
|
|
body: {
|
|
userId: 'user-123',
|
|
items: [{ productId: 'prod-1', quantity: 1 }],
|
|
webhookUrl: 'https://webhook.site/test-endpoint',
|
|
},
|
|
});
|
|
|
|
// Poll for webhook delivery status
|
|
const { body: webhookStatus } = await recurse(
|
|
() => apiRequest({ method: 'GET', path: `/api/webhooks/order/${order.id}` }),
|
|
(response) => response.body.delivered === true,
|
|
{ timeout: 30000, interval: 1000 }
|
|
);
|
|
|
|
expect(webhookStatus.delivered).toBe(true);
|
|
expect(webhookStatus.deliveredAt).toBeDefined();
|
|
expect(webhookStatus.responseStatus).toBe(200);
|
|
});
|
|
});
|
|
```
|
|
|
|
**Key Points**:
|
|
|
|
- `recurse` for polling async operations
|
|
- Test both success and failure scenarios
|
|
- Configurable timeout and interval
|
|
- Log messages for debugging
|
|
|
|
### Example 7: Service Authentication (No Browser)
|
|
|
|
**Context**: Test authenticated API endpoints using tokens directly - no browser login needed.
|
|
|
|
**Implementation**:
|
|
|
|
```typescript
|
|
// tests/api/authenticated.spec.ts
|
|
import { test, expect } from '@seontechnologies/playwright-utils/fixtures';
|
|
|
|
test.describe('Authenticated API Tests', () => {
|
|
let authToken: string;
|
|
|
|
test.beforeAll(async ({ request }) => {
|
|
// Get token via API (no browser!)
|
|
const response = await request.post('/api/auth/login', {
|
|
data: {
|
|
email: process.env.TEST_USER_EMAIL,
|
|
password: process.env.TEST_USER_PASSWORD,
|
|
},
|
|
});
|
|
|
|
const { token } = await response.json();
|
|
authToken = token;
|
|
});
|
|
|
|
test('should access protected endpoint with token', async ({ apiRequest }) => {
|
|
const { status, body } = await apiRequest({
|
|
method: 'GET',
|
|
path: '/api/me',
|
|
headers: {
|
|
Authorization: `Bearer ${authToken}`,
|
|
},
|
|
});
|
|
|
|
expect(status).toBe(200);
|
|
expect(body.email).toBe(process.env.TEST_USER_EMAIL);
|
|
});
|
|
|
|
test('should reject request without token', async ({ apiRequest }) => {
|
|
const { status, body } = await apiRequest({
|
|
method: 'GET',
|
|
path: '/api/me',
|
|
// No Authorization header
|
|
});
|
|
|
|
expect(status).toBe(401);
|
|
expect(body.code).toBe('UNAUTHORIZED');
|
|
});
|
|
|
|
test('should reject expired token', async ({ apiRequest }) => {
|
|
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'; // Expired token
|
|
|
|
const { status, body } = await apiRequest({
|
|
method: 'GET',
|
|
path: '/api/me',
|
|
headers: {
|
|
Authorization: `Bearer ${expiredToken}`,
|
|
},
|
|
});
|
|
|
|
expect(status).toBe(401);
|
|
expect(body.code).toBe('TOKEN_EXPIRED');
|
|
});
|
|
|
|
test('should handle role-based access', async ({ apiRequest }) => {
|
|
// User token (non-admin)
|
|
const { status } = await apiRequest({
|
|
method: 'GET',
|
|
path: '/api/admin/users',
|
|
headers: {
|
|
Authorization: `Bearer ${authToken}`,
|
|
},
|
|
});
|
|
|
|
expect(status).toBe(403); // Forbidden for non-admin
|
|
});
|
|
});
|
|
```
|
|
|
|
**Key Points**:
|
|
|
|
- Token obtained via API login (no browser)
|
|
- Token reused across all tests in describe block
|
|
- Test auth, expired tokens, and RBAC
|
|
- Pure API testing without UI
|
|
|
|
## API Test Configuration
|
|
|
|
### Playwright Config for API-Only Tests
|
|
|
|
```typescript
|
|
// playwright.config.ts
|
|
import { defineConfig } from '@playwright/test';
|
|
|
|
export default defineConfig({
|
|
testDir: './tests/api',
|
|
|
|
// No browser needed for API tests
|
|
use: {
|
|
baseURL: process.env.API_URL || 'http://localhost:3000',
|
|
extraHTTPHeaders: {
|
|
'Accept': 'application/json',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
},
|
|
|
|
// Faster without browser overhead
|
|
timeout: 30000,
|
|
|
|
// Run API tests in parallel
|
|
workers: 4,
|
|
fullyParallel: true,
|
|
|
|
// No screenshots/traces needed for API tests
|
|
reporter: [['html'], ['json', { outputFile: 'api-test-results.json' }]],
|
|
});
|
|
```
|
|
|
|
### Separate API Test Project
|
|
|
|
```typescript
|
|
// playwright.config.ts
|
|
export default defineConfig({
|
|
projects: [
|
|
{
|
|
name: 'api',
|
|
testDir: './tests/api',
|
|
use: {
|
|
baseURL: process.env.API_URL,
|
|
},
|
|
},
|
|
{
|
|
name: 'e2e',
|
|
testDir: './tests/e2e',
|
|
use: {
|
|
baseURL: process.env.APP_URL,
|
|
...devices['Desktop Chrome'],
|
|
},
|
|
},
|
|
],
|
|
});
|
|
```
|
|
|
|
## Comparison: API Tests vs E2E Tests
|
|
|
|
| Aspect | API Test | E2E Test |
|
|
|--------|----------|----------|
|
|
| **Speed** | ~50-100ms per test | ~2-10s per test |
|
|
| **Stability** | Very stable | More flaky (UI timing) |
|
|
| **Setup** | Minimal | Browser, context, page |
|
|
| **Debugging** | Clear request/response | DOM, screenshots, traces |
|
|
| **Coverage** | Service logic | User experience |
|
|
| **Parallelization** | Easy (stateless) | Complex (browser resources) |
|
|
| **CI Cost** | Low (no browser) | High (browser containers) |
|
|
|
|
## Related Fragments
|
|
|
|
- `api-request.md` - apiRequest utility details
|
|
- `recurse.md` - Polling patterns for async operations
|
|
- `auth-session.md` - Token management
|
|
- `contract-testing.md` - Pact contract testing
|
|
- `test-levels-framework.md` - When to use which test level
|
|
- `data-factories.md` - Test data setup patterns
|
|
|
|
## Anti-Patterns
|
|
|
|
**DON'T use E2E for API validation:**
|
|
|
|
```typescript
|
|
// Bad: Testing API through UI
|
|
test('validate user creation', async ({ page }) => {
|
|
await page.goto('/admin/users');
|
|
await page.fill('#name', 'John');
|
|
await page.click('#submit');
|
|
await expect(page.getByText('User created')).toBeVisible();
|
|
});
|
|
```
|
|
|
|
**DO test APIs directly:**
|
|
|
|
```typescript
|
|
// Good: Direct API test
|
|
test('validate user creation', async ({ apiRequest }) => {
|
|
const { status, body } = await apiRequest({
|
|
method: 'POST',
|
|
path: '/api/users',
|
|
body: { name: 'John' },
|
|
});
|
|
expect(status).toBe(201);
|
|
expect(body.id).toBeDefined();
|
|
});
|
|
```
|
|
|
|
**DON'T ignore API tests because "E2E covers it":**
|
|
|
|
```typescript
|
|
// Bad thinking: "Our E2E tests create users, so API is tested"
|
|
// Reality: E2E tests one happy path; API tests cover edge cases
|
|
```
|
|
|
|
**DO have dedicated API test coverage:**
|
|
|
|
```typescript
|
|
// Good: Explicit API test suite
|
|
test.describe('Users API', () => {
|
|
test('creates user', async ({ apiRequest }) => { /* ... */ });
|
|
test('handles duplicate email', async ({ apiRequest }) => { /* ... */ });
|
|
test('validates required fields', async ({ apiRequest }) => { /* ... */ });
|
|
test('handles malformed JSON', async ({ apiRequest }) => { /* ... */ });
|
|
test('rate limits requests', async ({ apiRequest }) => { /* ... */ });
|
|
});
|
|
```
|