backend-testing
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: 'test@example.com' };
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: 'test@example.com',
name: 'Test User',
password: 'hashed_password'
});
assert.ok(user.id);
assert.strictEqual(user.email, 'test@example.com');
// 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: 'existing@example.com',
name: 'Existing',
password_hash: 'hash',
role: 'user',
created_at: new Date(),
updated_at: new Date()
}]);
await assert.rejects(
() => service.create({
email: 'existing@example.com',
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: 'new@example.com',
name: 'New User',
password: 'SecurePass123'
}
});
assert.strictEqual(res.status, 201);
assert.ok(res.body.user.id);
assert.strictEqual(res.body.user.email, 'new@example.com');
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: 'new@example.com',
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: 'dupe@example.com',
name: 'First',
password: 'SecurePass123'
}
});
// Duplicate attempt
const res = await client.post('/api/auth/register', {
body: {
email: 'dupe@example.com',
name: 'Second',
password: 'SecurePass123'
}
});
assert.strictEqual(res.status, 409);
});
});
describe('POST /api/auth/login', () => {
beforeEach(async () => {
await client.post('/api/auth/register', {
body: {
email: 'user@example.com',
name: 'Test User',
password: 'SecurePass123'
}
});
});
it('should return tokens for valid credentials', async () => {
const res = await client.post('/api/auth/login', {
body: {
email: 'user@example.com',
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: 'user@example.com',
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: 'auth@example.com',
name: 'Auth User',
password: 'SecurePass123'
}
});
const loginRes = await client.post('/api/auth/login', {
body: { email: 'auth@example.com', 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, 'auth@example.com');
});
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
More from profpowell/vanilla-breeze
api-client
Fetch API patterns with error handling, retry logic, and caching. Use when building API integrations, handling network failures, or implementing offline-first data fetching.
44validation
Validate data with JSON Schema and AJV. Use when validating API requests, form submissions, database inputs, or any data boundaries. Provides deterministic validation with consistent error formats.
43service-worker
Service worker patterns for offline support, caching strategies, and PWA functionality. Use when implementing offline-first features, caching, or background sync.
8git-workflow
Enforce structured git workflow with conventional commits, feature branches, semver versioning, and work logging. Use for all code changes to prevent work loss and maintain history.
8patterns
Reusable HTML page patterns and component blocks. Use when building common page types like FAQs, product listings, press releases, or other structured content.
8i18n
Write internationalization-friendly HTML pages. Use when creating multilingual content, setting lang attributes, handling RTL languages, or preparing content for translation.
7