Build a URL Shortener: Testing Strategy

Your URL shortener works. URLs get shortened, redirects are fast, analytics get tracked, and users can log in. Ship it?
Not yet. Right now your only testing strategy is "click around in the browser and hope nothing breaks." That works until you refactor the redirect logic, accidentally break Base62 encoding, and every short link in production starts returning 404s. On a Friday evening.
In this post, we'll build a comprehensive testing strategy covering unit tests for isolated logic, integration tests for API flows, database tests with real PostgreSQL, and load tests to verify redirect performance under pressure.
Time commitment: 2–3 hours
Prerequisites: Phase 7: Frontend with React
What we'll build in this post:
✅ Vitest setup with TypeScript and path aliases
✅ Unit tests for Base62 encoding, URL validation, and short code generation
✅ Service-layer tests with mocked dependencies
✅ Integration tests with Supertest for full API flows
✅ Database tests with Docker test containers
✅ Redis mocking with ioredis-mock
✅ Load testing with k6 for redirect throughput
✅ Coverage configuration and CI pipeline
The Testing Pyramid
Before writing a single test, let's agree on the strategy. The testing pyramid tells us how many of each type to write:
For our URL shortener, that translates to:
| Layer | What We Test | Tool | Count |
|---|---|---|---|
| Unit | Base62 encoding, URL validation, short code generation, service logic | Vitest | ~30 tests |
| Integration | API endpoints, middleware, database queries, cache behavior | Vitest + Supertest | ~20 tests |
| Load | Redirect throughput, concurrent shortening, cache hit rates | k6 | ~5 scenarios |
We skip browser E2E tests for now — the React frontend is thin enough that API integration tests cover the critical paths.
Setting Up Vitest
Install testing dependencies:
npm install -D vitest @vitest/coverage-v8 supertest @types/supertest ioredis-mockWhy these packages:
- vitest — Fast, Vite-native test runner with TypeScript support out of the box
- @vitest/coverage-v8 — Code coverage via V8's built-in instrumentation
- supertest — HTTP assertions for Express apps without starting a server
- ioredis-mock — In-memory Redis mock that implements the ioredis API
Vitest Configuration
Create vitest.config.ts in the project root:
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
globals: true,
environment: 'node',
setupFiles: ['./tests/setup.ts'],
include: ['tests/**/*.test.ts'],
exclude: ['tests/load/**'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
include: ['src/**/*.ts'],
exclude: [
'src/index.ts',
'src/**/*.d.ts',
'src/types/**',
],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
testTimeout: 30000,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});Test Setup File
Create tests/setup.ts:
import { beforeAll, afterAll, afterEach } from 'vitest';
// Set test environment variables
process.env.NODE_ENV = 'test';
process.env.JWT_SECRET = 'test-secret-key';
process.env.BASE_URL = 'http://localhost:3000';
beforeAll(() => {
// Global test setup
});
afterEach(() => {
// Clean up mocks between tests
vi.restoreAllMocks();
});
afterAll(() => {
// Global teardown
});Update package.json Scripts
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui",
"test:load": "k6 run tests/load/redirect.js"
}
}Test Directory Structure
tests/
├── setup.ts # Global test setup
├── unit/
│ ├── base62.test.ts # Base62 encoding/decoding
│ ├── urlValidator.test.ts # URL validation logic
│ ├── shortCode.test.ts # Short code generation
│ └── services/
│ ├── urlService.test.ts # URL service with mocks
│ └── authService.test.ts # Auth service with mocks
├── integration/
│ ├── helpers.ts # Test app factory, DB helpers
│ ├── shorten.test.ts # POST /api/shorten
│ ├── redirect.test.ts # GET /:code
│ ├── analytics.test.ts # GET /api/urls/:code/stats
│ └── auth.test.ts # Auth endpoints
├── load/
│ ├── redirect.js # k6 redirect load test
│ └── shorten.js # k6 shortening load test
└── fixtures/
└── urls.ts # Test dataUnit Tests: Pure Functions
Unit tests are the foundation. They're fast, deterministic, and catch logic bugs early. Start with the pure functions — no dependencies, no mocks, just input and output.
Testing Base62 Encoding
// tests/unit/base62.test.ts
import { describe, it, expect } from 'vitest';
import { encode, decode } from '@/utils/base62';
describe('Base62', () => {
describe('encode', () => {
it('should encode 0 as "0"', () => {
expect(encode(0)).toBe('0');
});
it('should encode small numbers correctly', () => {
expect(encode(1)).toBe('1');
expect(encode(10)).toBe('a');
expect(encode(61)).toBe('Z');
});
it('should encode 62 as "10"', () => {
expect(encode(62)).toBe('10');
});
it('should encode large numbers', () => {
expect(encode(238328)).toBe('1000');
// 62^3 = 238328
});
it('should produce URL-safe characters only', () => {
const encoded = encode(999999999);
expect(encoded).toMatch(/^[0-9a-zA-Z]+$/);
});
it('should throw for negative numbers', () => {
expect(() => encode(-1)).toThrow();
});
});
describe('decode', () => {
it('should decode single characters', () => {
expect(decode('0')).toBe(0);
expect(decode('a')).toBe(10);
expect(decode('Z')).toBe(61);
});
it('should decode multi-character strings', () => {
expect(decode('10')).toBe(62);
expect(decode('1000')).toBe(238328);
});
it('should throw for invalid characters', () => {
expect(() => decode('hello!')).toThrow();
expect(() => decode('abc-def')).toThrow();
});
});
describe('encode-decode roundtrip', () => {
it('should roundtrip any positive integer', () => {
const testValues = [0, 1, 42, 100, 9999, 1000000, 2147483647];
for (const value of testValues) {
expect(decode(encode(value))).toBe(value);
}
});
});
});Testing URL Validation
// tests/unit/urlValidator.test.ts
import { describe, it, expect } from 'vitest';
import { isValidUrl, normalizeUrl } from '@/utils/urlValidator';
describe('URL Validator', () => {
describe('isValidUrl', () => {
it('should accept valid HTTP URLs', () => {
expect(isValidUrl('https://example.com')).toBe(true);
expect(isValidUrl('http://example.com')).toBe(true);
expect(isValidUrl('https://example.com/path?q=1')).toBe(true);
});
it('should accept URLs with ports', () => {
expect(isValidUrl('http://localhost:3000')).toBe(true);
expect(isValidUrl('https://example.com:8443/api')).toBe(true);
});
it('should reject non-HTTP protocols', () => {
expect(isValidUrl('ftp://example.com')).toBe(false);
expect(isValidUrl('javascript:alert(1)')).toBe(false);
expect(isValidUrl('data:text/html,<h1>Hi</h1>')).toBe(false);
});
it('should reject invalid URLs', () => {
expect(isValidUrl('')).toBe(false);
expect(isValidUrl('not a url')).toBe(false);
expect(isValidUrl('example.com')).toBe(false);
});
it('should reject URLs that point to the shortener itself', () => {
expect(isValidUrl('http://localhost:3000/abc123')).toBe(false);
});
});
describe('normalizeUrl', () => {
it('should remove trailing slashes', () => {
expect(normalizeUrl('https://example.com/')).toBe('https://example.com');
});
it('should lowercase the hostname', () => {
expect(normalizeUrl('https://EXAMPLE.COM/Path'))
.toBe('https://example.com/Path');
});
it('should preserve query parameters', () => {
expect(normalizeUrl('https://example.com/search?q=test'))
.toBe('https://example.com/search?q=test');
});
});
});Testing Short Code Generation
// tests/unit/shortCode.test.ts
import { describe, it, expect } from 'vitest';
import { generateShortCode, isValidShortCode } from '@/utils/shortCode';
describe('Short Code', () => {
describe('generateShortCode', () => {
it('should generate a code of specified length', () => {
const code = generateShortCode(7);
expect(code).toHaveLength(7);
});
it('should use default length of 7', () => {
const code = generateShortCode();
expect(code).toHaveLength(7);
});
it('should contain only URL-safe characters', () => {
for (let i = 0; i < 100; i++) {
const code = generateShortCode();
expect(code).toMatch(/^[0-9a-zA-Z]+$/);
}
});
it('should generate unique codes', () => {
const codes = new Set<string>();
for (let i = 0; i < 1000; i++) {
codes.add(generateShortCode());
}
// With 62^7 possible values, 1000 codes should all be unique
expect(codes.size).toBe(1000);
});
});
describe('isValidShortCode', () => {
it('should accept valid short codes', () => {
expect(isValidShortCode('abc123')).toBe(true);
expect(isValidShortCode('AbCdEfG')).toBe(true);
});
it('should reject codes that are too short', () => {
expect(isValidShortCode('ab')).toBe(false);
});
it('should reject codes that are too long', () => {
expect(isValidShortCode('a'.repeat(21))).toBe(false);
});
it('should reject codes with special characters', () => {
expect(isValidShortCode('abc-123')).toBe(false);
expect(isValidShortCode('abc_123')).toBe(false);
expect(isValidShortCode('abc 123')).toBe(false);
});
});
});Unit Tests: Services with Mocked Dependencies
Service-layer tests verify business logic without touching databases or caches. The key is mocking dependencies — inject fakes for Prisma, Redis, and external services.
Mocking Strategy
// tests/unit/services/urlService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UrlService } from '@/services/urlService';
// Create mock dependencies
const mockPrisma = {
url: {
create: vi.fn(),
findUnique: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
click: {
create: vi.fn(),
count: vi.fn(),
groupBy: vi.fn(),
},
};
const mockRedis = {
get: vi.fn(),
set: vi.fn(),
del: vi.fn(),
incr: vi.fn(),
};
describe('UrlService', () => {
let urlService: UrlService;
beforeEach(() => {
vi.clearAllMocks();
urlService = new UrlService(mockPrisma as any, mockRedis as any);
});
describe('shortenUrl', () => {
it('should create a shortened URL', async () => {
const mockUrl = {
id: '1',
originalUrl: 'https://example.com',
shortCode: 'abc1234',
clicks: 0,
createdAt: new Date(),
};
mockPrisma.url.findUnique.mockResolvedValue(null); // No collision
mockPrisma.url.create.mockResolvedValue(mockUrl);
mockRedis.set.mockResolvedValue('OK');
const result = await urlService.shortenUrl('https://example.com');
expect(result.shortCode).toBe('abc1234');
expect(mockPrisma.url.create).toHaveBeenCalledOnce();
expect(mockRedis.set).toHaveBeenCalledWith(
'url:abc1234',
'https://example.com',
'EX',
expect.any(Number)
);
});
it('should retry on short code collision', async () => {
const existingUrl = { id: '1', shortCode: 'abc1234' };
const newUrl = {
id: '2',
originalUrl: 'https://example.com',
shortCode: 'xyz7890',
clicks: 0,
createdAt: new Date(),
};
// First attempt: collision. Second attempt: success.
mockPrisma.url.findUnique
.mockResolvedValueOnce(existingUrl)
.mockResolvedValueOnce(null);
mockPrisma.url.create.mockResolvedValue(newUrl);
mockRedis.set.mockResolvedValue('OK');
const result = await urlService.shortenUrl('https://example.com');
expect(result.shortCode).toBe('xyz7890');
expect(mockPrisma.url.findUnique).toHaveBeenCalledTimes(2);
});
it('should reject invalid URLs', async () => {
await expect(
urlService.shortenUrl('not-a-valid-url')
).rejects.toThrow('Invalid URL');
expect(mockPrisma.url.create).not.toHaveBeenCalled();
});
it('should use custom alias when provided', async () => {
const mockUrl = {
id: '1',
originalUrl: 'https://example.com',
shortCode: 'my-link',
clicks: 0,
createdAt: new Date(),
};
mockPrisma.url.findUnique.mockResolvedValue(null);
mockPrisma.url.create.mockResolvedValue(mockUrl);
mockRedis.set.mockResolvedValue('OK');
const result = await urlService.shortenUrl(
'https://example.com',
{ customAlias: 'my-link' }
);
expect(result.shortCode).toBe('my-link');
});
it('should reject duplicate custom alias', async () => {
mockPrisma.url.findUnique.mockResolvedValue({ shortCode: 'taken' });
await expect(
urlService.shortenUrl('https://example.com', { customAlias: 'taken' })
).rejects.toThrow('Alias already taken');
});
});
describe('resolveUrl', () => {
it('should return cached URL on Redis hit', async () => {
mockRedis.get.mockResolvedValue('https://example.com');
const result = await urlService.resolveUrl('abc1234');
expect(result).toBe('https://example.com');
expect(mockPrisma.url.findUnique).not.toHaveBeenCalled();
});
it('should fall back to database on Redis miss', async () => {
mockRedis.get.mockResolvedValue(null);
mockPrisma.url.findUnique.mockResolvedValue({
originalUrl: 'https://example.com',
shortCode: 'abc1234',
});
mockRedis.set.mockResolvedValue('OK');
const result = await urlService.resolveUrl('abc1234');
expect(result).toBe('https://example.com');
expect(mockRedis.set).toHaveBeenCalled(); // Re-cached
});
it('should return null for unknown short code', async () => {
mockRedis.get.mockResolvedValue(null);
mockPrisma.url.findUnique.mockResolvedValue(null);
const result = await urlService.resolveUrl('unknown');
expect(result).toBeNull();
});
it('should not return expired URLs', async () => {
mockRedis.get.mockResolvedValue(null);
mockPrisma.url.findUnique.mockResolvedValue({
originalUrl: 'https://example.com',
shortCode: 'abc1234',
expiresAt: new Date('2020-01-01'), // Expired
});
const result = await urlService.resolveUrl('abc1234');
expect(result).toBeNull();
});
});
describe('recordClick', () => {
it('should record click analytics', async () => {
mockPrisma.click.create.mockResolvedValue({ id: '1' });
await urlService.recordClick('abc1234', {
ip: '127.0.0.1',
userAgent: 'Mozilla/5.0',
referer: 'https://twitter.com',
});
expect(mockPrisma.click.create).toHaveBeenCalledWith({
data: expect.objectContaining({
urlShortCode: 'abc1234',
ip: '127.0.0.1',
userAgent: 'Mozilla/5.0',
referer: 'https://twitter.com',
}),
});
});
});
});Testing Auth Service
// tests/unit/services/authService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AuthService } from '@/services/authService';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
const mockPrisma = {
user: {
create: vi.fn(),
findUnique: vi.fn(),
},
};
describe('AuthService', () => {
let authService: AuthService;
beforeEach(() => {
vi.clearAllMocks();
authService = new AuthService(mockPrisma as any);
});
describe('register', () => {
it('should hash password before storing', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null);
mockPrisma.user.create.mockResolvedValue({
id: '1',
email: 'user@example.com',
});
await authService.register('user@example.com', 'password123');
const createCall = mockPrisma.user.create.mock.calls[0][0];
expect(createCall.data.password).not.toBe('password123');
// Verify it's a valid bcrypt hash
const isValid = await bcrypt.compare(
'password123',
createCall.data.password
);
expect(isValid).toBe(true);
});
it('should reject duplicate emails', async () => {
mockPrisma.user.findUnique.mockResolvedValue({
id: '1',
email: 'user@example.com',
});
await expect(
authService.register('user@example.com', 'password123')
).rejects.toThrow('Email already registered');
});
});
describe('login', () => {
it('should return JWT token on valid credentials', async () => {
const hashedPassword = await bcrypt.hash('password123', 10);
mockPrisma.user.findUnique.mockResolvedValue({
id: '1',
email: 'user@example.com',
password: hashedPassword,
});
const token = await authService.login(
'user@example.com',
'password123'
);
expect(token).toBeDefined();
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as any;
expect(decoded.userId).toBe('1');
});
it('should reject invalid password', async () => {
const hashedPassword = await bcrypt.hash('password123', 10);
mockPrisma.user.findUnique.mockResolvedValue({
id: '1',
email: 'user@example.com',
password: hashedPassword,
});
await expect(
authService.login('user@example.com', 'wrong-password')
).rejects.toThrow('Invalid credentials');
});
it('should reject non-existent user', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null);
await expect(
authService.login('nobody@example.com', 'password123')
).rejects.toThrow('Invalid credentials');
});
});
});Integration Tests: API Endpoints with Supertest
Integration tests verify that your Express routes, middleware, validation, and database queries work together. Supertest lets you send HTTP requests to your Express app without starting a real server.
Test App Factory
Create a helper that builds a fresh Express app for each test:
// tests/integration/helpers.ts
import { createApp } from '@/app';
import { PrismaClient } from '@prisma/client';
import Redis from 'ioredis-mock';
let prisma: PrismaClient;
export async function createTestApp() {
// Use a test database (configured via DATABASE_URL in test env)
prisma = new PrismaClient({
datasourceUrl: process.env.TEST_DATABASE_URL,
});
const redis = new Redis();
const app = createApp({ prisma, redis });
return { app, prisma, redis };
}
export async function cleanDatabase() {
// Delete in order to respect foreign key constraints
await prisma.click.deleteMany();
await prisma.url.deleteMany();
await prisma.user.deleteMany();
}
export async function teardown() {
await prisma.$disconnect();
}Testing the Shorten Endpoint
// tests/integration/shorten.test.ts
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import { createTestApp, cleanDatabase, teardown } from './helpers';
import type { Express } from 'express';
describe('POST /api/shorten', () => {
let app: Express;
beforeAll(async () => {
const testApp = await createTestApp();
app = testApp.app;
});
beforeEach(async () => {
await cleanDatabase();
});
afterAll(async () => {
await teardown();
});
it('should shorten a valid URL', async () => {
const response = await request(app)
.post('/api/shorten')
.send({ url: 'https://example.com/very-long-path' })
.expect(201);
expect(response.body).toMatchObject({
success: true,
data: {
originalUrl: 'https://example.com/very-long-path',
shortCode: expect.any(String),
shortUrl: expect.stringContaining('http'),
},
});
expect(response.body.data.shortCode).toHaveLength(7);
});
it('should accept custom alias', async () => {
const response = await request(app)
.post('/api/shorten')
.send({
url: 'https://example.com',
customAlias: 'my-link',
})
.expect(201);
expect(response.body.data.shortCode).toBe('my-link');
});
it('should reject duplicate custom alias', async () => {
// Create first URL with alias
await request(app)
.post('/api/shorten')
.send({ url: 'https://example.com', customAlias: 'taken' })
.expect(201);
// Try same alias again
const response = await request(app)
.post('/api/shorten')
.send({ url: 'https://other.com', customAlias: 'taken' })
.expect(409);
expect(response.body.error).toContain('already taken');
});
it('should reject invalid URLs', async () => {
const invalidUrls = [
'',
'not-a-url',
'ftp://example.com',
'javascript:alert(1)',
];
for (const url of invalidUrls) {
await request(app)
.post('/api/shorten')
.send({ url })
.expect(400);
}
});
it('should reject missing URL field', async () => {
await request(app)
.post('/api/shorten')
.send({})
.expect(400);
});
it('should accept expiration date', async () => {
const expiresAt = new Date(Date.now() + 86400000).toISOString(); // +24h
const response = await request(app)
.post('/api/shorten')
.send({
url: 'https://example.com',
expiresAt,
})
.expect(201);
expect(response.body.data.expiresAt).toBeDefined();
});
it('should require auth for bulk shortening', async () => {
await request(app)
.post('/api/shorten/bulk')
.send({
urls: ['https://example.com', 'https://other.com'],
})
.expect(401);
});
});Testing the Redirect Endpoint
// tests/integration/redirect.test.ts
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import { createTestApp, cleanDatabase, teardown } from './helpers';
import type { Express } from 'express';
describe('GET /:code (Redirect)', () => {
let app: Express;
beforeAll(async () => {
const testApp = await createTestApp();
app = testApp.app;
});
beforeEach(async () => {
await cleanDatabase();
});
afterAll(async () => {
await teardown();
});
it('should redirect to original URL', async () => {
// Create a short URL first
const createRes = await request(app)
.post('/api/shorten')
.send({ url: 'https://example.com/target' });
const { shortCode } = createRes.body.data;
// Follow the redirect
const response = await request(app)
.get(`/${shortCode}`)
.expect(302);
expect(response.headers.location).toBe('https://example.com/target');
});
it('should return 404 for unknown codes', async () => {
await request(app)
.get('/nonexistent')
.expect(404);
});
it('should return 410 for expired URLs', async () => {
// Create a URL that expires immediately
const createRes = await request(app)
.post('/api/shorten')
.send({
url: 'https://example.com',
expiresAt: new Date(Date.now() - 1000).toISOString(), // Already expired
});
const { shortCode } = createRes.body.data;
await request(app)
.get(`/${shortCode}`)
.expect(410); // Gone
});
it('should track click on redirect', async () => {
const createRes = await request(app)
.post('/api/shorten')
.send({ url: 'https://example.com' });
const { shortCode } = createRes.body.data;
// Visit the redirect
await request(app)
.get(`/${shortCode}`)
.set('User-Agent', 'TestBot/1.0')
.set('Referer', 'https://twitter.com')
.expect(302);
// Check analytics
const statsRes = await request(app)
.get(`/api/urls/${shortCode}/stats`)
.expect(200);
expect(statsRes.body.data.totalClicks).toBe(1);
});
});Testing Auth Endpoints
// tests/integration/auth.test.ts
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import { createTestApp, cleanDatabase, teardown } from './helpers';
import type { Express } from 'express';
describe('Auth Endpoints', () => {
let app: Express;
beforeAll(async () => {
const testApp = await createTestApp();
app = testApp.app;
});
beforeEach(async () => {
await cleanDatabase();
});
afterAll(async () => {
await teardown();
});
describe('POST /api/auth/register', () => {
it('should register a new user', async () => {
const response = await request(app)
.post('/api/auth/register')
.send({
email: 'newuser@example.com',
password: 'securePassword123',
})
.expect(201);
expect(response.body.data.token).toBeDefined();
expect(response.body.data.user.email).toBe('newuser@example.com');
// Password should never be in response
expect(response.body.data.user.password).toBeUndefined();
});
it('should reject weak passwords', async () => {
await request(app)
.post('/api/auth/register')
.send({
email: 'user@example.com',
password: '123',
})
.expect(400);
});
});
describe('POST /api/auth/login', () => {
it('should return JWT on valid credentials', async () => {
// Register first
await request(app)
.post('/api/auth/register')
.send({ email: 'user@example.com', password: 'password123' });
// Login
const response = await request(app)
.post('/api/auth/login')
.send({ email: 'user@example.com', password: 'password123' })
.expect(200);
expect(response.body.data.token).toBeDefined();
});
});
describe('Authenticated routes', () => {
it('should allow access with valid token', async () => {
// Register and get token
const registerRes = await request(app)
.post('/api/auth/register')
.send({ email: 'user@example.com', password: 'password123' });
const token = registerRes.body.data.token;
// Access protected route
const response = await request(app)
.get('/api/urls/my')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(response.body.data).toBeInstanceOf(Array);
});
it('should reject expired tokens', async () => {
// Create an expired token manually
const jwt = await import('jsonwebtoken');
const expiredToken = jwt.sign(
{ userId: '1' },
process.env.JWT_SECRET!,
{ expiresIn: '0s' }
);
await request(app)
.get('/api/urls/my')
.set('Authorization', `Bearer ${expiredToken}`)
.expect(401);
});
});
});Database Testing with Test Containers
Real database tests catch issues that mocks miss: constraint violations, index behavior, migration problems. Use Docker to spin up a throwaway PostgreSQL for tests.
Docker Test Setup
Create docker-compose.test.yml:
version: '3.8'
services:
test-db:
image: postgres:16-alpine
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: urlshortener_test
ports:
- '5433:5432'
tmpfs:
- /var/lib/postgresql/data # RAM-backed for speedTest Database Helper
// tests/integration/testDb.ts
import { execSync } from 'child_process';
import { PrismaClient } from '@prisma/client';
const TEST_DATABASE_URL =
'postgresql://test:test@localhost:5433/urlshortener_test';
export async function setupTestDatabase() {
// Start test database container
execSync('docker compose -f docker-compose.test.yml up -d', {
stdio: 'inherit',
});
// Wait for PostgreSQL to be ready
let retries = 10;
while (retries > 0) {
try {
const prisma = new PrismaClient({
datasourceUrl: TEST_DATABASE_URL,
});
await prisma.$connect();
await prisma.$disconnect();
break;
} catch {
retries--;
await new Promise((r) => setTimeout(r, 1000));
}
}
if (retries === 0) {
throw new Error('Test database failed to start');
}
// Run migrations
execSync('npx prisma migrate deploy', {
env: { ...process.env, DATABASE_URL: TEST_DATABASE_URL },
stdio: 'inherit',
});
return TEST_DATABASE_URL;
}
export async function teardownTestDatabase() {
execSync('docker compose -f docker-compose.test.yml down -v', {
stdio: 'inherit',
});
}Global Setup with Vitest
// tests/globalSetup.ts
import { setupTestDatabase, teardownTestDatabase } from './integration/testDb';
export async function setup() {
const dbUrl = await setupTestDatabase();
process.env.TEST_DATABASE_URL = dbUrl;
}
export async function teardown() {
await teardownTestDatabase();
}Update vitest.config.ts to use the global setup:
export default defineConfig({
test: {
// ...existing config
globalSetup: ['./tests/globalSetup.ts'],
},
});Mocking Redis with ioredis-mock
ioredis-mock provides an in-memory Redis implementation that supports most ioredis commands. It's perfect for tests that need cache behavior without a running Redis server.
// tests/integration/cache.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import Redis from 'ioredis-mock';
import { CacheService } from '@/services/cacheService';
describe('CacheService', () => {
let redis: InstanceType<typeof Redis>;
let cacheService: CacheService;
beforeEach(() => {
redis = new Redis();
cacheService = new CacheService(redis);
});
describe('URL caching', () => {
it('should cache URL with TTL', async () => {
await cacheService.cacheUrl('abc1234', 'https://example.com', 3600);
const cached = await redis.get('url:abc1234');
expect(cached).toBe('https://example.com');
const ttl = await redis.ttl('url:abc1234');
expect(ttl).toBeGreaterThan(0);
expect(ttl).toBeLessThanOrEqual(3600);
});
it('should return null for uncached URLs', async () => {
const result = await cacheService.getCachedUrl('nonexistent');
expect(result).toBeNull();
});
it('should invalidate cache on URL update', async () => {
await cacheService.cacheUrl('abc1234', 'https://old.com', 3600);
await cacheService.invalidateUrl('abc1234');
const cached = await redis.get('url:abc1234');
expect(cached).toBeNull();
});
});
describe('Rate limiting', () => {
it('should track request counts', async () => {
const key = 'ratelimit:127.0.0.1';
for (let i = 0; i < 5; i++) {
await cacheService.incrementRateLimit(key, 60);
}
const count = await redis.get(key);
expect(Number(count)).toBe(5);
});
it('should respect window expiration', async () => {
const key = 'ratelimit:127.0.0.1';
await cacheService.incrementRateLimit(key, 60);
const ttl = await redis.ttl(key);
expect(ttl).toBeGreaterThan(0);
expect(ttl).toBeLessThanOrEqual(60);
});
});
});Why not mock Redis manually? ioredis-mock implements the actual Redis command semantics (TTL, expiration, atomicity). Manual mocks with
vi.fn()would miss subtle bugs like "you forgot to set the TTL" or "INCR on a non-existent key should return 1."
Test Fixtures and Factories
Keep test data organized with factories:
// tests/fixtures/urls.ts
export const validUrls = [
'https://example.com',
'https://example.com/path/to/page?q=search&lang=en',
'http://subdomain.example.com:8080/api/v1/users',
'https://example.com/path#section-heading',
];
export const invalidUrls = [
'',
'not-a-url',
'ftp://example.com',
'javascript:alert(1)',
'data:text/html,<script>alert(1)</script>',
];
export function createUrlFixture(overrides = {}) {
return {
id: 'test-id-1',
originalUrl: 'https://example.com/test',
shortCode: 'abc1234',
clicks: 0,
userId: null,
expiresAt: null,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
...overrides,
};
}
export function createUserFixture(overrides = {}) {
return {
id: 'user-id-1',
email: 'test@example.com',
password: '$2b$10$hashedpassword',
createdAt: new Date('2026-01-01'),
...overrides,
};
}
export function createClickFixture(overrides = {}) {
return {
id: 'click-id-1',
urlShortCode: 'abc1234',
ip: '127.0.0.1',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
referer: 'https://twitter.com',
country: 'US',
city: 'San Francisco',
device: 'desktop',
browser: 'Chrome',
createdAt: new Date('2026-01-01'),
...overrides,
};
}Load Testing with k6
Unit and integration tests verify correctness. Load tests verify performance. Will your redirect engine handle 10,000 requests per second? Let's find out.
Install k6
# macOS
brew install k6
# Linux
sudo gpg -k
sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg \
--keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D68
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" \
| sudo tee /etc/apt/sources.list.d/k6.list
sudo apt-get update && sudo apt-get install k6
# Docker
docker run --rm -i grafana/k6 run -Redirect Load Test
// tests/load/redirect.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
// Custom metrics
const redirectSuccess = new Rate('redirect_success');
const redirectDuration = new Trend('redirect_duration', true);
export const options = {
scenarios: {
// Ramp up to 500 concurrent users
redirect_load: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '30s', target: 100 }, // Ramp up
{ duration: '1m', target: 500 }, // Peak load
{ duration: '30s', target: 0 }, // Ramp down
],
gracefulRampDown: '10s',
},
},
thresholds: {
// 95% of redirects should complete within 100ms
redirect_duration: ['p(95)<100'],
// 99.9% success rate
redirect_success: ['rate>0.999'],
// Overall HTTP failures under 0.1%
http_req_failed: ['rate<0.001'],
},
};
// Pre-created short codes (seed these before running the test)
const SHORT_CODES = [
'test001', 'test002', 'test003', 'test004', 'test005',
'test006', 'test007', 'test008', 'test009', 'test010',
];
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
export default function () {
// Pick a random short code
const code = SHORT_CODES[Math.floor(Math.random() * SHORT_CODES.length)];
const response = http.get(`${BASE_URL}/${code}`, {
redirects: 0, // Don't follow redirects — we're testing the redirect response
});
const success = check(response, {
'status is 302': (r) => r.status === 302,
'has Location header': (r) => r.headers['Location'] !== undefined,
'response time < 100ms': (r) => r.timings.duration < 100,
});
redirectSuccess.add(success);
redirectDuration.add(response.timings.duration);
sleep(0.1); // 100ms between requests per VU
}
export function handleSummary(data) {
return {
'tests/load/results/redirect-summary.json': JSON.stringify(data, null, 2),
stdout: textSummary(data, { indent: ' ', enableColors: true }),
};
}URL Shortening Load Test
// tests/load/shorten.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate } from 'k6/metrics';
const shortenSuccess = new Rate('shorten_success');
export const options = {
scenarios: {
// Simulate concurrent URL creation
concurrent_shorten: {
executor: 'constant-arrival-rate',
rate: 100, // 100 requests per second
timeUnit: '1s',
duration: '1m',
preAllocatedVUs: 50,
maxVUs: 200,
},
},
thresholds: {
http_req_duration: ['p(95)<500'], // 95th percentile under 500ms
shorten_success: ['rate>0.99'], // 99% success
},
};
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
let counter = 0;
export default function () {
counter++;
const payload = JSON.stringify({
url: `https://example.com/page/${counter}/${Date.now()}`,
});
const response = http.post(`${BASE_URL}/api/shorten`, payload, {
headers: { 'Content-Type': 'application/json' },
});
const success = check(response, {
'status is 201': (r) => r.status === 201,
'has shortCode': (r) => {
try {
return JSON.parse(r.body).data.shortCode !== undefined;
} catch {
return false;
}
},
'response time < 500ms': (r) => r.timings.duration < 500,
});
shortenSuccess.add(success);
sleep(0.01);
}Seeding Test Data for Load Tests
Create a script to seed short URLs before running load tests:
// tests/load/seed.ts
import { PrismaClient } from '@prisma/client';
async function seed() {
const prisma = new PrismaClient();
const urls = Array.from({ length: 10 }, (_, i) => ({
shortCode: `test${String(i + 1).padStart(3, '0')}`,
originalUrl: `https://example.com/page-${i + 1}`,
clicks: 0,
}));
for (const url of urls) {
await prisma.url.upsert({
where: { shortCode: url.shortCode },
update: {},
create: url,
});
}
console.log(`Seeded ${urls.length} test URLs`);
await prisma.$disconnect();
}
seed();Running Load Tests
# 1. Start the app
npm run start
# 2. Seed test data
npx tsx tests/load/seed.ts
# 3. Run redirect load test
k6 run tests/load/redirect.js
# 4. Run shortening load test
k6 run tests/load/shorten.js
# 5. Run with custom base URL
k6 run -e BASE_URL=https://staging.myshortener.com tests/load/redirect.jsInterpreting k6 Results
scenarios: (100.00%) 1 scenario, 500 max VUs, 2m30s max duration
✓ redirect_load
✓ status is 302............: 99.95% ✓ 149925 ✗ 75
✓ has Location header......: 99.95% ✓ 149925 ✗ 75
✓ response time < 100ms...: 97.20% ✓ 145725 ✗ 4200
redirect_duration...........: avg=12.3ms min=1.2ms p(50)=8.5ms p(95)=45.2ms p(99)=89.1ms
redirect_success............: 99.95%
http_req_duration...........: avg=12.5ms min=1.2ms p(50)=8.7ms p(95)=45.8ms p(99)=90.3ms
http_reqs...................: 150000 1000.0/sKey metrics to watch:
- p(95) < 100ms: Your 95th percentile redirect latency
- http_reqs/s: Throughput — how many redirects per second
- redirect_success rate: Should be > 99.9%
- http_req_failed rate: Should be < 0.1%
Coverage Configuration
Track which code paths your tests exercise:
# Run tests with coverage
npm run test:coverageThis generates a coverage report in coverage/:
-----------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
-----------------------|---------|----------|---------|---------|
All files | 87.5 | 82.3 | 91.2 | 87.5 |
src/services | 92.1 | 88.4 | 95.0 | 92.1 |
urlService.ts | 94.3 | 90.1 | 100.0 | 94.3 |
authService.ts | 89.2 | 85.7 | 88.9 | 89.2 |
cacheService.ts | 93.1 | 90.0 | 100.0 | 93.1 |
src/utils | 100.0 | 100.0 | 100.0 | 100.0 |
base62.ts | 100.0 | 100.0 | 100.0 | 100.0 |
urlValidator.ts | 100.0 | 100.0 | 100.0 | 100.0 |
shortCode.ts | 100.0 | 100.0 | 100.0 | 100.0 |
src/routes | 78.5 | 72.3 | 80.0 | 78.5 |
-----------------------|---------|----------|---------|---------|Coverage Targets
Set realistic thresholds in vitest.config.ts:
| Metric | Target | Why |
|---|---|---|
| Statements | 80% | Most code paths covered |
| Branches | 80% | Error handling and edge cases |
| Functions | 80% | No dead code |
| Lines | 80% | Comprehensive execution |
Don't chase 100%. Covering generated Prisma client code, type definitions, and server bootstrapping adds test maintenance cost without catching real bugs. Focus on business logic.
CI Integration: GitHub Actions
Automate test execution on every push and pull request:
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run unit tests
run: npm run test -- --reporter=verbose
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: coverage/lcov.info
fail_ci_if_error: false
integration-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: urlshortener_test
ports:
- 5433:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
TEST_DATABASE_URL: postgresql://test:test@localhost:5433/urlshortener_test
JWT_SECRET: test-secret-for-ci
NODE_ENV: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run database migrations
run: npx prisma migrate deploy
- name: Run integration tests
run: npm run test -- --reporter=verbose tests/integration/
load-tests:
runs-on: ubuntu-latest
needs: [unit-tests, integration-tests]
if: github.ref == 'refs/heads/main' # Only on main branch
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: urlshortener_test
ports:
- 5433:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql://test:test@localhost:5433/urlshortener_test
REDIS_URL: redis://localhost:6379
JWT_SECRET: test-secret-for-ci
NODE_ENV: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- uses: grafana/setup-k6-action@v1
- run: npm ci
- run: npx prisma migrate deploy
- name: Start server
run: npm run start &
- name: Wait for server
run: npx wait-on http://localhost:3000 --timeout 30000
- name: Seed test data
run: npx tsx tests/load/seed.ts
- name: Run load tests
run: k6 run tests/load/redirect.js
- name: Upload results
uses: actions/upload-artifact@v4
with:
name: load-test-results
path: tests/load/results/Pipeline Flow
Common Testing Mistakes
Watch out for these patterns that make tests fragile or useless:
1. Testing Implementation, Not Behavior
// ❌ Bad: Testing internal implementation details
it('should call Redis.set with exact arguments', () => {
expect(mockRedis.set).toHaveBeenCalledWith(
'url:abc1234',
'https://example.com',
'EX',
3600
);
});
// ✅ Good: Testing observable behavior
it('should cache the URL for future lookups', async () => {
await urlService.shortenUrl('https://example.com');
// Verify the URL is retrievable (behavior, not implementation)
const cached = await urlService.resolveUrl('abc1234');
expect(cached).toBe('https://example.com');
});2. Shared Mutable State Between Tests
// ❌ Bad: Tests share state and depend on execution order
const urls: string[] = [];
it('should add a URL', () => {
urls.push('https://example.com');
expect(urls).toHaveLength(1);
});
it('should be empty', () => {
expect(urls).toHaveLength(0); // FAILS — previous test mutated shared state
});
// ✅ Good: Fresh state for each test
beforeEach(() => {
vi.clearAllMocks();
// Reset database / clear cache
});3. Not Testing Error Paths
// ❌ Bad: Only testing the happy path
describe('shortenUrl', () => {
it('should create a short URL', async () => { /* ... */ });
// Done? No!
});
// ✅ Good: Error paths are where most production bugs live
describe('shortenUrl', () => {
it('should create a short URL', async () => { /* ... */ });
it('should reject invalid URLs', async () => { /* ... */ });
it('should handle database connection failures', async () => { /* ... */ });
it('should retry on collision', async () => { /* ... */ });
it('should fail after max collision retries', async () => { /* ... */ });
it('should handle Redis connection failures gracefully', async () => { /* ... */ });
});4. Ignoring Async Edge Cases
// ❌ Bad: Missing await — test always passes even if assertion fails
it('should reject invalid URL', () => {
expect(urlService.shortenUrl('invalid')).rejects.toThrow();
// This test passes regardless because the promise rejection is never awaited
});
// ✅ Good: Always await async assertions
it('should reject invalid URL', async () => {
await expect(urlService.shortenUrl('invalid')).rejects.toThrow('Invalid URL');
});5. Flaky Time-Dependent Tests
// ❌ Bad: Depends on exact timing
it('should expire after 1 second', async () => {
await cacheService.set('key', 'value', 1); // 1 second TTL
await new Promise(r => setTimeout(r, 1000));
expect(await cacheService.get('key')).toBeNull(); // Flaky!
});
// ✅ Good: Mock time instead
it('should expire after TTL', async () => {
vi.useFakeTimers();
await cacheService.set('key', 'value', 1);
vi.advanceTimersByTime(1500);
expect(await cacheService.get('key')).toBeNull();
vi.useRealTimers();
});Running Your Test Suite
Here's the complete workflow:
# Run all unit tests
npm test
# Run with watch mode during development
npm run test:watch
# Run specific test file
npx vitest run tests/unit/base62.test.ts
# Run tests matching a pattern
npx vitest run --reporter=verbose -t "should reject invalid"
# Run with coverage report
npm run test:coverage
# Run integration tests only (requires test database)
docker compose -f docker-compose.test.yml up -d
npx vitest run tests/integration/
# Run load tests
k6 run tests/load/redirect.js
k6 run tests/load/shorten.jsTest Output Example
✓ tests/unit/base62.test.ts (10 tests) 3ms
✓ tests/unit/urlValidator.test.ts (9 tests) 2ms
✓ tests/unit/shortCode.test.ts (6 tests) 5ms
✓ tests/unit/services/urlService.test.ts (8 tests) 15ms
✓ tests/unit/services/authService.test.ts (5 tests) 12ms
✓ tests/integration/shorten.test.ts (7 tests) 245ms
✓ tests/integration/redirect.test.ts (4 tests) 189ms
✓ tests/integration/auth.test.ts (5 tests) 312ms
✓ tests/integration/cache.test.ts (5 tests) 8ms
Test Files 9 passed (9)
Tests 59 passed (59)
Start at 10:30:15
Duration 1.24sWhat's Next
Your URL shortener now has a solid testing strategy: unit tests catch logic bugs fast, integration tests verify API contracts, and load tests prove your redirect engine can handle production traffic. Next up, we'll deploy everything to production.
In Phase 9: Deployment & Production, we'll cover:
- Docker multi-stage builds for production
- Environment configuration and secrets management
- CI/CD pipeline with GitHub Actions
- Health checks and monitoring setup
- SSL/TLS and reverse proxy configuration
Series: Build a URL Shortener
Previous: Phase 7: Frontend with React
Next: Phase 9: Deployment & Production
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.