Testing Benzene
Benzene services are built to be tested at two levels: a handler in isolation — the fast unit
test, calling handleAsync directly and asserting the result — and a whole
transport pipeline end to end — building an in-memory host from the same wiring you deploy, feeding
it a native cloud event, and asserting the transport-native response. The end-to-end approach is the
recommended one, since it exercises routing, request mapping, middleware, and your handler exactly as
production does rather than any one piece in isolation.
Overview
The test helpers live in three packages, all dev-only:
@benzene/testing— the platform-neutral request buildersmessageBuilder(topic, body)andhttpBuilder(method, path, body), plusasBenzeneMessage/asRawHttpRequest. These describe a request once, independent of transport.@benzene/aws-lambda-testing— turns a builder into a native AWS event (asApiGatewayRequest,asSqs,asSns,asEventBridge,asAwsKafkaEvent,asDynamoDb,asKinesis,asS3, …).@benzene/azure-function-testing— the Azure counterpart (asAzureHttpRequest,asAzureServiceBusMessage,asEventHubBenzeneMessage,asAzureKafkaEvent).
You then drive the generated event through a real app booted from its own BenzeneStartUp with the
benzeneTestHost startup-host harness. A BenzeneStartUp is a small class with two methods — the same
configureServices / configure split every host uses — that a test boots as-is:
// src/OrdersStartUp.ts
import { IBenzeneServiceContainer } from '@benzene/abstractions';
import { IBenzeneApplicationBuilder } from '@benzene/abstractions-middleware';
import { BenzeneStartUp, BenzeneConfiguration } from '@benzene/testing';
import { useMessageHandlers } from '@benzene/core-message-handlers';
import { useAwsLambda } from '@benzene/aws-lambda-core';
import { useApiGateway } from '@benzene/aws-lambda-api-gateway';
import { CreateOrderHandler } from './handlers.js';
export class OrdersStartUp implements BenzeneStartUp {
// `configuration` is the settings threaded into both methods (produced by an optional
// `getConfiguration()`); a test layers overrides on top with `.withConfiguration(...)`.
configureServices(services: IBenzeneServiceContainer, _configuration: BenzeneConfiguration): void {
// register your real services here — a test overrides any of them via `.withServices(...)`
}
configure(app: IBenzeneApplicationBuilder, _configuration: BenzeneConfiguration): void {
useAwsLambda(app, (aws) => useApiGateway(aws, (api) => useMessageHandlers(api, CreateOrderHandler)));
}
}
Booting it is then the same construction a deployed AWS Lambda or Azure Functions host performs:
const fake = new FakeBenzeneMessageSender();
const host = benzeneTestHost(OrdersStartUp) // boot the REAL app from its startup
.withServices((s) => s.addSingletonInstance(IBenzeneMessageSender, fake)) // override ANY registration
.buildAwsLambdaHost(); // the ONE transport/cloud-specific line
const response = await host.sendEventAsync<APIGatewayProxyResult>(
asApiGatewayRequest(httpBuilder('POST', '/orders', order)),
);
expect(response.statusCode).toBe(201); // assert the native response…
expect(fake.lastTopic).toBe('order:created'); // …and the egress
To test the same handlers on Azure, only the .buildAwsLambdaHost() line (→ .buildAzureFunctionApp())
and the as* builder change; everything else is identical. The worked exemplars live in
test/Benzene.Core.Test/Testing/BenzeneTestHostTest.test.ts.
Fidelity to .NET.
benzeneTestHost(StartUp)ports the .NETBenzeneTestHost/BenzeneTestHostBuilder(benzeneTestHost→Create,.withServices→WithServices,.withConfiguration→WithConfiguration,.buildAwsLambdaHost()/.buildAzureFunctionApp()→ theBuild*Hostextensions). Two idiom bends are recorded in the README: the neutralBenzeneStartUpis generic over its app-builder type (the per-transport app-builder unification is still deferred), and theBuild*extension is a fluent method added by module augmentation. You can still drive a transport directly via itsInline*StartUpentry point when you want to bypass a startup class entirely (see Notes and limitations).
Installation
npm install --save-dev vitest @benzene/testing @benzene/aws-lambda-testing @benzene/azure-function-testing
Add only the transport testing package you actually use. Tests run under vitest
(npm test), the port's counterpart to the .NET suite's xUnit.
The examples below assume this handler, which pushes its logic into an injected service so the handler itself stays thin (see Message Handlers):
// src/GreetingService.ts
import { ServiceToken, serviceToken } from '@benzene/abstractions';
export interface IGreetingService {
greetAsync(name: string): Promise<string>;
}
// A resolved interface declares a merged ServiceToken constant of the same name (README convention).
export const IGreetingService: ServiceToken<IGreetingService> =
serviceToken<IGreetingService>('IGreetingService');
// src/HelloWorldHandler.ts
import { IBenzeneResultOf } from '@benzene/abstractions';
import { IMessageHandler } from '@benzene/abstractions-message-handlers';
import { message } from '@benzene/core-message-handlers';
import { httpEndpoint } from '@benzene/http';
import { BenzeneResult } from '@benzene/results';
import { IGreetingService } from './GreetingService.js';
export class HelloWorldRequest {
name?: string;
}
export class HelloWorldResponse {
message?: string;
}
@httpEndpoint('POST', '/hello')
@message('hello:world', { requestType: HelloWorldRequest, responseType: HelloWorldResponse })
export class HelloWorldHandler implements IMessageHandler<HelloWorldRequest, HelloWorldResponse> {
static readonly inject = [IGreetingService] as const;
constructor(private readonly greetings: IGreetingService) {}
async handleAsync(request: HelloWorldRequest): Promise<IBenzeneResultOf<HelloWorldResponse>> {
const response = new HelloWorldResponse();
response.message = await this.greetings.greetAsync(request.name ?? 'world');
return BenzeneResult.ok(response);
}
}
Testing a handler in isolation
The fastest test needs no pipeline and no container: construct the handler with a fake dependency,
call handleAsync, and assert on the returned IBenzeneResultOf<T>. A result carries status,
isSuccessful, payload, and errors, so you assert both the status and the payload:
// test/HelloWorldHandler.test.ts
import { describe, expect, it } from 'vitest';
import { BenzeneResultStatus } from '@benzene/results';
import { HelloWorldHandler, HelloWorldRequest } from '../src/HelloWorldHandler.js';
import { IGreetingService } from '../src/GreetingService.js';
// A hand-rolled fake — no mocking library required. Use vitest's `vi.fn()` if you prefer.
const fakeGreetings: IGreetingService = {
greetAsync: (name) => Promise.resolve(`Hello ${name}!`),
};
describe('HelloWorldHandler', () => {
it('returns an ok result with the greeting payload', async () => {
const handler = new HelloWorldHandler(fakeGreetings);
const request = new HelloWorldRequest();
request.name = 'Ada';
const result = await handler.handleAsync(request);
expect(result.isSuccessful).toBe(true);
expect(result.status).toBe(BenzeneResultStatus.ok);
expect(result.payload.message).toBe('Hello Ada!');
});
});
This is the level to unit-test business branches (validation short-circuits, error statuses, edge cases) cheaply. It does not exercise routing, request-body deserialization, or middleware — for that, drive the pipeline.
Assert against the
BenzeneResultStatusconstants (ok,notFound,validationError, …) rather than raw strings — they are the normative wire-status vocabulary, shared byte-for-byte with .NET. See Message Results.
Driving a transport pipeline end to end (recommended)
Build a real entry point from your production wiring, turn a builder into a native event, and call the entry point's handler. This is the closest a test gets to a live invocation.
AWS Lambda
Boot your StartUp with benzeneTestHost(StartUp).buildAwsLambdaHost() — the same construction a
deployed host performs — and push a native event through host.sendEventAsync(...). First the production
composition root wiring HelloWorldHandler on API Gateway:
// src/HelloStartUp.ts — the same composition root you deploy.
import { IBenzeneServiceContainer } from '@benzene/abstractions';
import { BenzeneStartUp, IBenzeneApplicationBuilder } from '@benzene/abstractions-middleware';
import { addBenzene, useMessageHandlers } from '@benzene/core-message-handlers';
import { useAwsLambda } from '@benzene/aws-lambda-core';
import { useApiGateway } from '@benzene/aws-lambda-api-gateway';
import { HelloWorldHandler } from './HelloWorldHandler.js';
export class HelloStartUp implements BenzeneStartUp {
configureServices(services: IBenzeneServiceContainer): void {
addBenzene(services);
// register your real IGreetingService here — the test overrides it below
}
configure(app: IBenzeneApplicationBuilder): void {
useAwsLambda(app, (aws) => useApiGateway(aws, (api) => useMessageHandlers(api, HelloWorldHandler)));
}
}
Feed the booted host an asApiGatewayRequest(...) (or asSqs, asSns, …) event and assert the
transport-native response:
// test/HelloPipeline.test.ts
import { describe, expect, it } from 'vitest';
import { APIGatewayProxyResult } from 'aws-lambda';
import { benzeneTestHost, httpBuilder } from '@benzene/testing';
import { asApiGatewayRequest } from '@benzene/aws-lambda-testing';
import { HelloStartUp } from '../src/HelloStartUp.js';
import { IGreetingService } from '../src/GreetingService.js';
const fakeGreetings: IGreetingService = {
greetAsync: (name) => Promise.resolve(`Hello ${name}!`),
};
describe('hello pipeline (API Gateway)', () => {
it('routes POST /hello to the handler and returns 200', async () => {
// Boot the SAME StartUp you deploy, overriding IGreetingService with the fake.
const host = benzeneTestHost(HelloStartUp)
.withServices((services) => services.addScopedInstance(IGreetingService, fakeGreetings))
.buildAwsLambdaHost();
const event = asApiGatewayRequest(httpBuilder('POST', '/hello', { name: 'Ada' }));
const response = await host.sendEventAsync<APIGatewayProxyResult>(event);
expect(response.statusCode).toBe(200);
expect(JSON.parse(response.body)).toEqual({ message: 'Hello Ada!' });
});
});
.withServices(...) is where you override services: register a fake instance against a token with
addScopedInstance / addSingletonInstance (or a factory with addScopedFactory). It runs after the
startup's own configureServices, so it replaces whatever the real wiring registered — the standard way
to swap in mocks or point a dependency at a locally running component (a Docker database, say) without
touching real configuration. This is the port's equivalent of the .NET WithServices(...) override.
Queue and event transports
Every AWS transport is driven the same way — only the builder and the response shape change. An SQS
handler with no @httpEndpoint, fed by asSqs, reports partial-batch failures:
import { IBenzeneServiceContainer } from '@benzene/abstractions';
import { BenzeneStartUp, IBenzeneApplicationBuilder } from '@benzene/abstractions-middleware';
import { addBenzene, useMessageHandlers } from '@benzene/core-message-handlers';
import { useAwsLambda } from '@benzene/aws-lambda-core';
import { useSqs } from '@benzene/aws-lambda-sqs';
import { benzeneTestHost, messageBuilder } from '@benzene/testing';
import { asSqs } from '@benzene/aws-lambda-testing';
class ProcessOrderStartUp implements BenzeneStartUp {
configureServices(services: IBenzeneServiceContainer): void {
addBenzene(services);
}
configure(app: IBenzeneApplicationBuilder): void {
useAwsLambda(app, (aws) => useSqs(aws, (sqs) => useMessageHandlers(sqs, ProcessOrderHandler)));
}
}
const host = benzeneTestHost(ProcessOrderStartUp).buildAwsLambdaHost();
// `numberOfMessages` emits N identical records; the topic rides the `topic` message attribute.
const event = asSqs(messageBuilder('order:process', { orderId: '42' }), { numberOfMessages: 2 });
const response = await host.sendEventAsync<{ batchItemFailures: { itemIdentifier: string }[] }>(event);
expect(response.batchItemFailures).toEqual([]); // both records succeeded
The as* builders each take a trailing options object ({ serializer?, numberOfMessages? }) and set
whatever the adapter routes on — the topic attribute for SQS/SNS, detail-type for EventBridge, the
record topic for Kafka, {table}:{eventName} for DynamoDB. asS3 is the exception: an S3 notification
has no JSON body, so it takes a bucket and key directly (asS3('my-bucket', 'photos/cat.png', { eventName: 'ObjectCreated:Put' })). Kinesis carries no topic, so pair asKinesis(...) with a
usePresetTopic(kinesis, 'order:process') in the pipeline. See
test/Benzene.Core.Test/Testing/AwsLambdaTestingTest.test.ts
for every AWS builder driven end to end.
Azure Functions
Boot your StartUp with benzeneTestHost(StartUp).buildAzureFunctionApp() and push native trigger
payloads through host.sendEventAsync(...) — an HTTP request returns the response; a fire-and-forget
trigger (Service Bus / Event Hub / Kafka) resolves to void. Only the .buildAwsLambdaHost() →
.buildAzureFunctionApp() line and the as* builder change from the AWS test; select Azure inside the
StartUp with useAzureFunctions(app, az => …):
import { HttpResponseInit } from '@azure/functions';
import { IBenzeneServiceContainer } from '@benzene/abstractions';
import { BenzeneStartUp, IBenzeneApplicationBuilder } from '@benzene/abstractions-middleware';
import { addBenzene, useMessageHandlers } from '@benzene/core-message-handlers';
import { useAzureFunctions } from '@benzene/azure-function-core';
import { useAzureHttp } from '@benzene/azure-function-http';
import { benzeneTestHost, httpBuilder } from '@benzene/testing';
import { asAzureHttpRequest } from '@benzene/azure-function-testing';
class HelloAzureStartUp implements BenzeneStartUp {
configureServices(services: IBenzeneServiceContainer): void {
addBenzene(services);
}
configure(app: IBenzeneApplicationBuilder): void {
useAzureFunctions(app, (az) => useAzureHttp(az, (http) => useMessageHandlers(http, HelloWorldHandler)));
}
}
const host = benzeneTestHost(HelloAzureStartUp)
.withServices((services) => services.addScopedInstance(IGreetingService, fakeGreetings))
.buildAzureFunctionApp();
const response = await host.sendEventAsync<HttpResponseInit>(
asAzureHttpRequest(httpBuilder('POST', '/hello', { name: 'Ada' })),
);
expect(response.status).toBe(200);
expect(JSON.parse(response.body as string)).toEqual({ message: 'Hello Ada!' });
Service Bus, Event Hub, and Kafka work identically — send an asAzureServiceBusMessage(...),
asEventHubBenzeneMessage(...), or asAzureKafkaEvent(...) (as a single payload or an array) through
host.sendEventAsync(...); they dispatch fire-and-forget and resolve to void, so assert on a faked
egress client. Event Hub carries a Benzene message envelope, so its handler is wired under
useBenzeneMessage inside useEventHub. See
test/Benzene.Core.Test/Testing/AzureFunctionTestingTest.test.ts
for all four triggers.
Topic-centric testing: the BenzeneMessage envelope
Sometimes you want to exercise a topic through the transport-neutral message pipeline —
regardless of whether it also has an HTTP route or is normally fed from a queue. asBenzeneMessage
turns a messageBuilder into a BenzeneMessageRequest (topic + serialized body + headers), which you
drive through a BenzeneMessageApplication. This is the port's analog of the .NET direct-invoke
SendBenzeneMessageAsync:
import { describe, expect, it } from 'vitest';
import { BenzeneMessageContext } from '@benzene/core-messages';
import { MiddlewarePipelineBuilder } from '@benzene/core-middleware';
import { BenzeneResultStatus } from '@benzene/results';
import {
addBenzeneMessage,
BenzeneMessageApplication,
useMessageHandlers,
} from '@benzene/core-message-handlers';
import { DefaultBenzeneServiceContainer } from '@benzene/dependencies';
import { messageBuilder, asBenzeneMessage } from '@benzene/testing';
describe('hello via the message pipeline', () => {
it('routes the topic and round-trips the payload', async () => {
const container = new DefaultBenzeneServiceContainer();
addBenzeneMessage(container);
container.addScopedInstance(IGreetingService, fakeGreetings);
const builder = new MiddlewarePipelineBuilder<BenzeneMessageContext>(container);
useMessageHandlers(builder, HelloWorldHandler);
const app = new BenzeneMessageApplication(builder.build());
const request = asBenzeneMessage(messageBuilder('hello:world', { name: 'Ada' }));
const response = await app.handleAsync(request, container.createServiceResolverFactory());
expect(response.statusCode).toBe(BenzeneResultStatus.ok);
expect(JSON.parse(response.body)).toEqual({ message: 'Hello Ada!' });
});
});
The response is the wire envelope { statusCode, headers, body } with body the serialized payload
as a JSON string — identical across languages, so a topic a TypeScript service answers classifies the
same in a .NET peer.
Test-event builder reference
Every builder takes a messageBuilder/httpBuilder (or, for asS3, a bucket/key) plus an optional
trailing options object.
| Builder | Package | Produces | Routes on |
|---|---|---|---|
asBenzeneMessage / asRawHttpRequest |
@benzene/testing |
BenzeneMessageRequest / raw HTTP string |
topic / route |
asApiGatewayRequest / asApiGatewayV2Request |
@benzene/aws-lambda-testing |
APIGatewayProxyEvent (v1/v2) |
HTTP method + path |
asApiGatewayCustomAuthorizerEvent |
@benzene/aws-lambda-testing |
APIGatewayRequestAuthorizerEvent |
method + path (no body) |
asSqs / asSns |
@benzene/aws-lambda-testing |
SQSEvent / SNSEvent |
topic message attribute |
asEventBridge |
@benzene/aws-lambda-testing |
EventBridgeEvent |
detail-type |
asAwsKafkaEvent |
@benzene/aws-lambda-testing |
MSKEvent |
record topic |
asDynamoDb |
@benzene/aws-lambda-testing |
DynamoDBStreamEvent |
{table}:{eventName} |
asKinesis |
@benzene/aws-lambda-testing |
KinesisStreamEvent |
preset topic (usePresetTopic) |
asS3 |
@benzene/aws-lambda-testing |
S3Event |
S3 event name (bucket/key body) |
asAzureHttpRequest |
@benzene/azure-function-testing |
@azure/functions HttpRequest |
HTTP method + path |
asAzureServiceBusMessage |
@benzene/azure-function-testing |
Service Bus message | topic application property |
asEventHubBenzeneMessage |
@benzene/azure-function-testing |
Event Hub envelope | envelope topic |
asAzureKafkaEvent |
@benzene/azure-function-testing |
Kafka event | record topic |
Notes and limitations
benzeneTestHostboots from yourBenzeneStartUp. Prefer it (top of this guide) to test the real app end to end; drop toInline*StartUponly for low-level unit tests that deliberately bypass a startup class. The specialization method (buildAwsLambdaHost/buildAzureFunctionApp) lights up when you import its transport*-testingpackage, which you already do for theas*builder.- Configuration overrides.
benzeneTestHost(...).withConfiguration(key, value)(or an object) layers in-memory config over your startup's owngetConfiguration()(last-wins), handed toconfigureServices/configureas aBenzeneConfiguration. - Express host. For an Express-hosted service you can test over real HTTP
with
supertestagainst theexpress()app — the analog of .NET'sWebApplicationFactory— since the app is a standard Express app; or use the API Gateway pipeline test above, which shares the same handler. - Workers. A worker-only host isn't request/response-shaped, so there is no
as*/handle*to call — drive the worker's lifecycle directly.
See also
- Message Handlers — the handler contract and
handleAsync. - Message Results —
IBenzeneResultOf<T>,BenzeneResult, and status mapping. - Middleware — the pipeline the entry points build on.
- Validation — asserting validation short-circuits before a handler runs.
- Getting Started (AWS) / Azure Functions — the production wiring these tests build from.
- Cookbooks — end-to-end recipes.
- Runnable projects:
examples/.