API Testing Recipe
Comprehensive guide for testing APIs in the GISE development workflow. Learn to implement automated testing strategies that ensure API reliability, performance, and security.
Testing Strategy
Unit Testing APIs
Test Structure
// tests/unit/auth.test.ts
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { AuthService } from '../../src/services/AuthService';
import { UserRepository } from '../../src/repositories/UserRepository';
import { TokenService } from '../../src/services/TokenService';
// Mock dependencies
jest.mock('../../src/repositories/UserRepository');
jest.mock('../../src/services/TokenService');
describe('AuthService', () => {
let authService: AuthService;
let mockUserRepository: jest.Mocked<UserRepository>;
let mockTokenService: jest.Mocked<TokenService>;
beforeEach(() => {
mockUserRepository = new UserRepository() as jest.Mocked<UserRepository>;
mockTokenService = new TokenService() as jest.Mocked<TokenService>;
authService = new AuthService(mockUserRepository, mockTokenService);
});
describe('login', () => {
it('should return token for valid credentials', async () => {
// Arrange
const email = 'test@example.com';
const password = 'validPassword123!';
const mockUser = {
id: 'user_123',
email,
hashedPassword: '$2b$10$hashedPassword'
};
const mockToken = 'jwt_token_here';
mockUserRepository.findByEmail.mockResolvedValue(mockUser);
mockTokenService.generateAccessToken.mockReturnValue(mockToken);
// Act
const result = await authService.login(email, password);
// Assert
expect(result).toEqual({
user: expect.objectContaining({ id: mockUser.id, email: mockUser.email }),
token: mockToken
});
expect(mockUserRepository.findByEmail).toHaveBeenCalledWith(email);
expect(mockTokenService.generateAccessToken).toHaveBeenCalledWith(mockUser.id);
});
it('should throw error for invalid credentials', async () => {
// Arrange
const email = 'test@example.com';
const password = 'wrongPassword';
mockUserRepository.findByEmail.mockResolvedValue(null);
// Act & Assert
await expect(authService.login(email, password))
.rejects
.toThrow('Invalid credentials');
});
});
});
Integration Testing
API Endpoint Testing
// tests/integration/users.test.ts
import request from 'supertest';
import { app } from '../../src/app';
import { DatabaseManager } from '../../src/database/DatabaseManager';
import { TestDataBuilder } from '../helpers/TestDataBuilder';
describe('Users API', () => {
let authToken: string;
let testUser: any;
beforeAll(async () => {
await DatabaseManager.connect();
await DatabaseManager.clearDatabase();
});
afterAll(async () => {
await DatabaseManager.clearDatabase();
await DatabaseManager.disconnect();
});
beforeEach(async () => {
// Create test user and get auth token
testUser = await TestDataBuilder.createUser({
email: 'test@example.com',
role: 'admin'
});
authToken = TestDataBuilder.generateAuthToken(testUser.id);
});
describe('GET /api/v1/users', () => {
it('should return paginated users list', async () => {
// Arrange
await TestDataBuilder.createUsers(25);
// Act
const response = await request(app)
.get('/api/v1/users?page=1&limit=10')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
// Assert
expect(response.body).toMatchObject({
data: expect.arrayContaining([
expect.objectContaining({
id: expect.stringMatching(/^user_/),
email: expect.stringMatching(/@/),
firstName: expect.any(String),
lastName: expect.any(String)
})
]),
pagination: {
page: 1,
limit: 10,
total: expect.any(Number),
totalPages: expect.any(Number)
}
});
expect(response.body.data).toHaveLength(10);
});
it('should return 401 for unauthenticated requests', async () => {
await request(app)
.get('/api/v1/users')
.expect(401)
.expect((res) => {
expect(res.body.error.code).toBe('UNAUTHORIZED');
});
});
});
describe('POST /api/v1/users', () => {
it('should create new user with valid data', async () => {
// Arrange
const userData = {
email: 'new.user@example.com',
password: 'SecurePass123!',
firstName: 'New',
lastName: 'User',
dateOfBirth: '1990-01-01',
membershipType: 'adult'
};
// Act
const response = await request(app)
.post('/api/v1/users')
.send(userData)
.expect(201);
// Assert
expect(response.body.data).toMatchObject({
id: expect.stringMatching(/^user_/),
email: userData.email,
firstName: userData.firstName,
lastName: userData.lastName,
status: 'active',
membershipType: userData.membershipType
});
expect(response.body.data.password).toBeUndefined();
});
it('should return validation errors for invalid data', async () => {
// Arrange
const invalidUserData = {
email: 'invalid-email',
password: '123', // Too short
firstName: '', // Empty
lastName: 'User'
};
// Act
const response = await request(app)
.post('/api/v1/users')
.send(invalidUserData)
.expect(400);
// Assert
expect(response.body.error.code).toBe('VALIDATION_ERROR');
expect(response.body.error.details).toEqual(
expect.arrayContaining([
expect.objectContaining({ field: 'email' }),
expect.objectContaining({ field: 'password' }),
expect.objectContaining({ field: 'firstName' })
])
);
});
});
});
Contract Testing
Consumer-Driven Contracts
// tests/contract/library-api.pact.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { APIClient } from '../../src/clients/APIClient';
const { like, eachLike, iso8601DateTime } = MatchersV3;
describe('Library API Contract Tests', () => {
const provider = new PactV3({
consumer: 'Library Frontend',
provider: 'Library API',
logLevel: 'info'
});
describe('GET /users/{id}', () => {
it('should return user details', async () => {
// Arrange
const userId = 'user_123';
await provider
.given('user with ID user_123 exists')
.uponReceiving('a request for user details')
.withRequest({
method: 'GET',
path: `/api/v1/users/${userId}`,
headers: {
'Authorization': 'Bearer valid_token',
'Content-Type': 'application/json'
}
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
data: {
id: like('user_123'),
email: like('john.doe@example.com'),
firstName: like('John'),
lastName: like('Doe'),
status: like('active'),
membershipType: like('adult'),
registrationDate: iso8601DateTime('2024-01-15T10:30:00Z')
}
}
});
// Act & Assert
return provider.executeTest(async (mockService) => {
const client = new APIClient(mockService.url);
const user = await client.getUser(userId);
expect(user.id).toBe('user_123');
expect(user.email).toBe('john.doe@example.com');
expect(user.status).toBe('active');
});
});
});
});
Performance Testing
Load Testing with Artillery
# tests/performance/load-test.yml
config:
target: 'http://localhost:3000'
phases:
- duration: 60
arrivalRate: 10
name: "Warm up phase"
- duration: 300
arrivalRate: 50
name: "Sustained load"
- duration: 120
arrivalRate: 100
name: "Peak load"
variables:
baseUrl: "http://localhost:3000/api/v1"
scenarios:
- name: "User Authentication Flow"
weight: 70
flow:
- post:
url: "{{ baseUrl }}/auth/login"
json:
email: "test{{ $randomInt(1, 1000) }}@example.com"
password: "TestPassword123!"
capture:
- json: "$.token.accessToken"
as: "authToken"
- get:
url: "{{ baseUrl }}/users/profile"
headers:
Authorization: "Bearer {{ authToken }}"
- name: "Book Search"
weight: 30
flow:
- get:
url: "{{ baseUrl }}/books"
qs:
search: "{{ $randomString() }}"
limit: 20
page: "{{ $randomInt(1, 10) }}"
Performance Test Script
// tests/performance/api-benchmark.ts
import autocannon from 'autocannon';
import { performance } from 'perf_hooks';
interface PerformanceResults {
endpoint: string;
avgLatency: number;
p95Latency: number;
requestsPerSecond: number;
errorRate: number;
}
class APIPerformanceTester {
private baseUrl: string;
private authToken: string;
constructor(baseUrl: string, authToken: string) {
this.baseUrl = baseUrl;
this.authToken = authToken;
}
async testEndpoint(
path: string,
options: {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
body?: any;
duration?: number;
connections?: number;
} = {}
): Promise<PerformanceResults> {
const {
method = 'GET',
body,
duration = 30,
connections = 10
} = options;
const result = await autocannon({
url: `${this.baseUrl}${path}`,
method,
headers: {
'Authorization': `Bearer ${this.authToken}`,
'Content-Type': 'application/json'
},
body: body ? JSON.stringify(body) : undefined,
duration,
connections
});
return {
endpoint: path,
avgLatency: result.latency.mean,
p95Latency: result.latency.p95,
requestsPerSecond: result.requests.mean,
errorRate: (result.non2xx / result.requests.total) * 100
};
}
async runFullSuite(): Promise<PerformanceResults[]> {
const tests = [
{ path: '/users', method: 'GET' as const },
{ path: '/books', method: 'GET' as const },
{
path: '/users',
method: 'POST' as const,
body: {
email: `perf-test-${Date.now()}@example.com`,
password: 'TestPassword123!',
firstName: 'Performance',
lastName: 'Test'
}
}
];
const results: PerformanceResults[] = [];
for (const test of tests) {
console.log(`Testing ${test.method} ${test.path}...`);
const result = await this.testEndpoint(test.path, test);
results.push(result);
// Wait between tests
await new Promise(resolve => setTimeout(resolve, 5000));
}
return results;
}
}
Security Testing
OWASP API Security Testing
// tests/security/api-security.test.ts
import request from 'supertest';
import { app } from '../../src/app';
describe('API Security Tests', () => {
describe('OWASP API Top 10', () => {
describe('API1: Broken Object Level Authorization', () => {
it('should prevent access to other users data', async () => {
// Create two users
const user1 = await createTestUser('user1@example.com');
const user2 = await createTestUser('user2@example.com');
const user1Token = generateToken(user1.id);
// Try to access user2's data with user1's token
await request(app)
.get(`/api/v1/users/${user2.id}`)
.set('Authorization', `Bearer ${user1Token}`)
.expect(403);
});
});
describe('API2: Broken User Authentication', () => {
it('should reject requests without authentication', async () => {
await request(app)
.get('/api/v1/users')
.expect(401);
});
it('should reject invalid tokens', async () => {
await request(app)
.get('/api/v1/users')
.set('Authorization', 'Bearer invalid_token')
.expect(401);
});
it('should reject expired tokens', async () => {
const expiredToken = generateExpiredToken('user_123');
await request(app)
.get('/api/v1/users')
.set('Authorization', `Bearer ${expiredToken}`)
.expect(401);
});
});
describe('API3: Excessive Data Exposure', () => {
it('should not expose sensitive data in responses', async () => {
const user = await createTestUser('test@example.com');
const token = generateToken(user.id);
const response = await request(app)
.get(`/api/v1/users/${user.id}`)
.set('Authorization', `Bearer ${token}`)
.expect(200);
// Verify sensitive data is not exposed
expect(response.body.data.password).toBeUndefined();
expect(response.body.data.hashedPassword).toBeUndefined();
expect(response.body.data.socialSecurityNumber).toBeUndefined();
});
});
describe('API4: Lack of Resources & Rate Limiting', () => {
it('should implement rate limiting', async () => {
const user = await createTestUser('ratelimit@example.com');
const token = generateToken(user.id);
// Make requests rapidly
const requests = Array(100).fill(null).map(() =>
request(app)
.get('/api/v1/users')
.set('Authorization', `Bearer ${token}`)
);
const responses = await Promise.all(requests);
const rateLimited = responses.some(res => res.status === 429);
expect(rateLimited).toBe(true);
});
});
describe('API5: Broken Function Level Authorization', () => {
it('should prevent non-admin from accessing admin endpoints', async () => {
const regularUser = await createTestUser('regular@example.com', 'user');
const token = generateToken(regularUser.id);
await request(app)
.delete('/api/v1/users/some_user_id')
.set('Authorization', `Bearer ${token}`)
.expect(403);
});
});
describe('API6: Mass Assignment', () => {
it('should prevent unauthorized field updates', async () => {
const user = await createTestUser('test@example.com');
const token = generateToken(user.id);
await request(app)
.put(`/api/v1/users/${user.id}`)
.set('Authorization', `Bearer ${token}`)
.send({
firstName: 'Updated',
role: 'admin', // Should be ignored
isVerified: true // Should be ignored
})
.expect(200);
// Verify protected fields weren't updated
const updatedUser = await getUserById(user.id);
expect(updatedUser.role).toBe('user'); // Original role
expect(updatedUser.firstName).toBe('Updated');
});
});
describe('API7: Security Misconfiguration', () => {
it('should have proper security headers', async () => {
await request(app)
.get('/api/v1/health')
.expect(200)
.expect('X-Content-Type-Options', 'nosniff')
.expect('X-Frame-Options', 'DENY')
.expect('X-XSS-Protection', '1; mode=block');
});
});
describe('API8: Injection', () => {
it('should prevent SQL injection in query parameters', async () => {
const user = await createTestUser('test@example.com');
const token = generateToken(user.id);
// Attempt SQL injection
await request(app)
.get('/api/v1/users')
.query({ search: "'; DROP TABLE users; --" })
.set('Authorization', `Bearer ${token}`)
.expect((res) => {
expect(res.status).not.toBe(500);
// Should handle gracefully or return validation error
expect([200, 400]).toContain(res.status);
});
});
});
describe('API9: Improper Assets Management', () => {
it('should not expose debug endpoints in production', async () => {
await request(app)
.get('/debug')
.expect(404);
await request(app)
.get('/api/debug')
.expect(404);
});
});
describe('API10: Insufficient Logging & Monitoring', () => {
it('should log security events', async () => {
// This would typically check log output
// Implementation depends on your logging system
// Attempt unauthorized access
await request(app)
.delete('/api/v1/users/user_123')
.set('Authorization', 'Bearer invalid_token')
.expect(401);
// In real implementation, verify that this event was logged
// with appropriate details for security monitoring
});
});
});
});
Test Automation & CI/CD
GitHub Actions Workflow
# .github/workflows/api-tests.yml
name: API Test Suite
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:unit -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v3
integration-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:14
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test_library
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run database migrations
run: npm run db:migrate
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_library
- name: Run integration tests
run: npm run test:integration
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_library
JWT_SECRET: test_secret
security-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run security tests
run: npm run test:security
- name: Run OWASP ZAP scan
uses: zaproxy/action-full-scan@v0.4.0
with:
target: 'http://localhost:3000'
performance-tests:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Start application
run: |
npm run build
npm start &
sleep 30
- name: Run performance tests
run: npm run test:performance
- name: Upload performance results
uses: actions/upload-artifact@v3
with:
name: performance-results
path: tests/performance/results/
Best Practices
Test Organization
tests/
├── unit/ # Unit tests (70% of tests)
│ ├── services/
│ ├── repositories/
│ └── utils/
├── integration/ # Integration tests (20% of tests)
│ ├── api/
│ ├── database/
│ └── external-services/
├── e2e/ # End-to-end tests (10% of tests)
│ ├── user-flows/
│ └── critical-paths/
├── contract/ # Contract tests
│ ├── consumer/
│ └── provider/
├── performance/ # Performance tests
│ ├── load/
│ └── stress/
├── security/ # Security tests
│ └── owasp/
├── helpers/ # Test utilities
│ ├── TestDataBuilder.ts
│ ├── DatabaseHelper.ts
│ └── MockFactory.ts
└── fixtures/ # Test data
├── users.json
└── books.json
Test Quality Guidelines
- AAA Pattern: Arrange, Act, Assert
- Descriptive Test Names: Test should read like a specification
- One Assertion Per Test: Focus on single behavior
- Test Independence: Tests should not depend on each other
- Mock External Dependencies: Use mocks for external services
- Clean Test Data: Set up and tear down test data properly
This comprehensive API testing recipe provides a solid foundation for ensuring your APIs are reliable, performant, and secure throughout the development lifecycle.