Testing GraphQL, WebSockets & Microservices
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against NestJS 12.1 (npm @nestjs/core and @nestjs/testing 12.1.2), @nestjs/graphql 14.0.3 with @nestjs/apollo 14.0.3 (Apollo Server 5.5, graphql 16.14), socket.io and socket.io-client 4.8, @nestjs/microservices 12.1.2, Vitest 4.1, supertest 7.3, TypeScript 6 and Node.js 24 (22 works too), and was checked on 2026-10-02. The examples target the Nest 12 ESM template with Vitest. The Pact tests and the load-test files run from their own tooling, outside the Vitest suite; the Testcontainers example needs a Docker daemon and the k6 scripts need the k6 binary. The Fastify variants of the GraphQL and gateway tests have the same structure; only the adapter passed to createNestApplication() changes.
The basics of testing in Nest (Test.createTestingModule, overrideProvider, useMocker, coverage, the Vitest and Jest setups) are covered in Testing; this page assumes them and covers what is different when the entry point is not an HTTP controller. The three transports have the same testing shape: a unit test that calls the handler class directly with fakes, an integration test that starts the application and talks to it through a real client, and a contract or load test that checks the agreement between two systems or how the whole thing behaves under traffic. In the Bookshelf application that grows across this section the examples are a GraphQL books API, a chat gateway per book, and a gateway service that talks to a ratings microservice.
The examples use Vitest (vi.fn(), vi.waitFor, fake timers). With Jest the structure is identical and vi becomes jest; the Nest APIs do not change.
Which level for which question
| Level | Question it answers | What is real |
|---|---|---|
Unit |
Does this resolver, gateway handler or message handler do the right thing with its inputs? |
The class under test. Everything it depends on is a fake. |
Integration |
Do decorators, pipes, guards, serialisation and the transport work together? |
The Nest application and a real client over a real socket or HTTP request. |
Contract |
Do producer and consumer still agree on the shape of what they exchange? |
Only the shared description (schema file, schema object, Pact file). |
Load |
How many concurrent users, and how fast, before it degrades? |
A running, production-like instance. |
GraphQL
The running example is a small code-first API: a Book type, a query, a guarded mutation, a field resolver and a subscription. The guard looks at the GraphQL context, which is where the HTTP request lives in GraphQL (see GraphQL: Code-First & Schema-First).
import { Inject, NotFoundException, UseGuards } from '@nestjs/common';
import { Args, ID, Mutation, Parent, Query, ResolveField, Resolver, Subscription } from '@nestjs/graphql';
import { PubSub } from 'graphql-subscriptions';
import { ApiKeyGuard } from './api-key.guard.js';
import { Author, Book } from './book.model.js';
import { BooksService } from './books.service.js';
import { CreateBookInput } from './create-book.input.js';
export const PUB_SUB = 'PUB_SUB';
@Resolver(() => Book)
export class BooksResolver {
constructor(
private readonly service: BooksService,
@Inject(PUB_SUB) private readonly pubSub: PubSub,
) {}
@Query(() => [Book])
books(): Book[] {
return this.service.findAll();
}
@Query(() => Book)
book(@Args('id', { type: () => ID }) id: string): Book {
const book = this.service.findOne(id);
if (!book) throw new NotFoundException(`Book ${id} does not exist`);
return book;
}
@UseGuards(ApiKeyGuard)
@Mutation(() => Book)
async addBook(@Args('input') input: CreateBookInput): Promise<Book> {
const book = this.service.create(input);
await this.pubSub.publish('bookAdded', { bookAdded: book });
return book;
}
@Subscription(() => Book)
bookAdded() {
return this.pubSub.asyncIterableIterator<Book>('bookAdded');
}
@ResolveField(() => Author, { nullable: true })
author(@Parent() book: Book): Author | undefined {
return this.service.findAuthor(book.authorId);
}
}
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
@Injectable()
export class ApiKeyGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
// In GraphQL the HTTP request lives on the GraphQL context, not on switchToHttp().
const { req } = GqlExecutionContext.create(context).getContext<{ req: { headers: Record<string, string> } }>();
return req.headers['x-api-key'] === 'let-me-in';
}
}
import { ApolloDriver, type ApolloDriverConfig } from '@nestjs/apollo';
import { Module, ValidationPipe } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';
import { GraphQLModule } from '@nestjs/graphql';
import { PubSub } from 'graphql-subscriptions';
import { ApiKeyGuard } from './api-key.guard.js';
import { BooksResolver, PUB_SUB } from './books.resolver.js';
import { BooksService } from './books.service.js';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: true, // schema kept in memory
sortSchema: true,
context: ({ req }: { req: unknown }) => ({ req }),
}),
],
providers: [
BooksService,
BooksResolver,
ApiKeyGuard,
{ provide: PUB_SUB, useValue: new PubSub() },
{ provide: APP_PIPE, useValue: new ValidationPipe({ transform: true }) },
],
})
export class BooksModule {}
Vitest configuration for GraphQL
Two copies of the graphql package in one process make graphql fail with "Cannot use GraphQLSchema from another module or realm". With Vitest this can happen because Vite prefers the module field of graphql (index.mjs) for files it transforms, while Node resolves main (index.js) for @nestjs/graphql. An alias to the CommonJS entry fixes it. If your GraphQL specs fail that way, add the alias to the config used by those specs:
import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
// Force a single copy of graphql: Vite would otherwise pick index.mjs for transformed files
// while Node resolves index.js for @nestjs/graphql.
export default defineConfig({
plugins: [tsconfigPaths()],
resolve: { alias: [{ find: /^graphql$/, replacement: 'graphql/index.js' }] },
test: { globals: true, root: './', include: ['**/*.gql.e2e-spec.ts'] },
});
Source: Testing (the Vitest setup) and GraphQL: Quick start.
Resolver unit tests
A resolver is an ordinary provider. A unit test does not need GraphQLModule, a schema or HTTP: build a plain testing module with the resolver and fakes for its dependencies and call the methods. Field resolvers are methods too, so @Parent() is just an argument.
describe('resolver unit tests', () => {
let resolver: BooksResolver;
const service = {
findAll: vi.fn<() => Book[]>(),
findOne: vi.fn<(id: string) => Book | undefined>(),
findAuthor: vi.fn(),
create: vi.fn(),
};
const pubSub = { publish: vi.fn().mockResolvedValue(undefined), asyncIterableIterator: vi.fn() };
beforeEach(async () => {
vi.clearAllMocks();
// No GraphQLModule: a resolver is an ordinary provider, so a plain module is enough.
const moduleRef = await Test.createTestingModule({
providers: [
BooksResolver,
{ provide: BooksService, useValue: service },
{ provide: PUB_SUB, useValue: pubSub },
],
}).compile();
resolver = moduleRef.get(BooksResolver);
});
it('returns the books from the service', () => {
const books = [{ id: 'b1', title: 'Dune', pages: 400, authorId: 'a1' }];
service.findAll.mockReturnValue(books);
expect(resolver.books()).toBe(books);
});
it('throws NotFoundException for an unknown book', () => {
service.findOne.mockReturnValue(undefined);
expect(() => resolver.book('nope')).toThrow(NotFoundException);
});
it('resolves the author field from the parent book', () => {
service.findAuthor.mockReturnValue({ id: 'a1', name: 'Ada Park' });
const parent: Book = { id: 'b1', title: 'Dune', pages: 400, authorId: 'a1' };
expect(resolver.author(parent)).toEqual({ id: 'a1', name: 'Ada Park' });
expect(service.findAuthor).toHaveBeenCalledWith('a1');
});
it('publishes bookAdded after creating a book', async () => {
const created = { id: 'b3', title: 'New', pages: 10, authorId: 'a1' };
service.create.mockReturnValue(created);
await resolver.addBook({ title: 'New', pages: 10 });
expect(pubSub.publish).toHaveBeenCalledWith('bookAdded', { bookAdded: created });
});
});
These tests cover the logic of the resolver (not-found handling, the field resolver, publishing after a mutation). They deliberately do not cover argument validation, guards or the schema, because none of those run when you call the method directly. That is what the next level is for.
Source: Testing (unit testing and Test.createTestingModule).
Guards and GqlExecutionContext
A guard that works on GraphQL must read the request through GqlExecutionContext, because in a resolver the arguments are (root, args, context, info) instead of (req, res). To unit test it, build an ExecutionContext whose argument array has that shape. ExecutionContextHost from @nestjs/core is the class Nest itself uses to build one.
describe('guard unit test with GqlExecutionContext', () => {
const contextWith = (headers: Record<string, string>) =>
// GraphQL resolvers receive (root, args, context, info): that is what GqlExecutionContext reads.
new ExecutionContextHost([{}, {}, { req: { headers } }, {}]) as ExecutionContext;
it('accepts the right key and rejects anything else', () => {
const guard = new ApiKeyGuard();
expect(guard.canActivate(contextWith({ 'x-api-key': 'let-me-in' }))).toBe(true);
expect(guard.canActivate(contextWith({}))).toBe(false);
});
});
Source: GraphQL: Quick start and Execution context.
End-to-end tests with supertest
An e2e test creates the application from the real module and sends GraphQL operations as POST /graphql with supertest. The gql helper below keeps each test to the query and the assertion; app.getHttpServer() means no port is opened.
describe('GraphQL e2e (Apollo, Express)', () => {
let app: INestApplication;
const gql = (query: string, variables?: object, headers: Record<string, string> = {}) =>
request(app.getHttpServer()).post('/graphql').set(headers).send({ query, variables });
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [BooksModule] }).compile();
app = moduleRef.createNestApplication();
await app.init();
});
afterAll(() => app.close());
it('runs a query with a resolved field', async () => {
const res = await gql('{ books { id title author { name } } }').expect(200);
expect(res.body.data.books[0]).toEqual({ id: 'b1', title: 'Graphs in practice', author: { name: 'Ada Park' } });
});
it('reports errors in the body with the exception status as a code', async () => {
const res = await gql('query($id: ID!) { book(id: $id) { title } }', { id: 'zzz' }).expect(200);
console.log('NOTFOUND', JSON.stringify(res.body));
expect(res.body.errors[0].message).toBe('Book zzz does not exist');
expect(res.body.errors[0].extensions.code).toBe('INTERNAL_SERVER_ERROR');
expect(res.body.data).toBeNull();
});
it('rejects a mutation without the key', async () => {
const res = await gql('mutation { addBook(input: { title: "Valid", pages: 10 }) { id } }').expect(200);
console.log('FORBIDDEN', JSON.stringify(res.body));
expect(res.body.errors[0].message).toBe('Forbidden resource');
});
it('validates the input and creates a book with the key', async () => {
const mutation = 'mutation($input: CreateBookInput!) { addBook(input: $input) { id title pages } }';
const headers = { 'x-api-key': 'let-me-in' };
const bad = await gql(mutation, { input: { title: 'x', pages: 0 } }, headers).expect(200);
console.log('BAD', JSON.stringify(bad.body));
expect(bad.body.errors[0].extensions.code).toBe('BAD_REQUEST');
const ok = await gql(mutation, { input: { title: 'Testing Nest', pages: 200 } }, headers).expect(200);
expect(ok.body.data.addBook).toMatchObject({ title: 'Testing Nest', pages: 200 });
});
it('rejects an invalid query with HTTP 400', async () => {
const res = await gql('{ nothing }').expect(400);
expect(res.body.errors[0].extensions.code).toBe('GRAPHQL_VALIDATION_FAILED');
});
// ... the schema-level tests follow in the next sections
});
Three things worth knowing about what these tests print:
-
Apollo answers with HTTP 200 even when a resolver throws; the failure is in
errors. ANotFoundExceptiondoes not become the codeNOT_FOUND; the code isINTERNAL_SERVER_ERRORand the HTTP status is carried inextensions.status. -
A guard rejection gets the code
FORBIDDEN, and a validation failure from the globalValidationPipegetsBAD_REQUESTwith the class-validator messages insideextensions.originalError. -
Queries that fail GraphQL validation (an unknown field) are answered with HTTP 400 and the code
GRAPHQL_VALIDATION_FAILED, so assert the status for those and the body for resolver errors.
{"errors":[{"message":"Book zzz does not exist","path":["book"],"extensions":{"code":"INTERNAL_SERVER_ERROR","status":404}}],"data":null}
{"errors":[{"message":"Forbidden resource","path":["addBook"],"extensions":{"code":"FORBIDDEN"}}],"data":null}
{"errors":[{"message":"Bad Request Exception","path":["addBook"],"extensions":{"code":"BAD_REQUEST","originalError":{"message":["title must be longer than or equal to 3 characters","pages must not be less than 1"],"statusCode":400}}}],"data":null}
(locations and the full originalError were trimmed from the output above.) Source: GraphQL: Quick start.
Executing operations without HTTP
GraphQLSchemaHost gives the built schema object. With it a test can call graphql() from the graphql package directly, with no HTTP listener and no supertest, which is faster and lets you pass any context object. Subscriptions can be tested the same way with subscribe(): the test consumes the async iterator the resolver returned, with no WebSocket connection, and triggers the event through a mutation.
it('executes queries without HTTP through GraphQLSchemaHost', async () => {
const { schema } = app.get(GraphQLSchemaHost);
const result = await graphql({
schema,
source: '{ book(id: "b2") { title } }',
contextValue: { req: { headers: {} } },
});
expect(result.data).toEqual({ book: { title: 'Typed APIs' } });
});
// ...
This tests the resolver chain but not the transport. A test of graphql-ws itself (authentication in onConnect, connection limits) needs a real client connected to a listening application; that is shown in GraphQL: Subscriptions, Security & Plugins.
Source: GraphQL: Quick start (the GraphQLSchemaHost section).
WebSockets
The gateway below authenticates the handshake with a Socket.IO middleware registered in afterInit(), validates payloads with a pipe that raises WsException, and broadcasts to a room. See WebSockets for how each piece works.
import { UsePipes, ValidationPipe } from '@nestjs/common';
import {
ConnectedSocket,
MessageBody,
OnGatewayInit,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
WsException,
} from '@nestjs/websockets';
import type { Namespace, Socket } from 'socket.io';
import { SendMessageDto } from './chat.dto.js';
// A string error becomes { status: 'error', message } on the client's `exception` event.
const wsValidationPipe = new ValidationPipe({
exceptionFactory: (errors) =>
new WsException(errors.flatMap((e) => Object.values(e.constraints ?? {})).join('; ')),
});
@UsePipes(wsValidationPipe)
@WebSocketGateway({ namespace: 'chat', transports: ['websocket'] })
export class ChatGateway implements OnGatewayInit {
// With a namespace this is a Namespace, not a Server.
@WebSocketServer()
server: Namespace;
afterInit(namespace: Namespace): void {
// Runs on every connection attempt, before handleConnection and before any message.
namespace.use((socket, next) => {
const user = socket.handshake.auth?.user;
if (typeof user !== 'string' || user === '') return next(new Error('unauthorized'));
socket.data.user = user;
next();
});
}
@SubscribeMessage('join')
async join(@MessageBody('bookId') bookId: number, @ConnectedSocket() client: Socket) {
await client.join(`book:${bookId}`);
return { room: `book:${bookId}` };
}
@SubscribeMessage('send')
send(@MessageBody() dto: SendMessageDto, @ConnectedSocket() client: Socket) {
const message = { bookId: dto.bookId, from: client.data.user as string, text: dto.text };
this.server.to(`book:${dto.bookId}`).emit('message', message);
return { delivered: true };
}
}
import { IsInt, IsNotEmpty, MaxLength } from 'class-validator';
export class SendMessageDto {
@IsInt()
bookId: number;
@IsNotEmpty()
@MaxLength(500)
text: string;
}
Unit tests without sockets
Handlers that take @MessageBody() and @ConnectedSocket() can be called as plain methods. The @WebSocketServer() property is set by the framework at runtime, so a unit test assigns a fake by hand. The official documentation advises against handlers that need the whole socket because every unit test then has to mock it; the test below only needs the two members the handler touches.
describe('gateway unit test (no sockets)', () => {
it('broadcasts to the room and acknowledges', () => {
const emit = vi.fn();
const to = vi.fn(() => ({ emit }));
const gateway = new ChatGateway();
// @WebSocketServer() only injects at runtime; in a unit test assign it by hand.
gateway.server = { to } as never;
const ack = gateway.send({ bookId: 7, text: 'hello' }, { data: { user: 'ada' } } as never);
expect(to).toHaveBeenCalledWith('book:7');
expect(emit).toHaveBeenCalledWith('message', { bookId: 7, from: 'ada', text: 'hello' });
expect(ack).toEqual({ delivered: true });
});
it('joins the room of the book', async () => {
const join = vi.fn().mockResolvedValue(undefined);
const result = await new ChatGateway().join(7, { join } as never);
expect(join).toHaveBeenCalledWith('book:7');
expect(result).toEqual({ room: 'book:7' });
});
});
Source: Gateways.
Integration tests with a real Socket.IO client
A unit test cannot show that the handshake middleware, the pipe and the room broadcast cooperate. For that, start the application on a random port and connect socket.io-client to it. Four habits keep these tests reliable:
-
app.listen(0)lets the operating system choose the port, so tests running in parallel never collide. The port comes fromapp.getHttpServer().address(). -
Never
awaita message after sending: register the listener first (const received = nextEvent(…)) and then send, otherwise a fast server wins the race. -
Wrap every wait in a timeout. A test that waits for an event that never comes must fail, not hang. The
nextEventhelper does that. -
Disconnect every client in
afterEachand close the application inafterAll; an open socket keeps the Vitest worker alive.
A negative assertion ("the outsider receives nothing") has no event to wait for, so the test waits a short fixed time. Keep those rare and short.
/** Resolve with the first payload of `event`, or fail instead of hanging the test run. */
function nextEvent<T = any>(socket: ClientSocket, event: string, ms = 2000): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`no "${event}" event within ${ms} ms`)), ms);
socket.once(event, (payload: T) => {
clearTimeout(timer);
resolve(payload);
});
});
}
describe('gateway integration test (real Socket.IO client)', () => {
let app: INestApplication;
let url: string;
const clients: ClientSocket[] = [];
/** Connect a client and wait until the handshake finished (or failed). */
async function connect(user?: string): Promise<ClientSocket> {
const client = io(`${url}/chat`, { transports: ['websocket'], auth: user ? { user } : {}, reconnection: false });
clients.push(client);
await Promise.race([nextEvent(client, 'connect'), nextEvent<Error>(client, 'connect_error').then((e) => Promise.reject(e))]);
return client;
}
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [ChatModule] }).compile();
app = moduleRef.createNestApplication();
await app.listen(0); // port 0: the OS picks a free port, so parallel runs never collide
url = `http://localhost:${(app.getHttpServer().address() as AddressInfo).port}`;
});
afterEach(() => {
clients.splice(0).forEach((client) => client.disconnect());
});
afterAll(() => app.close());
it('refuses a connection without credentials', async () => {
await expect(connect()).rejects.toThrow('unauthorized');
});
it('delivers a message to the other clients in the room and acknowledges the sender', async () => {
const ada = await connect('ada');
const grace = await connect('grace');
await ada.emitWithAck('join', { bookId: 1 });
await grace.emitWithAck('join', { bookId: 1 });
const received = nextEvent(grace, 'message');
const ack = await ada.emitWithAck('send', { bookId: 1, text: 'hello' });
expect(ack).toEqual({ delivered: true });
expect(await received).toEqual({ bookId: 1, from: 'ada', text: 'hello' });
});
it('does not deliver to clients outside the room', async () => {
const ada = await connect('ada');
const outsider = await connect('linus');
await ada.emitWithAck('join', { bookId: 2 });
const spy = vi.fn();
outsider.on('message', spy);
await ada.emitWithAck('send', { bookId: 2, text: 'private' });
await new Promise((resolve) => setTimeout(resolve, 50)); // negative assertion: give the event time to (not) arrive
expect(spy).not.toHaveBeenCalled();
});
it('reports an invalid payload as an exception event', async () => {
const ada = await connect('ada');
const failure = nextEvent(ada, 'exception');
ada.emit('send', { bookId: 'not-a-number', text: '' });
const error = await failure;
console.log('EXCEPTION', JSON.stringify(error));
expect(error).toMatchObject({ status: 'error', message: 'bookId must be an integer number; text should not be empty' });
expect(error.cause.pattern).toBe('send'); // the event that failed
});
});
Two details of the validation test are worth reading in the output. With a string passed to WsException, the client receives the event exception with { status: 'error', message }, plus a cause carrying the pattern and data of the message that failed, so a client (and a test) can tell which event was rejected:
{"status":"error","message":"bookId must be an integer number; text should not be empty","cause":{"pattern":"send","data":{"bookId":"not-a-number","text":""}}}
Passing an array (the raw class-validator errors) to WsException makes Nest send that array as is, with neither status nor message; the test for the string form is the one that documents the shape your clients can rely on.
Source: Gateways, Exception filters and Pipes.
The ws adapter and a native client
With WsAdapter from @nestjs/platform-ws the gateway speaks plain WebSocket, and messages are JSON frames of the form { event, data }. The test client can be the WebSocket class that Node.js has provided globally since version 22, so no extra package is needed.
describe('gateway over the ws adapter (native WebSocket client)', () => {
it('echoes through Node.js built-in WebSocket', async () => {
const { WebSocketGateway, SubscribeMessage, MessageBody } = await import('@nestjs/websockets');
const { Module } = await import('@nestjs/common');
@WebSocketGateway({ path: '/ws' })
class EchoGateway {
@SubscribeMessage('echo')
echo(@MessageBody() data: string) {
return { event: 'echoed', data };
}
}
@Module({ providers: [EchoGateway] })
class EchoModule {}
const moduleRef = await Test.createTestingModule({ imports: [EchoModule] }).compile();
const app = moduleRef.createNestApplication();
app.useWebSocketAdapter(new WsAdapter(app));
await app.listen(0);
const port = (app.getHttpServer().address() as AddressInfo).port;
const socket = new WebSocket(`ws://localhost:${port}/ws`); // global since Node.js 22
await once(socket, 'open');
const reply = new Promise<string>((resolve) => socket.addEventListener('message', (e) => resolve(String(e.data)), { once: true }));
socket.send(JSON.stringify({ event: 'echo', data: 'ping' }));
expect(JSON.parse(await reply)).toEqual({ event: 'echoed', data: 'ping' });
socket.close();
await app.close();
});
});
Source: Adapters.
Microservices
The scenario is a gateway service with an HTTP API that publishes review.created events to the ratings microservice and asks it for a book’s average rating. The gateway owns a ClientProxy and the ratings side has a controller with a @MessagePattern() handler and an @EventPattern() handler. For the concepts see Microservices Fundamentals and Microservice Transports.
import { z } from 'zod';
/** The `review.created` event: one definition shared by producer and consumer tests. */
export const reviewCreatedSchema = z.object({
reviewId: z.number().int(),
bookId: z.number().int(),
rating: z.number().int().min(1).max(5),
});
export type ReviewCreated = z.infer<typeof reviewCreatedSchema>;
export const RATINGS_CLIENT = 'RATINGS_CLIENT';
import { Controller } from '@nestjs/common';
import { EventPattern, MessagePattern, Payload, RpcException } from '@nestjs/microservices';
import type { ReviewCreated } from './contracts.js';
@Controller()
export class RatingsController {
private readonly ratings = new Map<number, number[]>();
// Request-response: the returned value is the reply.
@MessagePattern({ cmd: 'ratings.average' })
average(@Payload() bookId: number): number {
const ratings = this.ratings.get(bookId);
if (!ratings) throw new RpcException({ code: 'NO_RATINGS', message: `Book ${bookId} has no ratings` });
return ratings.reduce((sum, rating) => sum + rating, 0) / ratings.length;
}
// Event-based: no reply, the producer does not wait for the handler.
@EventPattern('review.created')
record(@Payload() event: ReviewCreated): void {
this.ratings.set(event.bookId, [...(this.ratings.get(event.bookId) ?? []), event.rating]);
}
}
import { Inject, Injectable, NotFoundException, RequestTimeoutException, ServiceUnavailableException } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { TimeoutError, catchError, firstValueFrom, lastValueFrom, throwError, timeout } from 'rxjs';
import { RATINGS_CLIENT, type ReviewCreated } from './contracts.js';
@Injectable()
export class ReviewsService {
constructor(@Inject(RATINGS_CLIENT) private readonly ratings: ClientProxy) {}
averageRating(bookId: number): Promise<number> {
return firstValueFrom(
this.ratings.send<number, number>({ cmd: 'ratings.average' }, bookId).pipe(
timeout(1000),
catchError((error) =>
throwError(() => (error instanceof TimeoutError ? new RequestTimeoutException('ratings is too slow') : this.map(error))),
),
),
);
}
async publish(event: ReviewCreated): Promise<void> {
// emit() is lazy-hot: awaiting it only confirms the event left this process.
await lastValueFrom(this.ratings.emit('review.created', event), { defaultValue: undefined });
}
private map(error: unknown): Error {
const code = (error as { code?: string })?.code;
return code === 'NO_RATINGS' ? new NotFoundException('no ratings yet') : new ServiceUnavailableException('ratings is unavailable');
}
}
import { DynamicModule, Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { RATINGS_CLIENT } from './contracts.js';
import { ReviewsController } from './reviews.controller.js';
import { ReviewsService } from './reviews.service.js';
@Module({})
export class ReviewsModule {
/** The address is a parameter so a test can point the module at a port it picked itself. */
static forRoot(options: { host: string; port: number }): DynamicModule {
return {
module: ReviewsModule,
imports: [ClientsModule.register([{ name: RATINGS_CLIENT, transport: Transport.TCP, options }])],
controllers: [ReviewsController],
providers: [ReviewsService],
};
}
}
The client is injected with @Inject(RATINGS_CLIENT) and registered through ClientsModule.register(). That is what makes it testable: the official documentation says the @Client() property decorator is not the preferred technique because such a client is harder to test and share. The module takes the transport address as a parameter so a test can point it at a port it chose.
Unit tests with a mocked ClientProxy
ClientProxy.send() and emit() return observables, so a fake is an object whose methods return of(value), throwError(…) or NEVER. The test checks what the service sent (pattern and payload) and how it reacts to each kind of reply. A silent peer is simulated with NEVER and fake timers, so the one-second timeout is tested in no time.
describe('unit: the client side with a mocked ClientProxy', () => {
const client = { send: vi.fn(), emit: vi.fn() };
let service: ReviewsService;
beforeEach(async () => {
vi.clearAllMocks();
const moduleRef = await Test.createTestingModule({
providers: [ReviewsService, { provide: RATINGS_CLIENT, useValue: client }],
}).compile();
service = moduleRef.get(ReviewsService);
});
afterEach(() => vi.useRealTimers());
it('sends the pattern and the payload, and returns the reply', async () => {
client.send.mockReturnValue(of(4.5));
await expect(service.averageRating(7)).resolves.toBe(4.5);
expect(client.send).toHaveBeenCalledWith({ cmd: 'ratings.average' }, 7);
});
it('turns a silent peer into a 408 (fake timers, no real waiting)', async () => {
vi.useFakeTimers();
client.send.mockReturnValue(NEVER); // a request that is never answered
const result = service.averageRating(7);
const assertion = expect(result).rejects.toBeInstanceOf(RequestTimeoutException);
await vi.advanceTimersByTimeAsync(1000);
await assertion;
});
it('maps an RpcException payload to a 404', async () => {
client.send.mockReturnValue(throwError(() => ({ code: 'NO_RATINGS', message: 'none' })));
await expect(service.averageRating(7)).rejects.toMatchObject({ status: 404 });
});
it('emits the event with its pattern', async () => {
client.emit.mockReturnValue(of(undefined));
await service.publish({ reviewId: 1, bookId: 7, rating: 5 });
expect(client.emit).toHaveBeenCalledWith('review.created', { reviewId: 1, bookId: 7, rating: 5 });
});
});
describe('unit: the server side is an ordinary controller', () => {
it('computes the average from recorded events and rejects unknown books with RpcException', () => {
const controller = new RatingsController();
controller.record({ reviewId: 1, bookId: 7, rating: 4 });
controller.record({ reviewId: 2, bookId: 7, rating: 5 });
expect(controller.average(7)).toBe(4.5);
expect(() => controller.average(8)).toThrow('Book 8 has no ratings');
});
});
The server side needs no mock at all: a controller with @MessagePattern() is a class with methods, so the test calls them. Called directly, average() throws the RpcException itself; how that exception travels over a transport is what the integration test shows.
Source: Microservices: Overview and Testing.
Testing the HTTP layer with the client faked
To test the gateway’s HTTP behaviour without starting the other service, override the client provider by its token. The real TCP client is never created, and the fake emit lets the test assert what was published.
describe('HTTP e2e with the client faked', () => {
let app: INestApplication;
const send = vi.fn();
const emit = vi.fn().mockReturnValue(of(undefined));
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [ReviewsModule.forRoot({ host: '127.0.0.1', port: 1 })] })
.overrideProvider(RATINGS_CLIENT) // the real TCP client is never created
.useValue({ send, emit })
.compile();
app = moduleRef.createNestApplication();
await app.init();
});
afterAll(() => app.close());
it('GET /books/:id/rating', async () => {
send.mockReturnValue(of(4.5));
const res = await request(app.getHttpServer()).get('/books/7/rating').expect(200);
expect(res.body).toEqual({ bookId: 7, average: 4.5 });
});
// Producer side of the contract: what the gateway emits must satisfy the shared schema.
it('POST /reviews emits an event that satisfies the shared contract', async () => {
await request(app.getHttpServer()).post('/reviews').send({ reviewId: 1, bookId: 7, rating: 5 }).expect(202);
const [pattern, payload] = emit.mock.calls.at(-1)!;
expect(pattern).toBe('review.created');
expect(reviewCreatedSchema.safeParse(payload).success).toBe(true);
});
});
Source: Testing (overriding providers).
Integration tests over a real transport
A mock cannot show that the pattern, the serialisation and the transport work together. The TCP transport needs no infrastructure, so it is the default choice for such tests: start the ratings module as a microservice with createNestMicroservice() and the gateway as a normal application pointed at the same port.
Notes on what the test below does:
-
The port comes from a helper that asks the operating system for a free one and releases it, so there is no fixed port to collide.
-
emit()is fire and forget: the HTTP call returns before the handler has run. The test therefore retries the read withvi.waitForinstead of sleeping for a guessed time. -
The
RpcExceptionthrown on theratingsside arrives at the client as an error object with the samecode, which the service maps to a 404. -
With nothing listening, the client fails with a connection error, which the service maps to a 503. Test the peer-down case: it is the one that production hits first.
/** Ask the OS for a free port, then release it for the code under test. */
function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const { port } = server.address() as { port: number };
server.close(() => resolve(port));
});
});
}
describe('integration over a real TCP transport', () => {
let ratings: INestMicroservice;
let gateway: INestApplication;
let port: number;
beforeAll(async () => {
port = await freePort();
const ratingsModule = await Test.createTestingModule({ imports: [RatingsModule] }).compile();
ratings = ratingsModule.createNestMicroservice({ transport: Transport.TCP, options: { host: '127.0.0.1', port } });
await ratings.listen();
const gatewayModule = await Test.createTestingModule({ imports: [ReviewsModule.forRoot({ host: '127.0.0.1', port })] }).compile();
gateway = gatewayModule.createNestApplication();
await gateway.init();
});
afterAll(async () => {
await gateway.close();
await ratings.close();
});
it('an emitted event is handled by the other side and a request reads the result', async () => {
await request(gateway.getHttpServer()).post('/reviews').send({ reviewId: 1, bookId: 7, rating: 4 }).expect(202);
await request(gateway.getHttpServer()).post('/reviews').send({ reviewId: 2, bookId: 7, rating: 5 }).expect(202);
// Events are fire-and-forget: poll until the handler has run instead of sleeping.
await vi.waitFor(async () => {
const res = await request(gateway.getHttpServer()).get('/books/7/rating');
expect(res.body).toEqual({ bookId: 7, average: 4.5 });
});
});
it('an RpcException on the server surfaces as a 404 through the gateway', async () => {
await request(gateway.getHttpServer()).get('/books/999/rating').expect(404);
});
});
describe('integration when the peer is down', () => {
it('answers 503 instead of hanging', async () => {
const port = await freePort(); // nothing listens here
const moduleRef = await Test.createTestingModule({ imports: [ReviewsModule.forRoot({ host: '127.0.0.1', port })] }).compile();
const app = moduleRef.createNestApplication();
await app.init();
await request(app.getHttpServer()).get('/books/7/rating').expect(503);
await app.close();
});
});
Source: Microservices: Overview.
Broker transports and Testcontainers
Redis, NATS, RabbitMQ, Kafka and MQTT behave differently from TCP (wildcards, consumer groups, acknowledgements, reconnection), so a transport-specific bug is only found against the real broker. Run that broker in a container with Testcontainers and keep the test identical apart from the transport options. See Testcontainers fundamentals for the tool itself.
import { ClientProxy, ClientProxyFactory, Transport } from '@nestjs/microservices';
import type { INestMicroservice } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { RedisContainer, type StartedRedisContainer } from '@testcontainers/redis';
import { lastValueFrom } from 'rxjs';
import { RatingsModule } from './ratings.module.js';
describe('ratings over a real Redis broker (Testcontainers)', () => {
let redis: StartedRedisContainer;
let ratings: INestMicroservice;
let client: ClientProxy;
beforeAll(async () => {
redis = await new RedisContainer('redis:8-alpine').start(); // needs a running Docker daemon
const options = { host: redis.getHost(), port: redis.getPort() };
const moduleRef = await Test.createTestingModule({ imports: [RatingsModule] }).compile();
ratings = moduleRef.createNestMicroservice({ transport: Transport.REDIS, options });
await ratings.listen();
client = ClientProxyFactory.create({ transport: Transport.REDIS, options });
await client.connect();
}, 120_000);
afterAll(async () => {
await client?.close();
await ratings?.close();
await redis?.stop();
});
it('round-trips an event and a request through the broker', async () => {
await lastValueFrom(client.emit('review.created', { reviewId: 1, bookId: 7, rating: 4 }), { defaultValue: undefined });
await vi.waitFor(async () => {
expect(await lastValueFrom(client.send({ cmd: 'ratings.average' }, 7))).toBe(4);
});
});
});
This file compiles against testcontainers 12.2 and @testcontainers/redis 12.2 and needs a running Docker daemon to execute. Treat it as a template, give the beforeAll a generous timeout because the first image pull is slow, and run these tests in a separate CI job from the fast ones.
Source: Microservices: Redis and Microservices: Overview.
Contract testing
Integration tests prove that two parts work together today. Contract tests prove that the two sides still agree after either of them changes, without starting both. Three levels of ceremony fit, from the lightest.
A schema file as the contract (GraphQL)
Write the schema printed from the application to a committed file and compare against it in a test. Vitest’s toMatchFileSnapshot() writes the file on the first run and fails on any later difference, so a schema change appears in the pull request diff and has to be acknowledged. This is the same idea as the schema check that tools such as Apollo Studio or GraphQL Inspector perform in CI, with no external service.
// Contract test: the committed file is the contract; a schema change must show up in review.
it('keeps the published schema stable', async () => {
const { schema } = app.get(GraphQLSchemaHost);
await expect(printSchema(schema)).toMatchFileSnapshot('./__snapshots__/books.schema.graphql');
});
type Author {
id: ID!
name: String!
}
type Book {
author: Author
id: ID!
pages: Int!
title: String!
}
input CreateBookInput {
pages: Int!
title: String!
}
type Mutation {
addBook(input: CreateBookInput!): Book!
}
type Query {
book(id: ID!): Book!
books: [Book!]!
}
type Subscription {
bookAdded: Book!
}
Source: GraphQL: Quick start (generating the schema).
A shared schema object (messages)
Put the description of the message in one module that both services import (a workspace library in a monorepo, see Project Structure & Workspaces; a published package otherwise). The contracts.ts file above uses zod for this. The producer’s test asserts that what it emits satisfies the schema, and the consumer’s test feeds it payloads the schema allows. Both tests appear in the microservice listings above: POST /reviews emits an event that satisfies the shared contract and the contract: review.created consumer block.
This is cheap and catches most breakages when both sides live in one repository. It cannot catch a consumer that expects more than the schema promises, or a producer deployed from a different version of the library.
Consumer-driven contracts with Pact
When the services are owned by different teams or released independently, use consumer-driven contracts. The consumer states, as a test, what it needs from the message; running the test writes a contract file. The provider then replays the contract against its real code. Pact supports message interactions, which fits event handlers like this one.
import { MatchersV3, MessageConsumerPact, asynchronousBodyHandler } from '@pact-foundation/pact';
import { join } from 'node:path';
import { RatingsController } from './ratings.controller.js';
import type { ReviewCreated } from './contracts.js';
const { integer } = MatchersV3;
describe('ratings consumes review.created', () => {
const pact = new MessageConsumerPact({
consumer: 'ratings-service',
provider: 'reviews-gateway',
dir: join(import.meta.dirname, '..', 'pacts'), // the generated contract file lands here
});
it('accepts the event the gateway publishes', () => {
return pact
.expectsToReceive('a review.created event')
.withContent({ reviewId: integer(1), bookId: integer(7), rating: integer(5) })
.withMetadata({ pattern: 'review.created' })
.verify(
asynchronousBodyHandler(async (body) => {
// Run the real handler against the example the contract describes.
const controller = new RatingsController();
controller.record(body as unknown as ReviewCreated);
expect(controller.average(7)).toBe(5);
}),
);
});
});
Running it writes pacts/ratings-service-reviews-gateway.json, which contains the example body, the metadata and the matching rules (each of the three fields must be an integer, not equal to the example value). The provider side verifies that file against the code that publishes the event:
import { MessageProviderPact, providerWithMetadata } from '@pact-foundation/pact';
import { join } from 'node:path';
import { of } from 'rxjs';
import type { ClientProxy } from '@nestjs/microservices';
import { ReviewsService } from './reviews.service.js';
describe('reviews-gateway honours the ratings-service contract', () => {
it('publishes the events the consumer expects', () => {
let published: unknown;
const client = {
emit: (_pattern: string, event: unknown) => {
published = event; // capture instead of sending
return of(undefined);
},
} as unknown as ClientProxy;
const service = new ReviewsService(client);
return new MessageProviderPact({
provider: 'reviews-gateway',
pactUrls: [join(import.meta.dirname, '..', 'pacts', 'ratings-service-reviews-gateway.json')],
messageProviders: {
// The key is the interaction description from the contract.
'a review.created event': providerWithMetadata(
async () => {
await service.publish({ reviewId: 1, bookId: 7, rating: 5 });
return published;
},
{ pattern: 'review.created' }, // the metadata the contract expects next to the body
),
},
logLevel: 'warn',
}).verify();
});
});
A common first failure is worth seeing once: when the contract carries the metadata pattern: 'review.created' and the provider returns only a body, Pact reports Expected message metadata 'pattern' but was missing. providerWithMetadata() is how a provider supplies the metadata along with the body.
These two specs use @pact-foundation/pact 17.1 and import the page’s RatingsController and ReviewsService. In a real setup the pact file is published to a Pact Broker (or PactFlow) so the provider’s pipeline fetches the latest contracts and can-i-deploy gates releases.
Source: Pact documentation and Microservices: Overview.
Load testing with Artillery and k6
Load tests run against a running, production-like instance, not inside the test runner. They answer a different question from the tests above (how many users, how fast, and where does it break), so keep them in their own directory and their own CI job. Start the application from the built output (node dist/main.js) with production settings; running the dev server or Vitest’s in-process app measures the wrong thing. The Performance Optimization page covers what to change once a load test shows a problem.
Artillery
Artillery describes a load test in YAML: phases set the arrival rate of virtual users over time, and scenarios are the flows they run. A scenario with engine: socketio drives Socket.IO, so one file can load the GraphQL endpoint and the chat gateway together. Thresholds in ensure turn the run into a pass or fail result by exit code.
config:
target: http://localhost:3000
phases:
- name: Warm up
duration: 10
arrivalRate: 2
- name: Ramp up
duration: 20
arrivalRate: 2
rampTo: 20
- name: Sustained load
duration: 30
arrivalRate: 20
# Socket.IO options for every `engine: socketio` scenario ($uuid gives each virtual user its own name).
socketio:
transports: ["websocket"]
auth:
user: "vu-{{ $uuid }}"
# `expect` checks the responses; `ensure` turns thresholds into an exit code. Both are opt-in plugins.
plugins:
expect: {}
ensure: {}
# The run exits non-zero when a threshold is broken, which is what fails a CI job.
ensure:
thresholds:
- http.response_time.p95: 300
- http.response_time.p99: 600
maxErrorRate: 1
scenarios:
- name: Browse the catalogue over GraphQL
weight: 8
flow:
- post:
url: /graphql
json:
query: "{ books { id title author { name } } }"
expect:
- statusCode: 200
- hasProperty: data.books
- name: Add a book
weight: 1
flow:
- post:
url: /graphql
headers:
x-api-key: "{{ $processEnvironment.LOAD_API_KEY }}"
json:
query: "mutation($input: CreateBookInput!) { addBook(input: $input) { id } }"
variables:
input:
title: "Load test {{ $randomNumber(1, 100000) }}"
pages: 100
capture:
- json: $.data.addBook.id
as: bookId
expect:
- statusCode: 200
- name: Chat in a room
weight: 3
engine: socketio
flow:
- emit:
channel: join
data:
bookId: 1
namespace: /chat
acknowledge:
data:
room: "book:1"
- loop:
- emit:
channel: send
data:
bookId: 1
text: "hello from {{ $uuid }}"
namespace: /chat
acknowledge:
data:
delivered: true
- think: 1
count: 5
-
The
expectandensureplugins are opt-in. Withoutplugins.ensure, the thresholds are silently ignored (Artillery exits 0 even with an impossible p95 of 0); with it Artillery printsfail: http.response_time.p95 < 0and exits 1. -
$processEnvironmentreads environment variables, so the API key comes from the environment of the process that runs Artillery and is not stored in the file. -
The
namespaceis a sibling ofemit, not part of it, andacknowledgechecks the value the gateway returned from the handler. -
--targetoverrides thetargetfrom the command line, which is how the same file serves local, staging and CI runs.
npm i -D artillery
LOAD_API_KEY=<your-key> npx artillery run --target http://localhost:3000 load/bookshelf.artillery.yml
With Artillery 2.0.34 against the page’s application started from compiled output on a local port, the file produces about 840 virtual users in 66 seconds (635 GraphQL requests, all HTTP 200, 1230 Socket.IO emits, no failed virtual users). Latencies measured that way (a few milliseconds) say nothing about a real deployment, because client and server share one machine; they only show that the file is valid and that the scenarios, acknowledgements and thresholds work.
k6
k6 scripts are JavaScript modules executed by the k6 runtime (not by Node.js), which makes them fast and means they cannot import npm packages freely. Scenarios choose an executor; ramping-arrival-rate is the open-model equivalent of Artillery’s arrivalRate, and thresholds decide the exit code.
import http from 'k6/http';
import { check, sleep } from 'k6';
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
export const options = {
scenarios: {
browse: {
executor: 'ramping-arrival-rate',
startRate: 2,
timeUnit: '1s',
preAllocatedVUs: 50,
stages: [
{ target: 20, duration: '20s' },
{ target: 20, duration: '30s' },
],
},
},
// k6 exits with a non-zero code when a threshold fails.
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<300', 'p(99)<600'],
},
};
export default function () {
const res = http.post(
`${BASE_URL}/graphql`,
JSON.stringify({ query: '{ books { id title author { name } } }' }),
{ headers: { 'Content-Type': 'application/json' } },
);
check(res, {
'status is 200': (r) => r.status === 200,
'no GraphQL errors': (r) => r.json('errors') === undefined,
});
sleep(1);
}
k6’s stable WebSocket module is k6/websockets (the k6/experimental/websockets module was deprecated in its favour). It speaks plain WebSocket, so it can load a gateway on the ws adapter, as in the native-client example above, but not a Socket.IO gateway, which uses its own protocol on top of WebSocket; use Artillery’s socketio engine for those.
import { check } from 'k6';
import { WebSocket } from 'k6/websockets';
const WS_URL = __ENV.WS_URL || 'ws://localhost:3000/ws';
export const options = {
vus: 20,
duration: '30s',
thresholds: { checks: ['rate>0.99'] },
};
// k6 speaks plain WebSocket: this targets a gateway on the `ws` adapter, where a frame is
// { event, data }. A Socket.IO gateway needs Artillery's socketio engine instead.
export default function () {
const socket = new WebSocket(WS_URL);
socket.onopen = () => {
socket.send(JSON.stringify({ event: 'echo', data: 'ping' }));
setTimeout(() => socket.close(), 2000); // safety net if no reply arrives
};
socket.onmessage = (message) => {
check(message.data, { echoed: (data) => JSON.parse(data).event === 'echoed' });
socket.close();
};
}
k6 run --env BASE_URL=http://localhost:3000 load/bookshelf.k6.js
k6 run --env WS_URL=ws://localhost:3000/ws load/bookshelf-ws.k6.js
The API names in these two scripts follow the k6 documentation. Run them against a staging instance before trusting them.
Source: k6: WebSockets, k6: Scenarios and k6: Thresholds.
| Artillery | k6 | |
|---|---|---|
Language |
YAML, with optional JavaScript hooks |
JavaScript run by the k6 runtime |
Socket.IO |
Built-in |
Not supported (plain WebSocket only) |
Installs as |
An npm package |
A separate binary |
Pass or fail |
|
|
Where older material is outdated
-
Older tutorials test GraphQL by creating an Apollo test client or by mocking
GraphQLModuleitself. A resolver needs neither: unit test it as a provider, and test the schema by building the real module and using supertest orGraphQLSchemaHost. -
Many WebSocket examples connect to a fixed port such as 3000 and use
setTimeoutto wait for messages. Use port 0 and an event wait with a timeout. -
Examples of microservice tests often create the client with the
@Client()decorator, which cannot be overridden through the container. Register it withClientsModuleand override the provider. -
Copying Jest-only setup into a current ESM project: the current Nest template ships Vitest, and
jest.fn()becomesvi.fn(). See Testing for both.
What changed recently
-
Node.js provides a global
WebSocketclient (22 and later), so native-client tests need nowspackage. -
The WebSocket exception event carries a
causewith the pattern and data of the message that failed, documented on the gateway exception filter page. -
k6/websocketsreplacedk6/experimental/websockets. -
Artillery 2 has
ensureandexpectas plugins that must be enabled inconfig.plugins.