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

Table 1. What each kind of test is for
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).

books.resolver.ts
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);
  }
}
api-key.guard.ts
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';
  }
}
books.module.ts
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:

vitest.config.gql.ts
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.

Resolver unit tests
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.

Guard unit test
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);
  });
});

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.

GraphQL e2e tests
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. A NotFoundException does not become the code NOT_FOUND; the code is INTERNAL_SERVER_ERROR and the HTTP status is carried in extensions.status.

  • A guard rejection gets the code FORBIDDEN, and a validation failure from the global ValidationPipe gets BAD_REQUEST with the class-validator messages inside extensions.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.

Output of the error cases (formatted for reading)
{"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.

Executing queries and subscriptions through the schema
  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.

chat.gateway.ts
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 };
  }
}
chat.dto.ts
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.

Gateway unit tests
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 from app.getHttpServer().address().

  • Never await a 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 nextEvent helper does that.

  • Disconnect every client in afterEach and close the application in afterAll; 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.

Gateway integration tests
/** 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:

The exception event received by the client
{"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.

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.

Gateway on the ws adapter
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.

contracts.ts
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';
ratings.controller.ts
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]);
  }
}
reviews.service.ts
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');
  }
}
reviews.module.ts
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.

Unit tests of the gateway service and the ratings controller
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.

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.

HTTP e2e test with an overridden client
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 with vi.waitFor instead of sleeping for a guessed time.

  • The RpcException thrown on the ratings side arrives at the client as an error object with the same code, 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.

Integration tests over TCP
/** 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();
  });
});

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.

Redis transport against a real broker
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.

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.

Schema snapshot test
  // 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');
  });
test/snapshots/books.schema.graphql (written by the first run)
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.

Consumer test: the ratings service states what it needs
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:

Provider verification: the gateway proves it honours the contract
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.

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.

load/bookshelf.artillery.yml
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 expect and ensure plugins are opt-in. Without plugins.ensure, the thresholds are silently ignored (Artillery exits 0 even with an impossible p95 of 0); with it Artillery prints fail: http.response_time.p95 < 0 and exits 1.

  • $processEnvironment reads environment variables, so the API key comes from the environment of the process that runs Artillery and is not stored in the file.

  • The namespace is a sibling of emit, not part of it, and acknowledge checks the value the gateway returned from the handler.

  • --target overrides the target from the command line, which is how the same file serves local, staging and CI runs.

Running it
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.

load/bookshelf.k6.js
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.

load/bookshelf-ws.k6.js
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();
  };
}
Running them
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.

Table 2. Artillery or k6
Artillery k6

Language

YAML, with optional JavaScript hooks

JavaScript run by the k6 runtime

Socket.IO

Built-in socketio engine

Not supported (plain WebSocket only)

Installs as

An npm package

A separate binary

Pass or fail

ensure plugin

thresholds

Where older material is outdated

  • Older tutorials test GraphQL by creating an Apollo test client or by mocking GraphQLModule itself. A resolver needs neither: unit test it as a provider, and test the schema by building the real module and using supertest or GraphQLSchemaHost.

  • Many WebSocket examples connect to a fixed port such as 3000 and use setTimeout to 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 with ClientsModule and override the provider.

  • Copying Jest-only setup into a current ESM project: the current Nest template ships Vitest, and jest.fn() becomes vi.fn(). See Testing for both.

What changed recently

  • Node.js provides a global WebSocket client (22 and later), so native-client tests need no ws package.

  • The WebSocket exception event carries a cause with the pattern and data of the message that failed, documented on the gateway exception filter page.

  • k6/websockets replaced k6/experimental/websockets.

  • Artillery 2 has ensure and expect as plugins that must be enabled in config.plugins.