Testing Architecture for TypeScript Backends: Unit Tests, Integration Tests, and a Testing Strategy That Actually Scales
A practical guide to testing TypeScript backends at scale: revisiting the testing pyramid, unit testing with Vitest and dependency injection, real integration tests with Testcontainers, and the tradeoffs between mocking and integration approaches that shape a testing strategy that survives a growing codebase.
Most testing guides start with the pyramid and never explain why it keeps failing teams in practice. The ratios feel right on paper. Lots of unit tests, some integration tests, a few end-to-end tests. Then the codebase grows, the unit tests mock everything, and you end up with a test suite that passes on CI and breaks in production.
This is not a guide about coverage percentages. It is about building a testing architecture that catches the bugs that actually hurt you.
The Pyramid, Revisited
The classic pyramid says: many unit tests (fast, cheap), fewer integration tests (slower, more setup), even fewer end-to-end tests (slowest, most fragile). The rationale holds at the unit level. For backend systems, the real bugs tend to live at the boundaries: the SQL query that returns wrong rows under a specific filter, the Redis pipeline that silently drops a write under backpressure, the queue consumer that fails to deserialize a message from a different producer version.
Unit tests will not catch any of those. Heavy mocking hides them further: you mock the database, you mock Redis, you mock the queue client, and now your unit tests are testing that your code calls mocks in the right order. That is not a test of behavior. It is a test of implementation.
The testing honeycomb, popularized by Spotify’s engineering blog, inverts the middle layer: fewer unit tests, more service integration tests, and only the critical paths at end-to-end. For TypeScript backends this framing is more useful. The question is not “how many unit tests should I write” but “what is the cheapest test that will actually catch this bug.”
Unit Testing with Vitest: Where It Actually Helps
Pure functions, domain logic, data transformation, complex conditional branching. These are the places where unit tests earn their keep. A function that computes a billing tier, validates a date range, or formats a report row is worth testing exhaustively at the unit level.
Vitest is the right choice for TypeScript backends in 2026. Native ESM support, fast startup, compatible with the Jest API, and built-in TypeScript support without configuration gymnastics.
// src/domain/billing.ts
export type Plan = "starter" | "growth" | "enterprise";
export function computeBillingTier(monthlyActiveUsers: number): Plan {
if (monthlyActiveUsers < 1_000) return "starter";
if (monthlyActiveUsers < 10_000) return "growth";
return "enterprise";
}
export function applyVolumeDiscount(
basePrice: number,
mau: number
): number {
const tier = computeBillingTier(mau);
if (tier === "enterprise") return basePrice * 0.75;
if (tier === "growth") return basePrice * 0.90;
return basePrice;
}
// src/domain/billing.test.ts
import { describe, it, expect } from "vitest";
import { computeBillingTier, applyVolumeDiscount } from "./billing";
describe("computeBillingTier", () => {
it("returns starter below 1000 MAU", () => {
expect(computeBillingTier(999)).toBe("starter");
});
it("returns growth at 1000 MAU", () => {
expect(computeBillingTier(1_000)).toBe("growth");
});
it("returns enterprise at 10000 MAU", () => {
expect(computeBillingTier(10_000)).toBe("enterprise");
});
});
describe("applyVolumeDiscount", () => {
it("applies 25% enterprise discount", () => {
expect(applyVolumeDiscount(100, 50_000)).toBe(75);
});
});
This is where unit tests belong. The logic is self-contained, deterministic, and has real branches worth covering.
Dependency Injection Over Module Mocking
When your code depends on external systems, the way you structure dependencies determines how testable it is. Module-level mocking with vi.mock() works, but it couples your tests to import paths and can produce false confidence.
Prefer passing dependencies explicitly. This makes the seam visible in the type system.
// src/services/user-service.ts
export interface UserRepository {
findById(id: string): Promise<User | null>;
save(user: User): Promise<void>;
}
export interface EmailClient {
send(to: string, subject: string, body: string): Promise<void>;
}
export class UserService {
constructor(
private readonly repo: UserRepository,
private readonly email: EmailClient
) {}
async deactivateUser(userId: string): Promise<void> {
const user = await this.repo.findById(userId);
if (!user) throw new Error(`User ${userId} not found`);
user.active = false;
await this.repo.save(user);
await this.email.send(
user.email,
"Your account has been deactivated",
"Contact support if this was unexpected."
);
}
}
// src/services/user-service.test.ts
import { describe, it, expect, vi } from "vitest";
import { UserService } from "./user-service";
const makeRepo = (overrides = {}) => ({
findById: vi.fn(),
save: vi.fn(),
...overrides,
});
const makeEmail = () => ({
send: vi.fn().mockResolvedValue(undefined),
});
describe("UserService.deactivateUser", () => {
it("throws when user not found", async () => {
const repo = makeRepo({ findById: vi.fn().mockResolvedValue(null) });
const service = new UserService(repo, makeEmail());
await expect(service.deactivateUser("missing-id")).rejects.toThrow(
"User missing-id not found"
);
});
it("saves user with active=false and sends email", async () => {
const user = { id: "u1", email: "a@b.com", active: true };
const repo = makeRepo({
findById: vi.fn().mockResolvedValue(user),
save: vi.fn().mockResolvedValue(undefined),
});
const email = makeEmail();
const service = new UserService(repo, email);
await service.deactivateUser("u1");
expect(repo.save).toHaveBeenCalledWith({ ...user, active: false });
expect(email.send).toHaveBeenCalledWith(
"a@b.com",
"Your account has been deactivated",
expect.any(String)
);
});
});
The test is fast, the dependencies are explicit, and the interfaces define a contract the real implementations must satisfy. This matters when you write the Postgres implementation: the test already tells you what the interface expects.
Integration Testing with Testcontainers
For the database layer, queue consumers, and cache interactions, you want real infrastructure. Not a mock that returns what you told it to return. A real Postgres instance, a real Redis, a real RabbitMQ. Testcontainers spins these up via Docker as part of your test run and tears them down afterward.
npm install --save-dev @testcontainers/postgresql @testcontainers/redis
// src/repositories/user-repository.integration.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { PostgreSqlContainer, StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import postgres from "postgres";
import { PgUserRepository } from "./pg-user-repository";
let container: StartedPostgreSqlContainer;
let sql: ReturnType<typeof postgres>;
let repo: PgUserRepository;
beforeAll(async () => {
container = await new PostgreSqlContainer("postgres:16-alpine").start();
sql = postgres(container.getConnectionUri());
await sql`
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`;
repo = new PgUserRepository(sql);
}, 30_000);
afterAll(async () => {
await sql.end();
await container.stop();
});
describe("PgUserRepository", () => {
it("returns null for missing user", async () => {
const result = await repo.findById("nonexistent");
expect(result).toBeNull();
});
it("saves and retrieves a user", async () => {
const user = { id: "u1", email: "test@example.com", active: true };
await repo.save(user);
const found = await repo.findById("u1");
expect(found).toMatchObject(user);
});
it("enforces unique email constraint", async () => {
await repo.save({ id: "u2", email: "dup@example.com", active: true });
await expect(
repo.save({ id: "u3", email: "dup@example.com", active: true })
).rejects.toThrow();
});
});
This test catches real problems: the SQL query shape, the column types, the unique constraint behavior, null handling when rows are missing. None of that is visible through a mock.
Testing Redis-Backed Code
The same approach applies to cache layers and rate limiters. If your rate limiter uses a Lua script on Redis, there is no substitute for testing against real Redis.
// src/rate-limiter.integration.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { RedisContainer, StartedRedisContainer } from "@testcontainers/redis";
import { createClient } from "redis";
import { SlidingWindowRateLimiter } from "./rate-limiter";
let container: StartedRedisContainer;
let client: ReturnType<typeof createClient>;
let limiter: SlidingWindowRateLimiter;
beforeAll(async () => {
container = await new RedisContainer("redis:7-alpine").start();
client = createClient({ url: container.getConnectionUrl() });
await client.connect();
limiter = new SlidingWindowRateLimiter(client, {
windowSeconds: 60,
maxRequests: 3,
});
}, 30_000);
afterAll(async () => {
await client.quit();
await container.stop();
});
it("allows requests under the limit", async () => {
const result = await limiter.check("user:1");
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(2);
});
it("blocks requests over the limit", async () => {
await limiter.check("user:2");
await limiter.check("user:2");
await limiter.check("user:2");
const result = await limiter.check("user:2");
expect(result.allowed).toBe(false);
});
Structuring Tests for a Growing Codebase
As the codebase grows, test organization becomes load-bearing. A flat structure breaks down fast. Use co-location with a clear naming convention for test types.
src/
domain/
billing.ts
billing.test.ts # unit: pure logic only
repositories/
pg-user-repository.ts
pg-user-repository.integration.test.ts # real Postgres
services/
user-service.ts
user-service.test.ts # unit: interfaces mocked
routes/
users.ts
users.integration.test.ts # HTTP layer with real DB
Run unit tests in watch mode during development. Run integration tests in CI. Separate them with Vitest’s include patterns or a project config.
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
projects: [
{
test: {
name: "unit",
include: ["src/**/*.test.ts"],
exclude: ["src/**/*.integration.test.ts"],
},
},
{
test: {
name: "integration",
include: ["src/**/*.integration.test.ts"],
testTimeout: 60_000,
hookTimeout: 60_000,
poolOptions: {
threads: { singleThread: true },
},
},
},
],
},
});
Run them separately:
vitest run --project unit
vitest run --project integration
CI Integration Patterns
In CI, you need Docker available for Testcontainers to work. On GitHub Actions, Docker is available by default on ubuntu-latest. The key concerns are startup time and parallelism.
# .github/workflows/test.yml
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx vitest run --project unit
integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx vitest run --project integration
env:
TESTCONTAINERS_RYUK_DISABLED: "false"
Run them as parallel jobs. Unit tests will finish in seconds. Integration tests might take 30-90 seconds depending on container startup and test count. Both jobs can fail independently so you get signal faster.
For larger suites, consider using --shard to split integration tests across runners:
vitest run --project integration --shard=1/3
vitest run --project integration --shard=2/3
vitest run --project integration --shard=3/3
The Mocking vs. Integration Tradeoffs
There is no universal answer. Here is how to think through the decision.
| Scenario | Approach | Reason |
|---|---|---|
| Pure domain logic, no I/O | Unit test | Fast, deterministic, thorough branch coverage |
| Service logic with external deps | Unit test with interface mocks | Tests behavior contract, not implementation |
| Repository layer (SQL, Redis, queues) | Integration with Testcontainers | SQL semantics, constraints, and types differ from mocks |
| HTTP route handlers | Integration with real DB | Request parsing, serialization, and DB round-trip all interact |
| Third-party API clients | Unit test with recorded responses | Real API calls are slow, unreliable, and cost money |
| Queue consumers | Integration with real queue | Message format, deserialization, and ack behavior matter |
| Email/SMS senders | Unit test with mock client | Side effects you never want in CI |
The pattern: mock at the boundary that does not have interesting runtime behavior. Use real infrastructure for anything where the implementation details of that system can break you.
A common mistake is mocking a database call and then discovering in production that the query silently returns an empty result instead of null, or that a JSONB column deserializes differently than expected. The mock told you everything was fine. Real Postgres would have told you immediately.
When NOT to Test
Coverage targets cause more damage than they prevent. When you push coverage from 80% to 95%, you start writing tests for getters, trivial constructors, and one-liner adapters. These tests have no failure modes worth catching. They slow the suite, they add maintenance cost, and they provide false confidence.
Do not test:
- Framework glue code that just connects your handler to the framework’s router
- Generated code (ORM query builders, protobuf output, OpenAPI client stubs)
- Trivial accessors and constructors with no conditional logic
- Configuration objects where the only test would assert
config.timeout === 30_000
Do test:
- Every conditional branch in domain logic
- Every query that filters, sorts, or aggregates data
- Every external boundary where serialization or type coercion happens
- Every error path your callers are expected to handle
The question to ask before writing a test: what bug would this test catch that could reach production? If the answer is “nothing I can think of,” skip it.
Putting It Together
A backend testing architecture that scales looks like this in practice. Unit tests cover domain logic and service behavior through interface boundaries. Integration tests cover every layer that touches real infrastructure: repositories, queue consumers, cache operations. The HTTP layer gets integration coverage end-to-end because route parsing, validation, serialization, and persistence interact in ways that only surface together.
Vitest gives you fast iteration on unit tests and the same runner for integration tests without switching tools. Testcontainers removes the “but it works against the mock” excuse from your debugging sessions. Dependency injection makes both approaches easier because the seams are explicit in your type signatures.
The goal is a suite you trust. When it passes, you ship. When it fails, you fix something real.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.