Backend Testing
When to use this skill
Specific situations that should trigger this skill:
-
New feature development: Write tests first using TDD (Test-Driven Development)
-
Adding API endpoints: Test success and failure cases for REST APIs
-
Bug fixes: Add tests to prevent regressions
-
Before refactoring: Write tests that guarantee existing behavior
-
CI/CD setup: Build automated test pipelines
Input Format
Format and required/optional information to collect from the user:
Required information
-
Framework: Express, Django, FastAPI, Spring Boot, etc.
-
Test tool: Jest, Pytest, Mocha/Chai, JUnit, etc.
-
Test target: API endpoints, business logic, DB operations, etc.
Optional information
-
Database: PostgreSQL, MySQL, MongoDB (default: in-memory DB)
-
Mocking library: jest.mock, sinon, unittest.mock (default: framework built-in)
-
Coverage target: 80%, 90%, etc. (default: 80%)
-
E2E tool: Supertest, TestClient, RestAssured (optional)
Input example
Test the user authentication endpoints for an Express.js API:
- Framework: Express + TypeScript
- Test tool: Jest + Supertest
- Target: POST /auth/register, POST /auth/login
- DB: PostgreSQL (in-memory for tests)
- Coverage: 90% or above
Instructions
Step-by-step task order to follow precisely.
Step 1: Set up the test environment
Install and configure the test framework and tools.
Tasks:
-
Install test libraries
-
Configure test database (in-memory or separate DB)
-
Separate environment variables (.env.test)
-
Configure jest.config.js or pytest.ini
Example (Node.js + Jest + Supertest):
npm install --save-dev jest ts-jest @types/jest supertest @types/supertest
jest.config.js:
module.exports = { preset: 'ts-jest', testEnvironment: 'node', roots: ['<rootDir>/src'], testMatch: ['/tests//.test.ts'], collectCoverageFrom: [ 'src/**/.ts', '!src//*.d.ts', '!src/tests/' ], coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, statements: 80 } }, setupFilesAfterEnv: ['<rootDir>/src/tests/setup.ts'] };
setup.ts (global test configuration):
import { db } from '../database';
// Reset DB before each test beforeEach(async () => { await db.migrate.latest(); await db.seed.run(); });
// Clean up after each test afterEach(async () => { await db.migrate.rollback(); });
// Close connection after all tests complete afterAll(async () => { await db.destroy(); });
Step 2: Write Unit Tests (business logic)
Write unit tests for individual functions and classes.
Tasks:
-
Test pure functions (no dependencies)
-
Isolate dependencies via mocking
-
Test edge cases (boundary values, exceptions)
-
AAA pattern (Arrange-Act-Assert)
Decision criteria:
-
No external dependencies (DB, API) -> pure Unit Test
-
External dependencies present -> use Mock/Stub
-
Complex logic -> test various input cases
Example (password validation function):
// src/utils/password.ts export function validatePassword(password: string): { valid: boolean; errors: string[] } { const errors: string[] = [];
if (password.length < 8) { errors.push('Password must be at least 8 characters'); }
if (!/[A-Z]/.test(password)) { errors.push('Password must contain uppercase letter'); }
if (!/[a-z]/.test(password)) { errors.push('Password must contain lowercase letter'); }
if (!/\d/.test(password)) { errors.push('Password must contain number'); }
if (!/[!@#$%^&*]/.test(password)) { errors.push('Password must contain special character'); }
return { valid: errors.length === 0, errors }; }
// src/tests/utils/password.test.ts import { validatePassword } from '../../utils/password';
describe('validatePassword', () => { it('should accept valid password', () => { const result = validatePassword('Password123!'); expect(result.valid).toBe(true); expect(result.errors).toHaveLength(0); });
it('should reject password shorter than 8 characters', () => { const result = validatePassword('Pass1!'); expect(result.valid).toBe(false); expect(result.errors).toContain('Password must be at least 8 characters'); });
it('should reject password without uppercase', () => { const result = validatePassword('password123!'); expect(result.valid).toBe(false); expect(result.errors).toContain('Password must contain uppercase letter'); });
it('should reject password without lowercase', () => { const result = validatePassword('PASSWORD123!'); expect(result.valid).toBe(false); expect(result.errors).toContain('Password must contain lowercase letter'); });
it('should reject password without number', () => { const result = validatePassword('Password!'); expect(result.valid).toBe(false); expect(result.errors).toContain('Password must contain number'); });
it('should reject password without special character', () => { const result = validatePassword('Password123'); expect(result.valid).toBe(false); expect(result.errors).toContain('Password must contain special character'); });
it('should return multiple errors for invalid password', () => { const result = validatePassword('pass'); expect(result.valid).toBe(false); expect(result.errors.length).toBeGreaterThan(1); }); });
Step 3: Integration Test (API endpoints)
Write integration tests for API endpoints.
Tasks:
-
Test HTTP requests/responses
-
Success cases (200, 201)
-
Failure cases (400, 401, 404, 500)
-
Authentication/authorization tests
-
Input validation tests
Checklist:
-
Verify status code
-
Validate response body structure
-
Confirm database state changes
-
Validate error messages
Example (Express.js + Supertest):
// src/tests/api/auth.test.ts import request from 'supertest'; import app from '../../app'; import { db } from '../../database';
describe('POST /auth/register', () => { it('should register new user successfully', async () => { const response = await request(app) .post('/api/auth/register') .send({ email: 'test@example.com', username: 'testuser', password: 'Password123!' });
expect(response.status).toBe(201);
expect(response.body).toHaveProperty('user');
expect(response.body).toHaveProperty('accessToken');
expect(response.body.user.email).toBe('test@example.com');
// Verify the record was actually saved to DB
const user = await db.user.findUnique({ where: { email: 'test@example.com' } });
expect(user).toBeTruthy();
expect(user.username).toBe('testuser');
});
it('should reject duplicate email', async () => { // Create first user await request(app) .post('/api/auth/register') .send({ email: 'test@example.com', username: 'user1', password: 'Password123!' });
// Second attempt with same email
const response = await request(app)
.post('/api/auth/register')
.send({
email: 'test@example.com',
username: 'user2',
password: 'Password123!'
});
expect(response.status).toBe(409);
expect(response.body.error).toContain('already exists');
});
it('should reject weak password', async () => { const response = await request(app) .post('/api/auth/register') .send({ email: 'test@example.com', username: 'testuser', password: 'weak' });
expect(response.status).toBe(400);
expect(response.body.error).toBeDefined();
});
it('should reject missing fields', async () => { const response = await request(app) .post('/api/auth/register') .send({ email: 'test@example.com' // username, password omitted });
expect(response.status).toBe(400);
}); });
describe('POST /auth/login', () => { beforeEach(async () => { // Create test user await request(app) .post('/api/auth/register') .send({ email: 'test@example.com', username: 'testuser', password: 'Password123!' }); });
it('should login with valid credentials', async () => { const response = await request(app) .post('/api/auth/login') .send({ email: 'test@example.com', password: 'Password123!' });
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('accessToken');
expect(response.body).toHaveProperty('refreshToken');
expect(response.body.user.email).toBe('test@example.com');
});
it('should reject invalid password', async () => { const response = await request(app) .post('/api/auth/login') .send({ email: 'test@example.com', password: 'WrongPassword123!' });
expect(response.status).toBe(401);
expect(response.body.error).toContain('Invalid credentials');
});
it('should reject non-existent user', async () => { const response = await request(app) .post('/api/auth/login') .send({ email: 'nonexistent@example.com', password: 'Password123!' });
expect(response.status).toBe(401);
}); });
Step 4: Authentication/Authorization Tests
Test JWT tokens and role-based access control.
Tasks:
-
Confirm 401 when accessing without a token
-
Confirm successful access with a valid token
-
Test expired token handling
-
Role-based permission tests
Example:
describe('Protected Routes', () => { let accessToken: string; let adminToken: string;
beforeEach(async () => { // Regular user token const userResponse = await request(app) .post('/api/auth/register') .send({ email: 'user@example.com', username: 'user', password: 'Password123!' }); accessToken = userResponse.body.accessToken;
// Admin token
const adminResponse = await request(app)
.post('/api/auth/register')
.send({
email: 'admin@example.com',
username: 'admin',
password: 'Password123!'
});
// Update role to 'admin' in DB
await db.user.update({
where: { email: 'admin@example.com' },
data: { role: 'admin' }
});
// Log in again to get a new token
const loginResponse = await request(app)
.post('/api/auth/login')
.send({
email: 'admin@example.com',
password: 'Password123!'
});
adminToken = loginResponse.body.accessToken;
});
describe('GET /api/auth/me', () => {
it('should return current user with valid token', async () => {
const response = await request(app)
.get('/api/auth/me')
.set('Authorization', Bearer ${accessToken});
expect(response.status).toBe(200);
expect(response.body.user.email).toBe('user@example.com');
});
it('should reject request without token', async () => {
const response = await request(app)
.get('/api/auth/me');
expect(response.status).toBe(401);
});
it('should reject request with invalid token', async () => {
const response = await request(app)
.get('/api/auth/me')
.set('Authorization', 'Bearer invalid-token');
expect(response.status).toBe(403);
});
});
describe('DELETE /api/users/:id (Admin only)', () => { it('should allow admin to delete user', async () => { const targetUser = await db.user.findUnique({ where: { email: 'user@example.com' } });
const response = await request(app)
.delete(`/api/users/${targetUser.id}`)
.set('Authorization', `Bearer ${adminToken}`);
expect(response.status).toBe(200);
});
it('should forbid non-admin from deleting user', async () => {
const targetUser = await db.user.findUnique({ where: { email: 'user@example.com' } });
const response = await request(app)
.delete(`/api/users/${targetUser.id}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(response.status).toBe(403);
});
}); });
Step 5: Mocking and Test Isolation
Mock external dependencies to isolate tests.
Tasks:
-
Mock external APIs
-
Mock email sending
-
Mock file system
-
Mock time-related functions
Example (mocking an external API):
// src/services/emailService.ts
export async function sendVerificationEmail(email: string, token: string): Promise<void> {
const response = await fetch('https://api.sendgrid.com/v3/mail/send', {
method: 'POST',
headers: { 'Authorization': Bearer ${process.env.SENDGRID_API_KEY} },
body: JSON.stringify({
to: email,
subject: 'Verify your email',
html: <a href="https://example.com/verify?token=${token}">Verify</a>
})
});
if (!response.ok) { throw new Error('Failed to send email'); } }
// src/tests/services/emailService.test.ts import { sendVerificationEmail } from '../../services/emailService';
// Mock fetch global.fetch = jest.fn();
describe('sendVerificationEmail', () => { beforeEach(() => { (fetch as jest.Mock).mockClear(); });
it('should send email successfully', async () => { (fetch as jest.Mock).mockResolvedValueOnce({ ok: true, status: 200 });
await expect(sendVerificationEmail('test@example.com', 'token123'))
.resolves
.toBeUndefined();
expect(fetch).toHaveBeenCalledWith(
'https://api.sendgrid.com/v3/mail/send',
expect.objectContaining({
method: 'POST'
})
);
});
it('should throw error if email sending fails', async () => { (fetch as jest.Mock).mockResolvedValueOnce({ ok: false, status: 500 });
await expect(sendVerificationEmail('test@example.com', 'token123'))
.rejects
.toThrow('Failed to send email');
}); });
Output format
Defines the exact format that outputs must follow.
Basic structure
project/ ├── src/ │ ├── tests/ │ │ ├── setup.ts # Global test configuration │ │ ├── utils/ │ │ │ └── password.test.ts # Unit tests │ │ ├── services/ │ │ │ └── emailService.test.ts │ │ └── api/ │ │ ├── auth.test.ts # Integration tests │ │ └── users.test.ts │ └── ... ├── jest.config.js └── package.json
Test run scripts (package.json)
{ "scripts": { "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", "test:ci": "jest --ci --coverage --maxWorkers=2" } }
Coverage report
$ npm run test:coverage
--------------------------|---------|----------|---------|---------|
| File | % Stmts | % Branch | % Funcs | % Lines |
|---|---|---|---|---|
| All files | 92.5 | 88.3 | 95.2 | 92.8 |
| auth/ | 95.0 | 90.0 | 100.0 | 95.0 |
| middleware.ts | 95.0 | 90.0 | 100.0 | 95.0 |
| routes.ts | 95.0 | 90.0 | 100.0 | 95.0 |
| utils/ | 90.0 | 85.0 | 90.0 | 90.0 |
| password.ts | 90.0 | 85.0 | 90.0 | 90.0 |
| -------------------------- | --------- | ---------- | --------- | --------- |
Constraints
Rules and prohibitions that must be strictly followed.
Required rules (MUST)
Test isolation: Each test must be runnable independently
-
Reset state with beforeEach/afterEach
-
Do not depend on test execution order
Clear test names: The name must convey what the test verifies
-
✅ 'should reject duplicate email'
-
❌ 'test1'
AAA pattern: Arrange (setup) - Act (execute) - Assert (verify) structure
-
Improves readability
-
Clarifies test intent
Prohibited (MUST NOT)
No production DB: Tests must use a separate or in-memory DB
-
Risk of losing real data
-
Cannot isolate tests
No real external API calls: Mock all external services
-
Removes network dependency
-
Speeds up tests
-
Reduces costs
No Sleep/Timeout abuse: Use fake timers for time-based tests
-
jest.useFakeTimers()
-
Prevents test slowdowns
Security rules
-
No hardcoded secrets: Never hardcode API keys or passwords in test code
-
Separate environment variables: Use .env.test file
Examples
Example 1: Python FastAPI tests (Pytest)
Situation: Testing a FastAPI REST API
User request:
Test the user API built with FastAPI using pytest.
Final result:
tests/conftest.py
import pytest from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker
from app.main import app from app.database import Base, get_db
In-memory SQLite for tests
SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db" engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@pytest.fixture(scope="function") def db_session(): Base.metadata.create_all(bind=engine) db = TestingSessionLocal() try: yield db finally: db.close() Base.metadata.drop_all(bind=engine)
@pytest.fixture(scope="function") def client(db_session): def override_get_db(): try: yield db_session finally: db_session.close()
app.dependency_overrides[get_db] = override_get_db
yield TestClient(app)
app.dependency_overrides.clear()
tests/test_auth.py
def test_register_user_success(client): response = client.post("/auth/register", json={ "email": "test@example.com", "username": "testuser", "password": "Password123!" })
assert response.status_code == 201
assert "access_token" in response.json()
assert response.json()["user"]["email"] == "test@example.com"
def test_register_duplicate_email(client): # First user client.post("/auth/register", json={ "email": "test@example.com", "username": "user1", "password": "Password123!" })
# Duplicate email
response = client.post("/auth/register", json={
"email": "test@example.com",
"username": "user2",
"password": "Password123!"
})
assert response.status_code == 409
assert "already exists" in response.json()["detail"]
def test_login_success(client): # Register client.post("/auth/register", json={ "email": "test@example.com", "username": "testuser", "password": "Password123!" })
# Login
response = client.post("/auth/login", json={
"email": "test@example.com",
"password": "Password123!"
})
assert response.status_code == 200
assert "access_token" in response.json()
def test_protected_route_without_token(client): response = client.get("/auth/me") assert response.status_code == 401
def test_protected_route_with_token(client): # Register and get token register_response = client.post("/auth/register", json={ "email": "test@example.com", "username": "testuser", "password": "Password123!" }) token = register_response.json()["access_token"]
# Access protected route
response = client.get("/auth/me", headers={
"Authorization": f"Bearer {token}"
})
assert response.status_code == 200
assert response.json()["email"] == "test@example.com"
Best practices
Quality improvements
TDD (Test-Driven Development): Write tests before writing code
-
Clarifies requirements
-
Improves design
-
Naturally achieves high coverage
Given-When-Then pattern: Write tests in BDD style
it('should return 404 when user not found', async () => { // Given: a non-existent user ID const nonExistentId = 'non-existent-uuid';
// When: attempting to look up that user
const response = await request(app).get(/users/${nonExistentId});
// Then: 404 response expect(response.status).toBe(404); });
Test Fixtures: Reusable test data
const validUser = { email: 'test@example.com', username: 'testuser', password: 'Password123!' };
Efficiency improvements
-
Parallel execution: Speed up tests with Jest's --maxWorkers option
-
Snapshot Testing: Save snapshots of UI components or JSON responses
-
Coverage thresholds: Enforce minimum coverage in jest.config.js
Common Issues
Issue 1: Test failures caused by shared state between tests
Symptom: Passes individually but fails when run together
Cause: DB state shared due to missing beforeEach/afterEach
Fix:
beforeEach(async () => { await db.migrate.rollback(); await db.migrate.latest(); });
Issue 2: "Jest did not exit one second after the test run"
Symptom: Process does not exit after tests complete
Cause: DB connections, servers, etc. not cleaned up
Fix:
afterAll(async () => { await db.destroy(); await server.close(); });
Issue 3: Async test timeout
Symptom: "Timeout - Async callback was not invoked"
Cause: Missing async/await or unhandled Promise
Fix:
// Bad it('should work', () => { request(app).get('/users'); // Promise not handled });
// Good it('should work', async () => { await request(app).get('/users'); });
References
Official docs
-
Jest Documentation
-
Pytest Documentation
-
Supertest GitHub
Learning resources
-
Testing JavaScript with Kent C. Dodds
-
Test-Driven Development by Example (Kent Beck)
Tools
-
Istanbul/nyc - code coverage
-
nock - HTTP mocking
-
faker.js - test data generation
Metadata
Version
-
Current version: 1.0.0
-
Last updated: 2025-01-01
-
Compatible platforms: Claude, ChatGPT, Gemini
Related skills
-
api-design: Design APIs alongside tests
-
authentication-setup: Test authentication systems
Tags
#testing #backend #Jest #Pytest #unit-test #integration-test #TDD #API-test