Knead

Search every guide and package page, down to section headings.

↑↓ move Enter open Esc close

Techniques

Testing

Boot real modules in tests, replace providers, guards, middleware and filters, and send requests to an ephemeral server with @knead-js/testing.

On this page 11 sections

@knead-js/testing builds a real application from the modules you name, with any providers you choose replaced, and can start it on a random local port so that tests send real HTTP, GraphQL and WebSocket traffic. Tests run with bun test; the examples use bun:test.

Terminal
bun add -d @knead-js/testing

@knead-js/core and @knead-js/http are peer dependencies, which your application already has.

#The application under test

The examples on this page test a small catalog module: a service, a controller with a guarded route, a middleware that sets x-request-id, and an exception filter that turns a storage error into a 503.

catalog.ts
import { Injectable, inject, LOGGER, Module } from '@knead-js/core';
import {
  Controller,
  type Ctx,
  type ExceptionFilter,
  Get,
  type Guard,
  type Middleware,
  type Next,
  NotFoundException,
  Post,
  UseFilters,
  UseGuards,
  UseMiddleware,
} from '@knead-js/http';

export interface Product {
  id: string;
  name: string;
  priceCents: number;
  tags: string[];
}

@Injectable()
export class ProductsService {
  readonly #products = new Map<string, Product>([
    ['p1', { id: 'p1', name: 'Kettle', priceCents: 3900, tags: ['kitchen'] }],
    ['p2', { id: 'p2', name: 'Desk lamp', priceCents: 2500, tags: ['office', 'lighting'] }],
  ]);

  find(id: string): Product | undefined {
    return this.#products.get(id);
  }

  list(tags: readonly string[]): Product[] {
    const all = [...this.#products.values()];
    return tags.length === 0 ? all : all.filter((p) => tags.some((t) => p.tags.includes(t)));
  }

  add(input: Omit<Product, 'id'>): Product {
    const product = { id: `p${this.#products.size + 1}`, ...input };
    this.#products.set(product.id, product);
    return product;
  }
}

/** Lets a request through only with the admin API key. */
export class AdminGuard implements Guard {
  canActivate(ctx: Ctx): boolean {
    return ctx.req.headers.get('x-api-key') === process.env.ADMIN_API_KEY;
  }
}

/** Echoes or creates an `x-request-id` header. */
export class RequestIdMiddleware implements Middleware {
  async use(ctx: Ctx, next: Next): Promise<Response> {
    const id = ctx.req.headers.get('x-request-id') ?? crypto.randomUUID();
    const res = await next();
    res.headers.set('x-request-id', id);
    return res;
  }
}

/** Thrown when the product store cannot be reached. */
export class StorageError extends Error {
  override name = 'StorageError';
}

/** Answers StorageError with a 503. */
export class StorageErrorFilter implements ExceptionFilter {
  readonly #log = inject(LOGGER);

  catch(error: unknown): Response | undefined {
    if (!(error instanceof StorageError)) return undefined;
    this.#log.warn('catalog storage unavailable');
    return Response.json({ error: 'catalog unavailable' }, { status: 503 });
  }
}

@Controller('/products')
@UseMiddleware(RequestIdMiddleware)
@UseFilters(StorageErrorFilter)
export class ProductsController {
  readonly #products = inject(ProductsService);

  @Get('/')
  list(ctx: Ctx) {
    const tag = ctx.query.tag ?? [];
    return this.#products.list(typeof tag === 'string' ? [tag] : tag);
  }

  @Get('/:id')
  get(ctx: Ctx<{ params: { id: string } }>) {
    const product = this.#products.find(ctx.params.id);
    if (!product) throw new NotFoundException(`No product ${ctx.params.id}`);
    return product;
  }

  @Post('/')
  @UseGuards(AdminGuard)
  async create(ctx: Ctx) {
    return this.#products.add(await ctx.json<Omit<Product, 'id'>>());
  }
}

@Module({
  controllers: [ProductsController],
  providers: [ProductsService],
  exports: [ProductsService],
})
export class CatalogModule {}

#Compile a testing module

Test.createModule(metadata) takes the same metadata as @Module() (imports, providers, controllers, exports) for a root module it creates, and returns a TestingModuleBuilder. compile() creates the application, exactly as KneadFactory.create() would: every provider is instantiated and the onModuleInit and onApplicationBootstrap hooks run. mod.get(token) returns a provider:

catalog.test.ts
test('finds a product by id', async () => {
  mod = await Test.createModule({ imports: [CatalogModule] }).compile();

  expect(mod.get(ProductsService).find('p1')?.name).toBe('Kettle');
});

Close every module you compile, so shutdown hooks run and servers stop. An afterEach hook does it for every test in the file:

catalog.test.ts
let mod: TestingModule | undefined;

afterEach(async () => {
  // Stops the server (if one was started) and runs the shutdown hooks.
  await mod?.close();
  mod = undefined;
});

compile() does not listen on a port. Tests that only call providers never open a socket.

#Send requests

mod.createApp() starts the application's server on 127.0.0.1 with a random free port and returns a TestApp. app.request(method, path, init) sends a request to it with fetch() and returns the Response:

catalog.test.ts
test('filters by tag and echoes the request id', async () => {
  mod = await Test.createModule({ imports: [CatalogModule] }).compile();
  const app = await mod.createApp();

  const res = await app.request('GET', '/products', {
    query: { tag: ['office', 'garden'] },
    headers: { 'x-request-id': 'req-42' },
  });

  expect(res.status).toBe(200);
  expect(res.headers.get('x-request-id')).toBe('req-42');
  expect(((await res.json()) as Product[]).map((p) => p.id)).toEqual(['p2']);
});

The init options:

  • query: appended to the URL; an array value repeats the parameter (tag=office&tag=garden).
  • headers: any HeadersInit.
  • json: serialized with JSON.stringify, and Content-Type: application/json is set unless your headers already have a content type.
  • body: a raw body (a string, FormData, Blob and so on), for other content types. json wins when both are given.
  • tls: options for an HTTPS test server; see HTTPS.

Responses are the framework's real responses, so errors arrive as problem details:

catalog.test.ts
test('answers an unknown id with a 404 problem', async () => {
  mod = await Test.createModule({ imports: [CatalogModule] }).compile();
  const app = await mod.createApp();

  const res = await app.request('GET', '/products/p9');

  expect(res.status).toBe(404);
  expect(await res.json()).toEqual({
    type: 'about:blank',
    title: 'Not Found',
    status: 404,
    detail: 'No product p9',
    instance: '/products/p9',
  });
});

createApp() creates the server once: calling it again returns the same TestApp and ignores the new options. It takes the same options as listen() (ListenOptions from @knead-js/http), with port: 0 and hostname: '127.0.0.1' as defaults. app.url is the server's URL.

#Override providers

The builder replaces any provider before the application is created. overrideProvider(token) picks the provider, and one of three methods supplies the replacement:

catalog.test.ts
test('useValue replaces a provider with a ready instance', async () => {
  const seeded = new ProductsService();
  seeded.add({ name: 'Bike light', priceCents: 1800, tags: ['cycling'] });
  mod = await Test.createModule({ imports: [CatalogModule] })
    .overrideProvider(ProductsService)
    .useValue(seeded)
    .compile();
  const app = await mod.createApp();

  const res = await app.request('GET', '/products', { query: { tag: 'cycling' } });

  expect(((await res.json()) as Product[]).map((p) => p.name)).toEqual(['Bike light']);
});

useValue(value) uses the value as is. useClass(cls) constructs a class in its place, which may inject() like any provider:

catalog.test.ts
class OneProductService extends ProductsService {
  override list(): Product[] {
    return [{ id: 'p1', name: 'Kettle', priceCents: 3900, tags: [] }];
  }
}

test('useClass constructs another class in its place', async () => {
  mod = await Test.createModule({ imports: [CatalogModule] })
    .overrideProvider(ProductsService)
    .useClass(OneProductService)
    .compile();

  expect(mod.get(ProductsService)).toBeInstanceOf(OneProductService);
});

useFactory(factory) calls a function, inside the injection context, and uses its result. For providers, the factory may be async:

catalog.test.ts
test('useFactory builds the replacement, and may be async for providers', async () => {
  mod = await Test.createModule({ imports: [CatalogModule] })
    .overrideProvider(ProductsService)
    .useFactory(async () => {
      const service = new ProductsService();
      service.add({ name: 'Seeded', priceCents: 100, tags: [] });
      return service;
    })
    .compile();

  expect(mod.get(ProductsService).find('p3')?.name).toBe('Seeded');
});

Each method returns the builder, so overrides chain. Overriding the same token twice keeps the last replacement. A replacement applies to every module that registers the token, and an override for a token that no module provides has no effect: mod.get() still fails with NoProviderError. multi: true providers cannot be overridden.

A class with #private fields, like ProductsService, cannot be imitated by a plain object, so these tests replace it with subclasses:

catalog.test.ts
/** A store whose lookups fail, for testing error paths. */
class FailingProducts extends ProductsService {
  override find(): Product | undefined {
    throw new Error('disk on fire');
  }
}

/** A store that is unreachable. */
class UnavailableProducts extends ProductsService {
  override list(): Product[] {
    throw new StorageError('replica lag');
  }
}

#Override guards, middleware and filters

Guards, middleware and exception filters referenced by class (@UseGuards(AdminGuard)) are constructed by the framework rather than registered as providers. overrideGuard(), overrideMiddleware() and overrideFilter() replace them the same way:

catalog.test.ts
test('rejects requests without the admin key', async () => {
  mod = await Test.createModule({ imports: [CatalogModule] }).compile();
  const app = await mod.createApp();

  const res = await app.request('POST', '/products', { json: { name: 'Mug' } });

  expect(res.status).toBe(403);
});

test('creates a product when the guard allows it', async () => {
  mod = await Test.createModule({ imports: [CatalogModule] })
    .overrideGuard(AdminGuard)
    .useValue({ canActivate: () => true })
    .compile();
  const app = await mod.createApp();

  const res = await app.request('POST', '/products', {
    json: { name: 'Mug', priceCents: 1200, tags: ['kitchen'] },
  });

  expect(await res.json()).toEqual({
    id: 'p3',
    name: 'Mug',
    priceCents: 1200,
    tags: ['kitchen'],
  });
});
catalog.test.ts
test('middleware and filters are replaced like guards', async () => {
  mod = await Test.createModule({ imports: [CatalogModule] })
    .overrideMiddleware(RequestIdMiddleware)
    .useValue({ use: (_ctx, next) => next() })
    .overrideFilter(StorageErrorFilter)
    .useValue({ catch: () => Response.json({ error: 'test filter' }, { status: 418 }) })
    .overrideProvider(ProductsService)
    .useClass(FailingProducts)
    .compile();
  const app = await mod.createApp();

  const res = await app.request('GET', '/products/p1');

  expect(res.headers.get('x-request-id')).toBeNull();
  expect(res.status).toBe(418);
});

The replacement is used wherever that class is referenced. These overrides accept useValue, useClass and useFactory, but a factory must be synchronous: an async factory works only for a token that is registered as a provider. An override matches a class reference only; a guard, middleware or filter passed as an instance or a plain function cannot be overridden.

Remember that a guard passes only when it returns exactly true, so a replacement such as { canActivate: () => true } must return the boolean, not a truthy value. See guards.

#Test logs

compile() gives the application a MemoryLogger as its LOGGER, in every module, even when the application imports LoggingModule or registers its own LOGGER. mod.logs lists every entry logged so far, as { level, message, meta } objects:

catalog.test.ts
test('captures log entries', async () => {
  mod = await Test.createModule({ imports: [CatalogModule] })
    .overrideProvider(ProductsService)
    .useClass(UnavailableProducts)
    .compile();
  const app = await mod.createApp();

  const res = await app.request('GET', '/products');

  expect(res.status).toBe(503);
  expect(mod.logs).toContainEqual({
    level: 'warn',
    message: 'catalog storage unavailable',
    meta: [],
  });
});

A MemoryLogger keeps entries exactly as logged, without redaction. It is for tests only. Messages that the framework redacts before logging (such as unhandled request errors) are still redacted, because the redaction happens before LOGGER is called.

A logger wrapped with childLogger(inject(LOGGER), fields) passes its fields as the first meta item, so they appear in mod.logs too. See child loggers.

To keep your own logger, pass it to compile({ logger }); mod.logger is the logger in use, and mod.logs is empty unless it is a MemoryLogger:

catalog.test.ts
test('compile({ logger }) takes your own logger', async () => {
  const logger = new MemoryLogger();
  mod = await Test.createModule({ imports: [CatalogModule] }).compile({ logger });

  expect(mod.logger).toBe(logger);
});

An overrideProvider(LOGGER) replacement wins over both, but then mod.logs does not see the entries, since mod.logger is still the testing logger.

#Development mode

compile() creates the application in development mode unless you pass development: false. Development mode makes 500 responses carry the error's detail and stack, which helps when a test fails, and logs errors in full:

catalog.test.ts
test('development is on by default, so 500 responses carry the error', async () => {
  mod = await Test.createModule({ imports: [CatalogModule] })
    .overrideProvider(ProductsService)
    .useClass(FailingProducts)
    .compile();
  const app = await mod.createApp();

  const body = (await (await app.request('GET', '/products/p1')).json()) as {
    detail?: string;
  };

  expect(body.detail).toBe('disk on fire');
  expect(mod.logs.some((entry) => entry.level === 'error')).toBe(true);
});

test('development: false tests the production response', async () => {
  mod = await Test.createModule({ imports: [CatalogModule] })
    .overrideProvider(ProductsService)
    .useClass(FailingProducts)
    .compile({ development: false });
  const app = await mod.createApp();

  const body = (await (await app.request('GET', '/products/p1')).json()) as {
    detail?: string;
    correlationId: string;
  };

  expect(body.detail).toBeUndefined();
  expect(mod.logs).toContainEqual({
    level: 'error',
    message: `Unhandled error on GET /products/p1 (correlation id ${body.correlationId}): Error: disk on fire`,
    meta: [],
  });
});

Development mode also switches on every development-only default in other packages. Pass development: false to test what production clients get: masked errors, and for GraphQL, no introspection or playground.

#GraphQL

app.graphql(query, options) POSTs an operation as JSON to /graphql and returns the parsed body with the response's status and headers. app.graphqlSubscribe(query, options) opens a graphql-transport-ws connection and returns an async iterator of results. Here is a module with a query, a mutation and a subscription:

stock.graphql.ts
import { Injectable, inject, Module } from '@knead-js/core';
import {
  Field,
  GraphQLModule,
  ID,
  Int,
  Mutation,
  ObjectType,
  PUBSUB,
  Query,
  Resolver,
  type ResolverCtx,
  Subscription,
} from '@knead-js/graphql';

@ObjectType()
export class StockLevel {
  @Field(() => ID) sku!: string;
  @Field(() => Int) quantity!: number;
}

@Injectable()
export class StockService {
  readonly #levels = new Map<string, number>([['KETTLE-1', 12]]);

  get(sku: string): StockLevel | null {
    const quantity = this.#levels.get(sku);
    return quantity === undefined ? null : { sku, quantity };
  }

  set(sku: string, quantity: number): StockLevel {
    this.#levels.set(sku, quantity);
    return { sku, quantity };
  }
}

@Resolver(() => StockLevel)
export class StockResolver {
  readonly #stock = inject(StockService);
  readonly #pubsub = inject(PUBSUB);

  @Query(() => StockLevel, { nullable: true, args: { sku: ID } })
  stock(ctx: ResolverCtx<{ sku: string }>) {
    return this.#stock.get(ctx.args.sku);
  }

  @Mutation(() => StockLevel, { args: { sku: ID, quantity: Int } })
  async setStock(ctx: ResolverCtx<{ sku: string; quantity: number }>) {
    const level = this.#stock.set(ctx.args.sku, ctx.args.quantity);
    await this.#pubsub.publish('stock', { ...level });
    return level;
  }

  @Subscription(() => StockLevel, { topic: 'stock' })
  stockChanged() {}
}

@Module({
  imports: [GraphQLModule.forRoot()],
  providers: [StockService, StockResolver],
})
export class StockModule {}
stock.test.ts
test('queries stock over HTTP', async () => {
  // development: false keeps production defaults: masked errors, no schema.graphql written.
  mod = await Test.createModule({ imports: [StockModule] }).compile({ development: false });
  const app = await mod.createApp();

  const res = await app.graphql<{ stock: { quantity: number } | null }>(
    'query Stock($sku: ID!) { stock(sku: $sku) { quantity } }',
    { variables: { sku: 'KETTLE-1' } },
  );

  expect(res.status).toBe(200);
  expect(res.errors).toBeUndefined();
  expect(res.data).toEqual({ stock: { quantity: 12 } });
});

Errors arrive in res.errors, with the code in extensions.code:

stock.test.ts
test('reports invalid variables as BAD_USER_INPUT', async () => {
  mod = await Test.createModule({ imports: [StockModule] }).compile({ development: false });
  const app = await mod.createApp();

  const res = await app.graphql('query Stock($sku: ID!) { stock(sku: $sku) { quantity } }');

  expect(res.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT');
});

Both methods take variables, operationName, headers and path (default '/graphql'). graphqlSubscribe() also takes connectionParams, sent as the connection_init payload: that is where a subscription sends its credentials, since browsers cannot set headers on a WebSocket.

#Subscriptions

graphqlSubscribe() resolves once the server acknowledges the connection (connection_ack). When the server closes the socket first, it rejects with WebSocket closed before connection_ack: <code> <reason>, so rejects.toThrow('4403') asserts that onConnect refused the client. The returned iterator yields each next payload, ends when the server completes the subscription, and yields an error message as { errors } before ending. If the server closes the socket with any code other than 1000 first (for example closeWhere() revoking it with 1008), next() rejects with WebSocket closed: <code> <reason> instead of ending quietly, so a revoked subscription cannot pass for a completed one. close() sends complete and closes the socket; breaking out of a for await loop does the same.

The server registers the subscription shortly after the acknowledgment, so an event published right away can be missed. Trigger the event until the subscription yields, as nextEvent does here:

stock.test.ts
/**
 * Runs `trigger` until the subscription yields. The server registers a subscription shortly
 * after connection_ack, so an event published right away could be missed.
 */
async function nextEvent<T>(
  subscription: GraphQLSubscription<T>,
  trigger: () => Promise<unknown>,
): Promise<GraphQLPayload<T>> {
  const next = subscription.next();
  for (let attempt = 0; attempt < 40; attempt++) {
    await trigger();
    const result = await Promise.race([next, Bun.sleep(50).then(() => undefined)]);
    if (result?.done) throw new Error('subscription ended without an event');
    if (result) return result.value;
  }
  throw new Error('no subscription event received');
}

test('pushes stock changes to subscribers', async () => {
  mod = await Test.createModule({ imports: [StockModule] }).compile({ development: false });
  const app = await mod.createApp();
  const subscription = await app.graphqlSubscribe<{
    stockChanged: { sku: string; quantity: number };
  }>('subscription { stockChanged { sku quantity } }');

  const event = await nextEvent(subscription, () =>
    app.graphql('mutation { setStock(sku: "KETTLE-1", quantity: 3) { quantity } }'),
  );

  expect(event.data).toEqual({ stockChanged: { sku: 'KETTLE-1', quantity: 3 } });
  await subscription.close();
});

This works with any pubsub engine, including RedisPubSub, where subscribing takes a round trip.

#HTTPS

To test an HTTPS server, pass tls to createApp() and accept the self-signed certificate per request with tls: { rejectUnauthorized: false }:

tls.test.ts
test('serves HTTPS', async () => {
  const mod = await Test.createModule({ controllers: [WhoAmIController] }).compile();
  try {
    const app = await mod.createApp({ tls: certificate() });

    // Accepts the self-signed certificate: for tests only, never in application code.
    const res = await app.request('GET', '/whoami', { tls: { rejectUnauthorized: false } });

    expect(app.url.protocol).toBe('https:');
    expect(await res.json()).toEqual({ secure: true });
  } finally {
    await mod.close();
  }
});

rejectUnauthorized: false skips certificate verification. It belongs in tests only. A throwaway certificate for 127.0.0.1 can be made with OpenSSL:

Terminal
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
  -keyout key.pem -out cert.pem -days 1 -subj /CN=localhost \
  -addext subjectAltName=DNS:localhost,IP:127.0.0.1

#Configuration in tests

ConfigModule reads process.env when the testing module compiles. To make tests independent of the machine, override the configuration handle with a ready Config, or pass values. See configuration testing.

The full API is on the @knead-js/testing reference page.