URL Shortener Tutorial · Module 03 of 11

Testing Discipline

By the end of this tutorial you'll have a fast unit suite, integration tests against a throwaway Postgres, a TDD-built custom-alias feature, and a coverage gate that fails the build below 70%.

~3–5 hrsVitestTestcontainersTDD
← Back to Module 03 overview
Before You Start

Prerequisites

  • Modules 01 and 02 complete: working server + Postgres in Docker.
  • Docker still running (we'll use Testcontainers).
What You'll Have at the End

Definition of Done

  • npm test runs unit tests in under 30s.
  • npm run test:integration spins up Postgres in a container, runs migrations, exercises the API.
  • A POST /shorten with { "alias": "..." } creates a vanity short code, built TDD-style.
  • Coverage threshold of 70% lines / branches enforced.
The Steps

Build It

STEP 1

Install Vitest and friends

npm install -D vitest @vitest/coverage-v8 supertest @types/supertest \
              testcontainers

Add scripts:

npm pkg set scripts.test="vitest run --coverage"
npm pkg set scripts.test:watch="vitest"
npm pkg set scripts.test:integration="vitest run -c vitest.integration.ts"
STEP 2

Configure Vitest with a coverage gate

Create vitest.config.ts:

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    include: ['src/**/*.test.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
      thresholds: { lines: 70, branches: 70, functions: 70, statements: 70 }
    }
  }
});
STEP 3

Write unit tests for the encoder

Create src/code.test.ts:

import { describe, it, expect } from 'vitest';
import { randomCode } from './code.js';

describe('randomCode', () => {
  it('returns the requested length', () => {
    expect(randomCode(7)).toHaveLength(7);
    expect(randomCode(3)).toHaveLength(3);
  });

  it('only uses base62 characters', () => {
    const re = /^[0-9a-zA-Z]+$/;
    for (let i = 0; i < 100; i++) expect(randomCode(7)).toMatch(re);
  });

  it('does not collide trivially', () => {
    const seen = new Set<string>();
    for (let i = 0; i < 1000; i++) seen.add(randomCode(7));
    expect(seen.size).toBeGreaterThan(990);
  });
});
npm test
✓ Verify: 3 passing tests, coverage report in the terminal.
STEP 4

Refactor for testability — extract the app

Split src/server.ts into src/app.ts (returns the Express app) and a thin src/server.ts that just listens. This lets tests import the app without binding a port.

// src/app.ts
import express from 'express';
import { pool } from './db.js';
import { randomCode } from './code.js';

export function createApp(base: string) {
  const app = express();
  app.use(express.json());

  app.get('/health', (_req, res) => res.json({ ok: true }));

  app.post('/shorten', async (req, res) => { /* …same as before… */ });
  app.get   ('/:code', async (req, res) => { /* …same as before… */ });
  app.delete('/:code', async (req, res) => { /* …same as before… */ });

  return app;
}
// src/server.ts
import 'dotenv/config';
import { createApp } from './app.js';

const port = Number(process.env.PORT ?? 3000);
const base = process.env.BASE_URL ?? `http://localhost:${port}`;
createApp(base).listen(port, () => console.log(`listening on :${port}`));
STEP 5

Set up integration tests with Testcontainers

Create vitest.integration.ts:

import { defineConfig } from 'vitest/config';
export default defineConfig({
  test: { include: ['tests/**/*.test.ts'], testTimeout: 60_000, hookTimeout: 60_000 }
});

Create tests/api.test.ts:

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { PostgreSqlContainer, StartedPostgreSqlContainer } from 'testcontainers';
import { execSync } from 'node:child_process';
import request from 'supertest';
import { createApp } from '../src/app.js';

let container: StartedPostgreSqlContainer;

beforeAll(async () => {
  container = await new PostgreSqlContainer('postgres:16-alpine').start();
  process.env.DATABASE_URL = container.getConnectionUri();
  execSync(
    'npx node-pg-migrate up -m migrations -j ts',
    { env: process.env, stdio: 'inherit' }
  );
});

afterAll(async () => { await container.stop(); });

describe('shorten / redirect / delete', () => {
  const app = () => createApp('http://test');

  it('rejects non-http urls', async () => {
    const r = await request(app()).post('/shorten').send({ url: 'javascript:1' });
    expect(r.status).toBe(400);
  });

  it('round-trips a link', async () => {
    const create = await request(app()).post('/shorten')
      .send({ url: 'https://example.com/abc' });
    expect(create.status).toBe(201);

    const code = create.body.code as string;
    const get = await request(app()).get(`/${code}`).redirects(0);
    expect(get.status).toBe(302);
    expect(get.headers.location).toBe('https://example.com/abc');

    const del = await request(app()).delete(`/${code}`);
    expect(del.status).toBe(204);

    const gone = await request(app()).get(`/${code}`);
    expect(gone.status).toBe(404);
  });
});
npm run test:integration
✓ Verify: Testcontainers boots a Postgres, migrations run, both tests pass. First run is slow (image pull); later runs are quick.
STEP 6

TDD a new feature: custom aliases

Goal: POST /shorten { url, alias } uses the alias as the code.

Red. Add to tests/api.test.ts:

it('uses a custom alias when provided', async () => {
  const r = await request(app()).post('/shorten')
    .send({ url: 'https://example.com', alias: 'my-link' });
  expect(r.status).toBe(201);
  expect(r.body.code).toBe('my-link');
});

it('rejects an alias that is already taken', async () => {
  await request(app()).post('/shorten').send({ url: 'https://a.com', alias: 'taken' });
  const r = await request(app()).post('/shorten').send({ url: 'https://b.com', alias: 'taken' });
  expect(r.status).toBe(409);
});

it('rejects malformed aliases', async () => {
  for (const alias of ['', 'a', 'has space', 'has/slash', 'a'.repeat(33)]) {
    const r = await request(app()).post('/shorten').send({ url: 'https://x.com', alias });
    expect(r.status, alias).toBe(400);
  }
});

Run integration tests — they fail. Green. Update POST /shorten in src/app.ts:

const ALIAS_RE = /^[A-Za-z0-9_-]{3,32}$/;

app.post('/shorten', async (req, res) => {
  const { url, alias } = req.body ?? {};
  if (typeof url !== 'string' || !/^https?:\/\//i.test(url) || url.length > 2048)
    return res.status(400).json({ error: 'url must be http(s)://...' });

  if (alias !== undefined) {
    if (typeof alias !== 'string' || !ALIAS_RE.test(alias))
      return res.status(400).json({ error: 'alias must be 3-32 chars, [A-Za-z0-9_-]' });
    try {
      await pool.query('INSERT INTO links(code, target_url) VALUES ($1, $2)', [alias, url]);
      return res.status(201).json({ code: alias, short: `${base}/${alias}` });
    } catch (e: unknown) {
      const err = e as { code?: string };
      if (err.code === '23505') return res.status(409).json({ error: 'alias taken' });
      throw e;
    }
  }

  // …fall through to random-code path as before
});

Re-run integration tests — green. Refactor. Extract the validation into a helper, dedupe the insert call. Run tests after every change.

STEP 7

Tighten the coverage gate

Run npm test. If coverage is comfortably above 70%, raise the threshold to 80% in vitest.config.ts. Don't chase 100% — it incentivizes weak tests.

STEP 8

Commit

git checkout -b module-03
git add .
git commit -m "module 03: vitest, integration tests, custom aliases via TDD"
git push -u origin module-03
Common Gotchas

If Something Goes Wrong

  • Testcontainers can't reach Docker — make sure Docker Desktop is running and your user has access (docker ps works).
  • Migration "no migrations directory" — paths are relative to where Vitest runs. Use absolute paths or set cwd in execSync.
  • Tests share state — each test should make its own data. If two tests fight, isolate per-test by using a unique alias prefix.
  • Coverage fails on test files themselves — Vitest excludes them by default; if not, add exclude: ['src/**/*.test.ts'] to the coverage config.
What's Next

Move On

Tests turn green fast — but the redirect path still hits Postgres on every call. Time to add a cache and measure the difference.