Backend Testing
This document outlines the testing strategy and implementation for the BookWish Node.js/TypeScript backend API.
Testing Framework
Dependencies
{
"devDependencies": {
"jest": "^29.x",
"ts-jest": "^29.x",
"@types/jest": "^29.x",
"supertest": "^6.x",
"@types/supertest": "^2.x"
}
}
Jest Configuration
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.test.ts'],
transform: {
'^.+\\.tsx?$': ['ts-jest', { useESM: false }],
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/__tests__/**',
],
coverageDirectory: 'coverage',
verbose: true,
};
Test Directory Structure
backend/
├── src/
│ ├── services/
│ │ ├── tax.service.ts
│ │ ├── shipping.service.ts
│ │ └── __tests__/
│ │ ├── tax.service.test.ts
│ │ └── shipping.service.test.ts
│ ├── controllers/
│ │ └── __tests__/
│ ├── middleware/
│ │ └── __tests__/
│ └── utils/
│ └── __tests__/
└── jest.config.js
Test Types
1. Unit Tests
Unit tests verify individual functions, services, and utilities in isolation.
Service Tests
// src/services/__tests__/tax.service.test.ts
import { taxService } from '../tax.service';
describe('TaxService', () => {
describe('calculateTax', () => {
it('should calculate tax correctly for California (7.25%)', async () => {
const result = await taxService.calculateTax({
subtotalCents: 10000, // $100.00
shippingAddress: {
city: 'San Francisco',
state: 'CA',
postalCode: '94102',
country: 'US',
},
});
expect(result).toBe(725); // $7.25
});
it('should return $0 tax for book tax-exempt states', async () => {
const result = await taxService.calculateTax({
subtotalCents: 10000,
shippingAddress: {
city: 'New York',
state: 'NY',
postalCode: '10001',
country: 'US',
},
});
expect(result).toBe(0); // Books are tax-exempt in NY
});
it('should round tax to nearest cent correctly', async () => {
const result = await taxService.calculateTax({
subtotalCents: 12345, // $123.45
shippingAddress: {
city: 'Los Angeles',
state: 'CA',
postalCode: '90001',
country: 'US',
},
});
expect(result).toBe(895); // $8.95 (rounded from $8.950125)
});
});
describe('isTaxable', () => {
it('should return true for books in taxable states', () => {
expect(taxService.isTaxable('CA', 'book')).toBe(true);
expect(taxService.isTaxable('TX', 'book')).toBe(true);
});
it('should return false for books in tax-exempt states', () => {
expect(taxService.isTaxable('PA', 'book')).toBe(false);
expect(taxService.isTaxable('NJ', 'book')).toBe(false);
expect(taxService.isTaxable('MA', 'book')).toBe(false);
});
});
});
Shipping Service Tests
// src/services/__tests__/shipping.service.test.ts
import { shippingService } from '../shipping.service';
describe('ShippingService', () => {
describe('calculateShipping', () => {
it('should return $0 for pickup fulfillment', async () => {
const result = await shippingService.calculateShipping({
items: [{ weight: 350, length: 20, width: 15, height: 2 }],
fromAddress: {
street1: '123 Main St',
city: 'San Francisco',
state: 'CA',
zip: '94102',
country: 'US',
},
toAddress: {
street1: '456 Oak Ave',
city: 'San Francisco',
state: 'CA',
zip: '94103',
country: 'US',
},
fulfillmentType: 'pickup',
});
expect(result.cents).toBe(0);
expect(result.carrier).toBe('pickup');
expect(result.service).toBe('pickup');
});
it('should use flat rate for ship fulfillment', async () => {
const result = await shippingService.calculateShipping({
items: [{ weight: 400, length: 20, width: 15, height: 2 }],
fromAddress: mockAddress,
toAddress: mockDestination,
fulfillmentType: 'ship',
});
expect(result.cents).toBe(399); // $3.99 for < 500g
expect(result.carrier).toBe('USPS');
expect(result.service).toBe('Media Mail');
});
it('should calculate total weight for multiple items', async () => {
const result = await shippingService.calculateShipping({
items: [
{ weight: 300, length: 20, width: 15, height: 2 },
{ weight: 300, length: 20, width: 15, height: 2 },
], // Total: 600g
fromAddress: mockAddress,
toAddress: mockDestination,
fulfillmentType: 'ship',
});
expect(result.cents).toBe(599); // $5.99 for 500-1000g total
});
});
describe('estimateBookWeight', () => {
it('should estimate paperback weight correctly', () => {
const weight = shippingService.estimateBookWeight(300, false);
expect(weight).toBe(350); // Base paperback weight
});
it('should estimate hardcover weight correctly', () => {
const weight = shippingService.estimateBookWeight(300, true);
expect(weight).toBe(600); // Base hardcover weight
});
it('should adjust for page count', () => {
const weight = shippingService.estimateBookWeight(500, false);
expect(weight).toBe(410); // 350 + (500-300) * 0.3
});
});
});
2. Controller Tests (Integration)
Controller tests verify API endpoints using supertest.
// src/controllers/__tests__/book.controller.test.ts
import request from 'supertest';
import { app } from '../../app';
import { prisma } from '../../config/database';
describe('Book Controller', () => {
beforeAll(async () => {
// Connect to test database
await prisma.$connect();
});
afterAll(async () => {
// Cleanup and disconnect
await prisma.$disconnect();
});
beforeEach(async () => {
// Clear test data
await prisma.book.deleteMany();
});
describe('GET /api/books/:id', () => {
it('should return book by ID', async () => {
// Arrange
const book = await prisma.book.create({
data: {
isbn13: '9781234567890',
title: 'Test Book',
authors: ['Test Author'],
},
});
// Act
const response = await request(app)
.get(`/api/books/${book.id}`)
.expect(200);
// Assert
expect(response.body.id).toBe(book.id);
expect(response.body.title).toBe('Test Book');
});
it('should return 404 for non-existent book', async () => {
await request(app)
.get('/api/books/nonexistent')
.expect(404);
});
});
describe('POST /api/books', () => {
it('should create a new book', async () => {
const bookData = {
isbn13: '9781234567890',
title: 'New Book',
authors: ['Author Name'],
};
const response = await request(app)
.post('/api/books')
.send(bookData)
.set('Authorization', `Bearer ${authToken}`)
.expect(201);
expect(response.body.title).toBe('New Book');
expect(response.body.id).toBeDefined();
});
it('should return 400 for invalid data', async () => {
await request(app)
.post('/api/books')
.send({ title: 'Invalid' }) // Missing required fields
.set('Authorization', `Bearer ${authToken}`)
.expect(400);
});
});
});
3. Middleware Tests
// src/middleware/__tests__/auth.middleware.test.ts
import { Request, Response } from 'express';
import { authMiddleware } from '../auth.middleware';
describe('Auth Middleware', () => {
let mockReq: Partial<Request>;
let mockRes: Partial<Response>;
let nextFunction: jest.Mock;
beforeEach(() => {
mockReq = {
headers: {},
};
mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
nextFunction = jest.fn();
});
it('should call next() for valid token', async () => {
mockReq.headers = {
authorization: 'Bearer valid_token',
};
await authMiddleware(
mockReq as Request,
mockRes as Response,
nextFunction
);
expect(nextFunction).toHaveBeenCalled();
});
it('should return 401 for missing token', async () => {
await authMiddleware(
mockReq as Request,
mockRes as Response,
nextFunction
);
expect(mockRes.status).toHaveBeenCalledWith(401);
expect(nextFunction).not.toHaveBeenCalled();
});
});
Mocking
Database Mocking
// Mock Prisma client
jest.mock('@prisma/client', () => {
const mockPrisma = {
book: {
findUnique: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
};
return { PrismaClient: jest.fn(() => mockPrisma) };
});
External API Mocking
// Mock external book API
jest.mock('../integrations/google-books', () => ({
searchBooks: jest.fn().mockResolvedValue([
{ isbn: '9781234567890', title: 'Mock Book' },
]),
}));
Redis Mocking
jest.mock('../config/redis', () => ({
redis: {
get: jest.fn(),
set: jest.fn(),
del: jest.fn(),
},
}));
Test Utilities
Test Database Setup
// src/__tests__/helpers/database.ts
import { PrismaClient } from '@prisma/client';
export const testDb = new PrismaClient({
datasources: {
db: {
url: process.env.TEST_DATABASE_URL,
},
},
});
export async function resetDatabase() {
await testDb.$executeRaw`TRUNCATE TABLE "Book" CASCADE`;
await testDb.$executeRaw`TRUNCATE TABLE "User" CASCADE`;
// ... other tables
}
export async function seedTestData() {
await testDb.book.createMany({
data: [
{ isbn13: '9781234567890', title: 'Test Book 1' },
{ isbn13: '9780987654321', title: 'Test Book 2' },
],
});
}
Test Factories
// src/__tests__/helpers/factories.ts
import { faker } from '@faker-js/faker';
export const bookFactory = {
build: (overrides = {}) => ({
isbn13: faker.string.numeric(13),
title: faker.lorem.words(3),
authors: [faker.person.fullName()],
publisher: faker.company.name(),
...overrides,
}),
};
export const userFactory = {
build: (overrides = {}) => ({
email: faker.internet.email(),
displayName: faker.person.fullName(),
tier: 'free',
...overrides,
}),
};
Auth Helpers
// src/__tests__/helpers/auth.ts
import jwt from 'jsonwebtoken';
export function generateTestToken(userId: string, tier: string = 'free') {
return jwt.sign(
{ userId, tier },
process.env.JWT_SECRET || 'test-secret',
{ expiresIn: '1h' }
);
}
export function createAuthHeader(token: string) {
return { Authorization: `Bearer ${token}` };
}
Running Tests
Run All Tests
npm test
Run Specific Test File
npm test -- tax.service.test.ts
Run with Coverage
npm test -- --coverage
Watch Mode
npm test -- --watch
Run Tests in CI
npm test -- --ci --coverage --maxWorkers=2
CI/CD Configuration
GitHub Actions
name: Backend Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: bookwish_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Run Prisma migrations
run: npx prisma migrate deploy
env:
DATABASE_URL: postgresql://test:test@localhost:5432/bookwish_test
- name: Run tests
run: npm test -- --coverage
env:
DATABASE_URL: postgresql://test:test@localhost:5432/bookwish_test
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
Current Test Coverage
The BookWish backend currently has:
- Tax Service: Full test coverage (11 tests)
- Shipping Service: Full test coverage (8 tests)
- Controllers: Minimal coverage
- Middleware: No tests yet
- Utils: No tests yet
Test Statistics
Service Tests:
Tax Service: 11 tests, 100% coverage
Shipping Service: 8 tests, 100% coverage
Total: 19 tests passing
Best Practices
1. Test Organization
describe('ServiceName', () => {
describe('methodName', () => {
it('should do X when Y', () => {});
it('should handle error when Z', () => {});
});
});
2. Arrange-Act-Assert Pattern
it('should calculate tax correctly', async () => {
// Arrange
const input = { subtotalCents: 10000, state: 'CA' };
// Act
const result = await taxService.calculateTax(input);
// Assert
expect(result).toBe(725);
});
3. Test Data Management
- Use factories for test data creation
- Clean up data after each test
- Use transactions for database tests when possible
4. Async Testing
it('should fetch data asynchronously', async () => {
const data = await service.fetchData();
expect(data).toBeDefined();
});
5. Error Testing
it('should throw error for invalid input', async () => {
await expect(service.processData(null)).rejects.toThrow('Invalid input');
});
Coverage Goals
- Services: 90%+ coverage
- Controllers: 80%+ coverage
- Middleware: 90%+ coverage
- Utils: 95%+ coverage
Future Improvements
-
Expand Controller Tests
- Test all API endpoints
- Verify authorization logic
- Test validation errors
-
Add Middleware Tests
- Rate limiting
- Authentication
- Error handling
-
Integration Tests
- End-to-end API flows
- Database transactions
- External service integrations
-
Performance Tests
- Load testing with Artillery or k6
- Database query optimization
- API response times
-
Contract Tests
- API contract testing with Pact
- Verify Flutter app compatibility
- Store website API contracts