backend-testing
Write tests for backend services, APIs, and database access. Use when testing Express/Fastify handlers, services with database calls, or integration tests.
SKILL.md
Full skill instructions
Backend Testing Skill
Write and maintain tests for backend services using Node.js native test runner with patterns for database mocking, API testing, and integration tests.
Test Categories
| Type | Purpose | Database | Speed |
|---|---|---|---|
| Unit | Service logic in isolation | Mocked | Fast |
| Integration | Service + real database | Test DB | Medium |
| API | HTTP endpoints | Mocked or Test DB | Medium |
| E2E | Full request flow | Test DB | Slow |
Directory Structure
test/
├── unit/
│ └── services/
│ └── user.test.js
├── integration/
│ └── services/
│ └── user.integration.test.js
├── api/
│ └── auth.api.test.js
├── helpers/
│ ├── db.js # Test database utilities
│ ├── request.js # HTTP request helpers
│ └── mocks.js # Common mocks
└── fixtures/
└── users.json
Unit Testing Services
Mocking Database Access
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert';
import { mock } from 'node:test';
// Service under test
import { UserService } from '../../src/services/user.js';
describe('UserService', () => {
let mockDb;
let service;
beforeEach(() => {
// Create mock database client
mockDb = {
query: mock.fn()
};
service = new UserService(mockDb);
});
describe('findById', () => {
it('should return user when found', async () => {
const mockUser = { id: '123', email: '[email protected]' };
mockDb.query.mock.mockImplementation(() => ({
rows: [mockUser]
}));
const result = await service.findById('123');
assert.deepStrictEqual(result, mockUser);
assert.strictEqual(mockDb.query.mock.calls.length, 1);
});
it('should return null when not found', async () => {
mockDb.query.mock.mockImplementation(() => ({ rows: [] }));
const result = await service.findById('nonexistent');
assert.strictEqual(result, null);
});
it('should throw on database error', async () => {
mockDb.query.mock.mockImplementation(() => {
throw new Error('Connection failed');
});
await assert.rejects(
() => service.findById('123'),
{ message: 'Connection failed' }
);
});
});
});
Service with Dependencies
import { describe, it, beforeEach } from 'node:test';
import { mock } from 'node:test';
describe('OrderService', () => {
let mockDb;
let mockEmailService;
let mockPaymentService;
let service;
beforeEach(() => {
mockDb = { query: mock.fn() };
mockEmailService = { send: mock.fn() };
mockPaymentService = { charge: mock.fn() };
service = new OrderService({
db: mockDb,
email: mockEmailService,
payment: mockPaymentService
});
});
it('should create order and send confirmation', async () => {
mockDb.query.mock.mockImplementation(() => ({
rows: [{ id: 'order-1' }]
}));
mockPaymentService.charge.mock.mockImplementation(() => ({
success: true
}));
await service.create({ userId: '123', items: [] });
assert.strictEqual(mockEmailService.send.mock.calls.length, 1);
assert.strictEqual(
mockEmailService.send.mock.calls[0].arguments[0].type,
'order_confirmation'
);
});
});
Integration Testing with Test Database
Test Database Setup
// test/helpers/db.js
import pg from 'pg';
const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL
|| 'postgresql://localhost:5432/myapp_test';
let pool;
/**
* Get or create test database pool
* @returns {pg.Pool}
*/
export function getTestPool() {
if (!pool) {
pool = new pg.Pool({ connectionString: TEST_DATABASE_URL });
}
return pool;
}
/**
* Run migrations on test database
*/
export async function migrateTestDb() {
const { migrate } = await import('../../src/db/migrate.js');
await migrate(getTestPool());
}
/**
* Truncate all tables (preserves schema)
*/
export async function truncateAll() {
const pool = getTestPool();
await pool.query(`
DO $$ DECLARE
r RECORD;
BEGIN
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename != 'migrations') LOOP
EXECUTE 'TRUNCATE TABLE ' || quote_ident(r.tablename) || ' CASCADE';
END LOOP;
END $$;
`);
}
/**
* Close test database pool
*/
export async function closeTestDb() {
if (pool) {
await pool.end();
pool = null;
}
}
/**
* Insert fixture data
* @param {string} table
* @param {Object[]} rows
*/
export async function insertFixtures(table, rows) {
const pool = getTestPool();
for (const row of rows) {
const keys = Object.keys(row);
const values = Object.values(row);
const placeholders = keys.map((_, i) => `$${i + 1}`).join(', ');
await pool.query(
`INSERT INTO ${table} (${keys.join(', ')}) VALUES (${placeholders})`,
values
);
}
}
Integration Test Example
// test/integration/services/user.integration.test.js
import { describe, it, before, after, beforeEach } from 'node:test';
import assert from 'node:assert';
import {
getTestPool,
migrateTestDb,
truncateAll,
closeTestDb,
insertFixtures
} from '../../helpers/db.js';
import { UserService } from '../../../src/services/user.js';
describe('UserService (integration)', () => {
let service;
before(async () => {
await migrateTestDb();
service = new UserService(getTestPool());
});
beforeEach(async () => {
await truncateAll();
});
after(async () => {
await closeTestDb();
});
describe('create', () => {
it('should insert user into database', async () => {
const user = await service.create({
email: '[email protected]',
name: 'Test User',
password: 'hashed_password'
});
assert.ok(user.id);
assert.strictEqual(user.email, '[email protected]');
// Verify in database
const pool = getTestPool();
const result = await pool.query(
'SELECT * FROM users WHERE id = $1',
[user.id]
);
assert.strictEqual(result.rows.length, 1);
});
it('should reject duplicate email', async () => {
await insertFixtures('users', [{
id: '550e8400-e29b-41d4-a716-446655440000',
email: '[email protected]',
name: 'Existing',
password_hash: 'hash',
role: 'user',
created_at: new Date(),
updated_at: new Date()
}]);
await assert.rejects(
() => service.create({
email: '[email protected]',
name: 'New User',
password: 'password'
}),
{ code: 'EMAIL_EXISTS' }
);
});
});
});
API Endpoint Testing
HTTP Request Helper
// test/helpers/request.js
/**
* Create test HTTP client for Express app
* @param {Express} app
* @returns {Object}
*/
export function createTestClient(app) {
let server;
let baseUrl;
return {
async start() {
return new Promise((resolve) => {
server = app.listen(0, () => {
const { port } = server.address();
baseUrl = `http://localhost:${port}`;
resolve();
});
});
},
async stop() {
return new Promise((resolve) => {
server?.close(resolve);
});
},
async request(method, path, options = {}) {
const url = `${baseUrl}${path}`;
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
...options.headers
},
body: options.body ? JSON.stringify(options.body) : undefined
});
return {
status: response.status,
headers: Object.fromEntries(response.headers),
body: await response.json().catch(() => null)
};
},
get(path, options) { return this.request('GET', path, options); },
post(path, options) { return this.request('POST', path, options); },
patch(path, options) { return this.request('PATCH', path, options); },
delete(path, options) { return this.request('DELETE', path, options); }
};
}
API Test Example
// test/api/auth.api.test.js
import { describe, it, before, after, beforeEach } from 'node:test';
import assert from 'node:assert';
import { createApp } from '../../src/index.js';
import { createTestClient } from '../helpers/request.js';
import { migrateTestDb, truncateAll, closeTestDb } from '../helpers/db.js';
describe('Auth API', () => {
let client;
let app;
before(async () => {
await migrateTestDb();
app = createApp({ isTest: true });
client = createTestClient(app);
await client.start();
});
beforeEach(async () => {
await truncateAll();
});
after(async () => {
await client.stop();
await closeTestDb();
});
describe('POST /api/auth/register', () => {
it('should create user and return tokens', async () => {
const res = await client.post('/api/auth/register', {
body: {
email: '[email protected]',
name: 'New User',
password: 'SecurePass123'
}
});
assert.strictEqual(res.status, 201);
assert.ok(res.body.user.id);
assert.strictEqual(res.body.user.email, '[email protected]');
assert.ok(res.body.accessToken);
assert.ok(res.body.refreshToken);
});
it('should reject weak password', async () => {
const res = await client.post('/api/auth/register', {
body: {
email: '[email protected]',
name: 'New User',
password: '123'
}
});
assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.code, 'VALIDATION_ERROR');
});
it('should reject duplicate email', async () => {
// First registration
await client.post('/api/auth/register', {
body: {
email: '[email protected]',
name: 'First',
password: 'SecurePass123'
}
});
// Duplicate attempt
const res = await client.post('/api/auth/register', {
body: {
email: '[email protected]',
name: 'Second',
password: 'SecurePass123'
}
});
assert.strictEqual(res.status, 409);
});
});
describe('POST /api/auth/login', () => {
beforeEach(async () => {
await client.post('/api/auth/register', {
body: {
email: '[email protected]',
name: 'Test User',
password: 'SecurePass123'
}
});
});
it('should return tokens for valid credentials', async () => {
const res = await client.post('/api/auth/login', {
body: {
email: '[email protected]',
password: 'SecurePass123'
}
});
assert.strictEqual(res.status, 200);
assert.ok(res.body.accessToken);
});
it('should reject invalid password', async () => {
const res = await client.post('/api/auth/login', {
body: {
email: '[email protected]',
password: 'WrongPassword'
}
});
assert.strictEqual(res.status, 401);
});
});
});
Authenticated Request Testing
describe('Protected Endpoints', () => {
let authToken;
beforeEach(async () => {
await truncateAll();
// Create and login test user
await client.post('/api/auth/register', {
body: {
email: '[email protected]',
name: 'Auth User',
password: 'SecurePass123'
}
});
const loginRes = await client.post('/api/auth/login', {
body: { email: '[email protected]', password: 'SecurePass123' }
});
authToken = loginRes.body.accessToken;
});
it('should access protected route with token', async () => {
const res = await client.get('/api/auth/me', {
headers: { Authorization: `Bearer ${authToken}` }
});
assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.user.email, '[email protected]');
});
it('should reject request without token', async () => {
const res = await client.get('/api/auth/me');
assert.strictEqual(res.status, 401);
});
});
Mocking External Services
HTTP Client Mocking
import { mock } from 'node:test';
describe('PaymentService', () => {
let originalFetch;
beforeEach(() => {
originalFetch = global.fetch;
global.fetch = mock.fn();
});
afterEach(() => {
global.fetch = originalFetch;
});
it('should call payment provider API', async () => {
global.fetch.mock.mockImplementation(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ transaction_id: 'txn_123' })
})
);
const result = await paymentService.charge({
amount: 1000,
currency: 'USD'
});
assert.strictEqual(result.transactionId, 'txn_123');
assert.strictEqual(global.fetch.mock.calls.length, 1);
const [url, options] = global.fetch.mock.calls[0].arguments;
assert.ok(url.includes('/charges'));
assert.strictEqual(options.method, 'POST');
});
});
Test Configuration
package.json Scripts
{
"scripts": {
"test": "node --test",
"test:unit": "node --test test/unit/**/*.test.js",
"test:integration": "node --test test/integration/**/*.test.js",
"test:api": "node --test test/api/**/*.test.js",
"test:coverage": "node --test --experimental-test-coverage",
"test:watch": "node --test --watch"
}
}
Environment Variables
# .env.test
NODE_ENV=test
TEST_DATABASE_URL=postgresql://localhost:5432/myapp_test
LOG_LEVEL=error
JWT_SECRET=test-secret-key
Test Checklist
Before completing a backend feature:
- Unit tests for service methods with mocked dependencies
- Integration tests for database operations
- API tests for all endpoints
- Error case coverage (validation, auth, not found)
- Edge cases (empty results, pagination bounds)
- Authentication/authorization checks tested
Running Tests
# All tests
npm test
# By category
npm run test:unit
npm run test:integration
npm run test:api
# Single file
node --test test/api/auth.api.test.js
# With coverage
npm run test:coverage
# Watch mode during development
npm run test:watch
Related Skills
- unit-testing - General unit testing patterns
- e2e-testing - Playwright browser testing
- nodejs-backend - Backend architecture patterns
