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%.
← Back to Module 03 overviewnpm test runs unit tests in under 30s.npm run test:integration spins up Postgres in a container, runs migrations, exercises the API.POST /shorten with { "alias": "..." } creates a vanity short code, built TDD-style.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"
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 }
}
}
});
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
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}`));
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
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.
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.
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
docker ps works).cwd in execSync.exclude: ['src/**/*.test.ts'] to the coverage config.Tests turn green fast — but the redirect path still hits Postgres on every call. Time to add a cache and measure the difference.