Skip to main content

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

  1. Expand Controller Tests

    • Test all API endpoints
    • Verify authorization logic
    • Test validation errors
  2. Add Middleware Tests

    • Rate limiting
    • Authentication
    • Error handling
  3. Integration Tests

    • End-to-end API flows
    • Database transactions
    • External service integrations
  4. Performance Tests

    • Load testing with Artillery or k6
    • Database query optimization
    • API response times
  5. Contract Tests

    • API contract testing with Pact
    • Verify Flutter app compatibility
    • Store website API contracts