Testing
The project uses Vitest v4 with a dual-project configuration: unit tests run without external dependencies, integration tests spin up isolated NATS containers via Testcontainers.
Philosophy
This library handles real-time message delivery, consumer lifecycle, and self-healing reconnection — things that are notoriously hard to test with mocks alone. Our testing strategy reflects that:
- Integration tests are first-class citizens. Every message flow (events, RPC, broadcast, ordered) is tested against a real NATS server. No hand-waved "it works in production" — if it's not tested end-to-end, it's not done.
- Each test suite gets its own NATS container. No shared state between suites. No "run tests in order". Suites execute in parallel — because if your tests can't run in parallel, your architecture has a problem.
- Self-healing is proven, not assumed. We delete consumers via the JetStream Management API and verify the transport recovers. The self-healing flow is tested with real NATS, not mocked RxJS streams.
- Unit tests fill the gaps. Infrastructure error paths (defensive throws, catch blocks, exponential backoff) that can't be triggered through integration get targeted unit tests. Coverage is a tool, not a target — but we track it to make sure we're not lying to ourselves.
Prerequisites
- Docker — Testcontainers starts NATS containers automatically. No manual
docker compose upneeded for tests. - Node.js >= 20 and pnpm
Test Commands
| Command | Description |
|---|---|
pnpm test | Run all tests (unit + integration, parallel) |
pnpm test:watch | Run tests in watch mode |
pnpm test:cov | Run all tests with coverage reporting |
docker compose up -d is available for manual testing and debugging, but is not needed to run the test suite.
Test Suites
Unit Tests
- Location:
src/**/*.spec.ts - Setup file:
test/setup-unit.ts - Timeout: 10 seconds per test
- Environment: Node.js
Unit tests mock all external dependencies and test individual classes/functions in isolation. Infrastructure files (in src/server/infrastructure/) prefer integration coverage, but unit tests are used for error paths unreachable through integration.
Integration Tests
- Location:
test/**/*.spec.ts - Timeout: 30 seconds per test (60s for self-healing scenarios)
- Parallelism: Enabled — each suite starts its own NATS container
- Container image:
nats:2.12.6with JetStream and persistent store
Integration tests verify end-to-end behavior: stream/consumer provisioning, message delivery, RPC round-trips, broadcast fan-out, ordered delivery, dead-letter handling, graceful shutdown, and self-healing recovery.
How It Works
Every integration suite follows this pattern:
import type { StartedTestContainer } from 'testcontainers';
import { startNatsContainer } from './nats-container';
describe('My Feature', () => {
let container: StartedTestContainer;
let port: number;
let nc: NatsConnection;
beforeAll(async () => {
({ container, port } = await startNatsContainer());
nc = await createNatsConnection(port);
});
afterAll(async () => {
try {
await nc?.drain();
} finally {
await container?.stop();
}
});
it('should do something', async () => {
const { app } = await createTestApp({ name: 'my-service', port }, [MyController]);
// ... test against real NATS ...
await app.close();
});
});
Key helpers in test/integration/:
| Helper | Purpose |
|---|---|
startNatsContainer() | Start a NATS container with JetStream, return container + random port |
createNatsConnection(port) | Create a standalone NATS connection for assertions |
createTestApp({ name, port }, controllers) | Bootstrap a full NestJS app with the transport |
cleanupStreams(nc, serviceName) | Delete streams/consumers created during a test |
waitForCondition(fn, timeoutMs) | Poll until an async condition is met |
uniqueServiceName() | Generate a unique service name per test |
Self-Healing Tests
The self-healing suite proves that the transport recovers from consumer failures:
- Start app, deliver an event, confirm receipt
- Delete the consumer via JetStream Management API
- Re-create it (simulating eventual recovery)
- Verify the self-healing flow (catchError → exponential backoff → retry) picks up the re-created consumer
- Deliver another event, confirm receipt
The test uses a 1-second heartbeat interval (vs 5s production default) for faster failure detection. No sleep(12s) hacks — consumer deletion triggers the error path immediately.
Test Conventions
System Under Test (sut)
The object being tested is always named sut:
let sut: StreamProvider;
beforeEach(() => {
sut = new StreamProvider(mockOptions, mockConnection);
});
Mocking with createMock<T>()
Use createMock<T>() from @golevelup/ts-vitest for type-safe mocks:
import { createMock } from '@golevelup/ts-vitest';
const mockConnection = createMock<ConnectionProvider>();
createMock<JsMsg>() creates a Proxy where 'ack' in proxy returns false unless you explicitly provide the ack property. If your code checks for property existence on JsMsg, provide it in the mock setup.
Fake Data with @faker-js/faker
Use @faker-js/faker for realistic test data instead of hardcoded strings:
import { faker } from '@faker-js/faker';
const serviceName = faker.word.noun();
const pattern = `${faker.word.noun()}.${faker.word.verb()}`;
Given-When-Then Structure
it('should create event stream when it does not exist', async () => {
// Given
mockJsm.streams.info.mockRejectedValue(streamNotFoundError);
// When
await sut.ensureStreams(['ev']);
// Then
expect(mockJsm.streams.add).toHaveBeenCalledWith(
expect.objectContaining({ name: expectedStreamName }),
);
});
Test Ordering
Tests within a describe block follow this order:
- Happy path — the expected successful behavior
- Edge cases — boundary conditions and unusual inputs
- Error cases — failure modes and error handling
Mock Reset
Always reset mocks after each test:
afterEach(() => {
vi.resetAllMocks();
});
Assertions
Use await expect(...).rejects for async errors:
await expect(sut.connect()).rejects.toThrow('Connection refused');
Vitest auto-await for .rejects is deprecated. Always explicitly await the expect().rejects expression.
Coverage
Coverage is collected with @vitest/coverage-v8:
- Included:
src/**/*.ts - Excluded:
*.module.ts,*.d.ts,index.ts,*.interface.ts,*.type.ts - Reporters:
text(terminal),lcov,html - CI: Uploaded to Codecov on every push
Run pnpm test:cov and open coverage/index.html for the HTML report.
Adding a New Integration Test
- Create
test/integration/my-feature.spec.ts - Follow the container lifecycle pattern (see How It Works)
- Use
uniqueServiceName()per test to avoid collisions - Clean up streams in
afterEach— even though each suite gets its own NATS, cleanup prevents intra-suite leaks - Use
try/finallyinafterAllsocontainer.stop()always runs even ifnc.drain()fails - Run
pnpm test— your suite will execute in parallel with others
Vitest Configuration
The config uses Vitest projects to run unit and integration tests with different settings. See vitest.config.ts in the repository root.