Back to blog

Build a URL Shortener: Testing Strategy

typescriptnodejstestingvitestbackend
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:

LayerWhat We TestToolCount
UnitBase62 encoding, URL validation, short code generation, service logicVitest~30 tests
IntegrationAPI endpoints, middleware, database queries, cache behaviorVitest + Supertest~20 tests
LoadRedirect throughput, concurrent shortening, cache hit ratesk6~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-mock

Why 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 data

Unit 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 speed

Test 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.js

Interpreting 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/s

Key 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:coverage

This 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:

MetricTargetWhy
Statements80%Most code paths covered
Branches80%Error handling and edge cases
Functions80%No dead code
Lines80%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.js

Test 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.24s

What'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.