DEV Community

Young Gao
Young Gao

Posted on

Testing Strategies for TypeScript APIs That Actually Catch Bugs

Testing Strategies for TypeScript APIs That Actually Catch Bugs

Most API test suites are theater. They test that 200 OK is returned for valid input, but miss the edge cases that break production — race conditions, partial failures, invalid state transitions, and subtle serialization bugs.

Here's a testing strategy built from real production incidents, organized by what each test type catches and how much it costs to maintain.

The Testing Pyramid for APIs

        /  E2E  \        — 5% of tests, catch integration failures
       / Contract \       — 15%, catch API compatibility breaks
      / Integration \     — 30%, catch database + service bugs
     /    Unit Tests  \   — 50%, catch logic bugs fast
Enter fullscreen mode Exit fullscreen mode

Project Setup

mkdir api-testing && cd api-testing
npm init -y
npm install express zod drizzle-orm better-sqlite3
npm install -D vitest supertest @types/supertest testcontainers msw
Enter fullscreen mode Exit fullscreen mode
// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov'],
      exclude: ['**/test/**', '**/*.test.ts'],
    },
    // Run tests in sequence for integration tests
    pool: 'forks',
    poolOptions: {
      forks: { singleFork: true },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Unit Tests: Pure Logic, No I/O

Unit tests should cover business logic — validation, transformations, calculations. No database, no HTTP, no file system.

// src/services/pricing.ts
export function calculateDiscount(
  basePrice: number,
  quantity: number,
  customerTier: 'standard' | 'premium' | 'enterprise'
): { price: number; discount: number; total: number } {
  if (basePrice < 0 || quantity < 1) {
    throw new Error('Invalid input');
  }

  const tierMultipliers = { standard: 0, premium: 0.1, enterprise: 0.2 };
  const volumeDiscount = quantity >= 100 ? 0.15 : quantity >= 10 ? 0.05 : 0;
  const discount = Math.min(tierMultipliers[customerTier] + volumeDiscount, 0.35);
  const total = basePrice * quantity * (1 - discount);

  return { price: basePrice, discount, total: Math.round(total * 100) / 100 };
}
Enter fullscreen mode Exit fullscreen mode
// src/services/pricing.test.ts
import { describe, it, expect } from 'vitest';
import { calculateDiscount } from './pricing';

describe('calculateDiscount', () => {
  it('applies no discount for standard tier, small quantity', () => {
    const result = calculateDiscount(100, 5, 'standard');
    expect(result).toEqual({ price: 100, discount: 0, total: 500 });
  });

  it('caps combined discount at 35%', () => {
    // Enterprise (20%) + volume 100+ (15%) = 35% cap
    const result = calculateDiscount(100, 100, 'enterprise');
    expect(result.discount).toBe(0.35);
    expect(result.total).toBe(6500);
  });

  it('stacks tier and volume discounts', () => {
    // Premium (10%) + volume 10+ (5%) = 15%
    const result = calculateDiscount(50, 20, 'premium');
    expect(result.discount).toBe(0.15);
    expect(result.total).toBe(850);
  });

  it('rejects negative prices', () => {
    expect(() => calculateDiscount(-10, 1, 'standard')).toThrow('Invalid input');
  });

  it('rejects zero quantity', () => {
    expect(() => calculateDiscount(100, 0, 'standard')).toThrow('Invalid input');
  });

  // This test caught a real bug — floating point precision
  it('rounds total to 2 decimal places', () => {
    const result = calculateDiscount(19.99, 3, 'premium');
    // Without rounding: 19.99 * 3 * 0.9 = 53.973
    expect(result.total).toBe(53.97);
  });
});
Enter fullscreen mode Exit fullscreen mode

What unit tests catch: Logic errors, edge cases, floating point bugs, off-by-one errors. They're fast (run in <1ms each) and stable (no external dependencies).

Integration Tests: Real Database, Real Queries

Integration tests hit the actual database. Mocking SQL queries is worse than useless — it tests that your mock matches your assumptions, not that your query works.

// src/repositories/user.integration.test.ts
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import Database from 'better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import { users } from '../schema';
import { UserRepository } from './user';

describe('UserRepository', () => {
  let db: ReturnType<typeof drizzle>;
  let sqlite: Database.Database;
  let repo: UserRepository;

  beforeAll(() => {
    sqlite = new Database(':memory:');
    db = drizzle(sqlite);
    migrate(db, { migrationsFolder: './drizzle' });
    repo = new UserRepository(db);
  });

  afterAll(() => {
    sqlite.close();
  });

  beforeEach(() => {
    // Clean slate for each test
    db.delete(users).run();
  });

  it('creates a user and retrieves by ID', async () => {
    const created = await repo.create({
      email: 'test@example.com',
      name: 'Test User',
    });

    expect(created.id).toBeDefined();
    expect(created.email).toBe('test@example.com');

    const found = await repo.findById(created.id);
    expect(found).toEqual(created);
  });

  it('enforces unique email constraint', async () => {
    await repo.create({ email: 'dup@example.com', name: 'User 1' });

    await expect(
      repo.create({ email: 'dup@example.com', name: 'User 2' })
    ).rejects.toThrow(/UNIQUE constraint/);
  });

  it('handles pagination correctly at boundaries', async () => {
    // Create exactly 25 users
    for (let i = 0; i < 25; i++) {
      await repo.create({ email: `user${i}@test.com`, name: `User ${i}` });
    }

    const page1 = await repo.list({ page: 1, limit: 10 });
    const page3 = await repo.list({ page: 3, limit: 10 });

    expect(page1.items).toHaveLength(10);
    expect(page3.items).toHaveLength(5);
    expect(page3.totalPages).toBe(3);
  });

  it('soft-deletes without removing from database', async () => {
    const user = await repo.create({ email: 'del@test.com', name: 'Delete Me' });
    await repo.softDelete(user.id);

    // findById should not return soft-deleted users
    const found = await repo.findById(user.id);
    expect(found).toBeNull();

    // But the row still exists (for audit/recovery)
    const raw = sqlite.prepare('SELECT * FROM users WHERE id = ?').get(user.id);
    expect(raw).toBeDefined();
    expect((raw as any).deleted_at).not.toBeNull();
  });
});
Enter fullscreen mode Exit fullscreen mode

What integration tests catch: SQL bugs, constraint violations, migration issues, ORM misuse, query performance regressions. Using SQLite in-memory is fast enough for most tests (sub-second for hundreds of queries).

API Tests: HTTP Layer + Middleware

Test the full HTTP stack — routing, middleware, serialization, error handling.

// src/app.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { createApp } from './app';

describe('API endpoints', () => {
  let app: ReturnType<typeof createApp>;

  beforeAll(() => {
    app = createApp({ database: ':memory:' });
  });

  describe('POST /api/users', () => {
    it('creates user with valid input', async () => {
      const res = await request(app)
        .post('/api/users')
        .send({ email: 'new@test.com', name: 'New User' })
        .expect(201);

      expect(res.body).toMatchObject({
        email: 'new@test.com',
        name: 'New User',
      });
      expect(res.body.id).toBeDefined();
      expect(res.body.createdAt).toBeDefined();
    });

    it('returns 400 with validation details for bad input', async () => {
      const res = await request(app)
        .post('/api/users')
        .send({ email: 'not-an-email', name: '' })
        .expect(400);

      expect(res.body.error).toBe('Validation failed');
      expect(res.body.details).toHaveLength(2);
      expect(res.body.details[0].path).toContain('email');
      expect(res.body.details[1].path).toContain('name');
    });

    it('returns 409 for duplicate email', async () => {
      await request(app)
        .post('/api/users')
        .send({ email: 'dup@test.com', name: 'First' });

      const res = await request(app)
        .post('/api/users')
        .send({ email: 'dup@test.com', name: 'Second' })
        .expect(409);

      expect(res.body.error).toContain('already exists');
    });
  });

  describe('GET /api/users', () => {
    it('returns paginated results with correct headers', async () => {
      // Seed some data
      for (let i = 0; i < 15; i++) {
        await request(app)
          .post('/api/users')
          .send({ email: `page${i}@test.com`, name: `User ${i}` });
      }

      const res = await request(app)
        .get('/api/users?page=2&limit=10')
        .expect(200);

      expect(res.body.items).toHaveLength(5);
      expect(res.body.pagination.page).toBe(2);
      expect(res.body.pagination.totalPages).toBe(2);
    });

    it('ignores unknown query parameters', async () => {
      await request(app)
        .get('/api/users?foo=bar&page=1')
        .expect(200);
    });
  });

  describe('Authentication', () => {
    it('returns 401 for missing token', async () => {
      await request(app).get('/api/admin/stats').expect(401);
    });

    it('returns 401 for expired token', async () => {
      const expiredToken = createExpiredJWT();
      await request(app)
        .get('/api/admin/stats')
        .set('Authorization', `Bearer ${expiredToken}`)
        .expect(401);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

What API tests catch: Routing bugs, middleware ordering issues, status code mistakes, response format errors, authentication bypass.

Contract Tests: API Compatibility

When other services depend on your API, contract tests prevent breaking changes.

// src/contracts/user-api.contract.test.ts
import { describe, it, expect } from 'vitest';
import { z } from 'zod';
import request from 'supertest';
import { createApp } from '../app';

// This schema represents what consumers expect
const UserResponseContract = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string(),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
});

const PaginatedUsersContract = z.object({
  items: z.array(UserResponseContract),
  pagination: z.object({
    page: z.number(),
    limit: z.number(),
    total: z.number(),
    totalPages: z.number(),
  }),
});

describe('User API contract', () => {
  const app = createApp({ database: ':memory:' });

  it('POST /api/users response matches contract', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ email: 'contract@test.com', name: 'Contract Test' });

    const parsed = UserResponseContract.safeParse(res.body);
    expect(parsed.success).toBe(true);
  });

  it('GET /api/users response matches paginated contract', async () => {
    const res = await request(app).get('/api/users');

    const parsed = PaginatedUsersContract.safeParse(res.body);
    expect(parsed.success).toBe(true);
  });

  it('error responses always include error field', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ email: 'bad' });

    expect(res.body).toHaveProperty('error');
    expect(typeof res.body.error).toBe('string');
  });
});
Enter fullscreen mode Exit fullscreen mode

What contract tests catch: Accidental field renames, type changes, missing fields. They fail when you'd break a consumer.

Testing External Service Calls with MSW

Mock Service Worker intercepts HTTP calls at the network level — no mocking fetch or axios.

// src/services/payment.test.ts
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { PaymentService } from './payment';

const server = setupServer();

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe('PaymentService', () => {
  const service = new PaymentService('https://api.stripe.test');

  it('creates a payment intent', async () => {
    server.use(
      http.post('https://api.stripe.test/v1/payment_intents', () => {
        return HttpResponse.json({
          id: 'pi_test_123',
          status: 'requires_payment_method',
          amount: 1000,
        });
      })
    );

    const result = await service.createPaymentIntent(1000, 'usd');
    expect(result.id).toBe('pi_test_123');
    expect(result.amount).toBe(1000);
  });

  it('retries on 503 then succeeds', async () => {
    let attempts = 0;
    server.use(
      http.post('https://api.stripe.test/v1/payment_intents', () => {
        attempts++;
        if (attempts < 3) {
          return new HttpResponse(null, { status: 503 });
        }
        return HttpResponse.json({ id: 'pi_retry', status: 'created', amount: 500 });
      })
    );

    const result = await service.createPaymentIntent(500, 'usd');
    expect(result.id).toBe('pi_retry');
    expect(attempts).toBe(3);
  });

  it('throws after max retries exhausted', async () => {
    server.use(
      http.post('https://api.stripe.test/v1/payment_intents', () => {
        return new HttpResponse(null, { status: 503 });
      })
    );

    await expect(service.createPaymentIntent(500, 'usd')).rejects.toThrow(
      'Payment service unavailable'
    );
  });

  it('handles malformed JSON response', async () => {
    server.use(
      http.post('https://api.stripe.test/v1/payment_intents', () => {
        return new HttpResponse('not json', {
          headers: { 'Content-Type': 'application/json' },
        });
      })
    );

    await expect(service.createPaymentIntent(500, 'usd')).rejects.toThrow();
  });
});
Enter fullscreen mode Exit fullscreen mode

What MSW tests catch: Retry logic bugs, timeout handling, error mapping, response parsing failures.

Test Organization

src/
├── services/
│   ├── pricing.ts
│   └── pricing.test.ts          # Unit tests: co-located
├── repositories/
│   ├── user.ts
│   └── user.integration.test.ts # Integration tests: co-located
├── app.test.ts                  # API tests: top-level
└── contracts/
    └── user-api.contract.test.ts # Contract tests: separate dir
Enter fullscreen mode Exit fullscreen mode

Run them separately:

{
  "scripts": {
    "test": "vitest run",
    "test:unit": "vitest run --testPathPattern='.test.ts$' --testPathIgnorePatterns='integration|contract'",
    "test:integration": "vitest run --testPathPattern='integration'",
    "test:contract": "vitest run --testPathPattern='contract'",
    "test:watch": "vitest watch"
  }
}
Enter fullscreen mode Exit fullscreen mode

What Not to Test

  • Framework behavior: Don't test that Express returns 404 for unregistered routes. That's Express's job.
  • Simple getters/setters: If a function just passes through data, the integration test covers it.
  • Third-party library internals: Don't test that Zod validates emails correctly.
  • Implementation details: Test the output, not how a function internally computes it.

Conclusion

The strategy is straightforward:

  • Unit tests for business logic and calculations (fast, numerous)
  • Integration tests for database operations (use real databases, not mocks)
  • API tests for HTTP behavior and middleware (use supertest)
  • Contract tests for API stability (use Zod schemas)
  • MSW for external service interactions (network-level mocking)

Each layer catches different bugs. Skip a layer and those bugs reach production.


If this was helpful, you can support my work at ko-fi.com/nopkt


If this article helped you, consider buying me a coffee on Ko-fi! Follow me for more production backend patterns.

Top comments (0)