Skip to content

Components

Components are the primary building blocks of Testurio tests. Each component represents a participant in a distributed system — a client sending requests, a server handling them, a message broker, or a database.

The Three Roles

The Server and AsyncServer components serve two distinct roles depending on their configuration:

  • Mock — Only listenAddress is set. The server handles requests directly using hooks (.mockResponse(), .mockEvent()). No real backend is involved.
  • Proxy — Both listenAddress and targetAddress are set. The server forwards traffic to the backend while allowing hooks to inspect (.assert()), transform (.transform(), .proxy()), mock selectively (.mockResponse()), or drop (.drop()) messages in flight.

Combined with Client / AsyncClient (which always sends requests to a target), these form the three roles at the heart of every Testurio test:

RoleComponentConfiguration
ClientClient, AsyncClienttargetAddress only
MockServer, AsyncServerlistenAddress only
ProxyServer, AsyncServerlistenAddress + targetAddress

This model works identically across all protocols — HTTP, gRPC, WebSocket, and TCP.

Component Overview

ComponentProtocol TypeRole
ClientSync (HTTP, gRPC Unary)Sends requests to a target server
ServerSyncMock server or proxy
AsyncClientAsync (WebSocket, TCP, gRPC Stream)Sends messages over persistent connections
AsyncServerAsyncMock async server or proxy
PublisherMQ AdapterPublishes messages to topics
SubscriberMQ AdapterSubscribes to and asserts on messages
DataSourceDirect SDKExecutes operations on databases/caches

Client

Sends synchronous requests to a target server. Used with HTTP and gRPC Unary protocols.

typescript
import { Client, HttpProtocol } from 'testurio';

const client = new Client('api', {
  protocol: new HttpProtocol<UserApi>(),
  targetAddress: { host: 'localhost', port: 3000 },
});

Options:

  • protocol — A sync protocol instance (HttpProtocol, GrpcUnaryProtocol)
  • targetAddress — Server address to send requests to ({ host, port })

Step builder methods:

MethodModeDescription
request(operationId, data)actionSend a request
onResponse(operationId)hookRegister a non-blocking response handler
waitResponse(operationId)waitBlock until the response arrives
typescript
const tc = testCase('example', (test) => {
  const api = test.use(client);

  api.request('getUser', { method: 'GET', path: '/users/1' });
  api.onResponse('getUser').assert((res) => res.code === 200);
});

Server

Acts as a mock server or proxy. When only listenAddress is provided, it's a mock. When both listenAddress and targetAddress are provided, it acts as a proxy.

typescript
import { Server, HttpProtocol } from 'testurio';

// Mock server
const mock = new Server('backend', {
  protocol: new HttpProtocol<UserApi>(),
  listenAddress: { host: 'localhost', port: 3000 },
});

// Proxy server
const proxy = new Server('gateway', {
  protocol: new HttpProtocol<UserApi>(),
  listenAddress: { host: 'localhost', port: 3001 },
  targetAddress: { host: 'localhost', port: 3000 },
});

Options:

  • protocol — A sync protocol instance
  • listenAddress — Address to listen on
  • targetAddress(optional) Backend address for proxy mode

Step builder methods:

MethodModeDescription
onRequest(operationId, matcher?)hookHandle incoming request
waitRequest(operationId, matcher?)waitBlock until request arrives
typescript
const tc = testCase('example', (test) => {
  const mock = test.use(server);

  mock.onRequest('getUser', { method: 'GET', path: '/users/1' })
    .mockResponse(() => ({
      code: 200,
      body: { id: 1, name: 'Alice' },
    }));
});

AsyncClient

Sends messages over persistent connections. Used with WebSocket, TCP, and gRPC streaming protocols.

By default, autoConnect is false — you must call connect() explicitly before sending messages. Set autoConnect: true for automatic connection on start, or pass an object with protocol-typed connect params to auto-connect with parameters.

typescript
import { AsyncClient } from 'testurio';
import { WebSocketProtocol } from '@testurio/protocol-ws';

const ws = new AsyncClient('ws-client', {
  protocol: new WebSocketProtocol<ChatService>(),
  targetAddress: { host: 'localhost', port: 8080 },
  // autoConnect: false (default) — must call connect() explicitly
});

Options:

  • protocol — An async protocol instance (WebSocketProtocol, TcpProtocol, GrpcStreamProtocol)
  • targetAddress — Server address to connect to
  • autoConnect(optional) Connection control. false (default): requires explicit connect(). true: auto-connect without params. Object: auto-connect with protocol-typed params (e.g., { headers: { ... } } for WS)

Step builder methods:

MethodModeDescription
connect(params?)actionEstablish connection (accepts protocol-typed params or factory)
sendMessage(messageType, data)actionSend a message (accepts static data or factory)
disconnect()actionClose the connection
onEvent(messageType)hookRegister a non-blocking event handler
waitEvent(messageType, options?)waitBlock until event arrives
waitDisconnect()waitBlock until connection closes
typescript
const tc = testCase('ping pong', (test) => {
  const ws = test.use(wsClient);

  ws.connect(); // Required when autoConnect: false (default)
  ws.sendMessage('ping', { seq: 1 });
  ws.waitEvent('pong').timeout(2000).assert((msg) => msg.seq === 1);
});

Auto-Connect with Parameters

When connection parameters are known at construction time, pass them directly to autoConnect:

typescript
// WebSocket with auth headers — no connect() step needed
const ws = new AsyncClient('ws-client', {
  protocol: new WebSocketProtocol<ChatService>(),
  targetAddress: { host: 'localhost', port: 8080 },
  autoConnect: { headers: { Authorization: 'Bearer token' } },
});

// gRPC stream with metadata
const grpc = new AsyncClient('grpc-client', {
  protocol: new GrpcStreamProtocol<StreamService>({ ... }),
  targetAddress: { host: 'localhost', port: 50051 },
  autoConnect: { metadata: { authorization: 'Bearer token' } },
});

Dynamic Connection Parameters

Use a factory function to pass parameters determined at execution time:

typescript
ws.connect(() => ({
  headers: { Authorization: `Bearer ${authToken}` },
  query: { version: '2' },
}));

Reconnection

Call connect() after disconnect() to create a fresh connection:

typescript
ws.connect();
ws.sendMessage('subscribe', { channel: 'updates' });
ws.disconnect();
ws.connect(); // Fresh connection
ws.sendMessage('subscribe', { channel: 'updates' });

Factory Parameters

Action step methods (request, sendMessage, sendEvent, broadcast, publish, publishBatch) accept either a static value or a factory function () => T. Factory functions are resolved at execution time, allowing data from earlier steps to flow into later steps.

typescript
let token: string;

const tc = testCase('multi-step flow', (test) => {
  const api = test.use(client);
  const mock = test.use(server);

  // Step 1: Login with static params
  api.request('login', { method: 'POST', path: '/login', body: { user: 'admin' } });
  mock.onRequest('login', { method: 'POST', path: '/login' })
    .mockResponse(() => ({ code: 200, body: { token: 'tok-secret' } }));

  // Extract token at execution time
  api.onResponse('login').transform((res) => {
    token = res.body.token;
    return res;
  });

  // Step 2: Use token via factory — resolved at execution time
  api.request('getProfile', () => ({
    method: 'GET' as const,
    path: '/profile',
    headers: { Authorization: `Bearer ${token}` },
  }));
});

This works the same way for WebSocket and other async protocols:

typescript
let sessionId: string;

ws.sendMessage('join', () => ({
  room: 'general',
  sessionId, // Read at execution time from earlier step
}));

Parallel Send + Filtered Wait

When multiple requests of the same type are in-flight, use matchers to correlate responses by payload content instead of relying on arrival order:

typescript
const tc = testCase('batch orders', (test) => {
  const api = test.use(wsClient);

  // Batch sends — all executed before any waits
  api.sendMessage('new_order', { price: 1.9, amount: 4000 });
  api.sendMessage('new_order', { price: 0.99, amount: 7000 });
  api.sendMessage('new_order', { price: 0.85, amount: 8000 });

  // Filtered waits — each matcher routes to the correct response
  api.waitEvent('order_confirm', { matcher: (r) => r.price === 1.9 })
    .assert((r) => expect(r.order_id).toBeDefined());
  api.waitEvent('order_confirm', { matcher: (r) => r.price === 0.99 })
    .assert((r) => expect(r.order_id).toBeDefined());
  api.waitEvent('order_confirm', { matcher: (r) => r.price === 0.85 })
    .assert((r) => expect(r.order_id).toBeDefined());
});

Without matchers, multiple waitEvent of the same type consume events in FIFO order (first registered hook gets first event). With matchers, events are routed based on payload content regardless of arrival order.

TIP

The matcher option works on both waitEvent() (AsyncClient) and waitMessage() (AsyncServer). When a matcher is provided, the strict ordering check is relaxed — pre-resolved hooks are allowed because events may arrive before the wait step executes in the batch pattern.

AsyncServer

Acts as a mock async server or proxy for persistent connections.

typescript
import { AsyncServer } from 'testurio';
import { WebSocketProtocol } from '@testurio/protocol-ws';

const wsMock = new AsyncServer('ws-server', {
  protocol: new WebSocketProtocol<ChatService>(),
  listenAddress: { host: 'localhost', port: 8080 },
});

Options:

  • protocol — An async protocol instance
  • listenAddress — Address to listen on
  • targetAddress(optional) Backend for proxy mode

Step builder methods:

MethodModeDescription
onConnection(linkId, options?)hookLink connection when it arrives
waitConnection(linkId, options?)waitBlock until client connects
onMessage(messageType, options?)hookHandle incoming message
waitMessage(messageType, options?)waitBlock until message arrives
onEvent(eventType)hookHandle backend event (proxy mode)
waitEvent(eventType, options?)waitBlock until backend event arrives (proxy mode)
sendEvent(linkId, eventType, payload)actionSend event to linked connection
broadcast(eventType, payload)actionSend event to all connections
disconnect(linkId)actionDisconnect a linked connection
onDisconnect(linkId, handler)hookHandle linked connection disconnect
waitDisconnect(linkId)waitBlock until client disconnects
typescript
const tc = testCase('echo', (test) => {
  const server = test.use(wsMock);

  server.onMessage('ping').mockEvent('pong', (msg) => ({
    seq: msg.seq,
    timestamp: Date.now(),
  }));
});

Publisher

Fire-and-forget message publishing to message queues (Kafka, RabbitMQ, Redis Pub/Sub).

typescript
import { Publisher } from 'testurio';
import { KafkaAdapter } from '@testurio/adapter-kafka';

const pub = new Publisher<OrderTopics>('order-pub', {
  adapter: new KafkaAdapter({ brokers: ['localhost:9092'] }),
});

Options:

  • adapter — MQ adapter instance
  • schema(optional) Topic-based Zod schemas for validation

Step builder methods:

MethodModeDescription
publish(topic, data, options?)actionPublish a message to a topic
publishBatch(topic, messages)actionPublish multiple messages
typescript
const tc = testCase('publish order', (test) => {
  const pub = test.use(publisher);

  pub.publish('order-created', {
    orderId: 'ORD-123',
    total: 99.99,
  });
});

Subscriber

Subscribes to messages from message queues and provides assertion/wait capabilities.

typescript
import { Subscriber } from 'testurio';
import { KafkaAdapter } from '@testurio/adapter-kafka';

const sub = new Subscriber<OrderTopics>('order-sub', {
  adapter: new KafkaAdapter({ brokers: ['localhost:9092'], groupId: 'test' }),
});

Options:

  • adapter — MQ adapter instance
  • schema(optional) Topic-based Zod schemas for validation

Step builder methods:

MethodModeDescription
onMessage(topic)hookRegister a non-blocking message handler
waitMessage(topic, options?)waitBlock until a message arrives
typescript
const tc = testCase('receive order', (test) => {
  const sub = test.use(subscriber);

  sub.waitMessage('order-created')
    .assert('orderId should match', (msg) => msg.value.orderId === 'ORD-123');
});

DataSource

Provides direct SDK access to databases and caches (Redis, PostgreSQL, MongoDB). No protocol abstraction — you work directly with the native client.

typescript
import { DataSource } from 'testurio';
import { RedisAdapter } from '@testurio/adapter-redis';

const redis = new DataSource('cache', {
  adapter: new RedisAdapter({ host: 'localhost', port: 6379 }),
});

Options:

  • adapter — DataSource adapter instance

Step builder methods:

MethodModeDescription
exec(description, fn)actionExecute operations on the data store
typescript
const tc = testCase('cache test', (test) => {
  const cache = test.use(redis);

  // Setup
  cache.exec('seed data', async (client) => {
    await client.set('user:1', JSON.stringify({ name: 'Alice' }));
  });

  // Assert
  cache.exec('verify', async (client) => client.get('user:1'))
    .assert('should be cached', (data) => data !== null);
});

See also: .exec(...) returns a hook builder that supports .timeout(ms) (per-attempt) and .retry(predicate) (poll until convergence). See the Polling & Retry guide for how to wait for rows to appear, jobs to complete, or other convergence scenarios.

Component Ordering

In the components array, order matters:

  1. Non-network components (DataSource, Publisher, Subscriber) — started first
  2. Servers — started sequentially in array order
  3. Clients — started in parallel after all servers are ready
typescript
const scenario = new TestScenario({
  name: 'Full Stack Test',
  components: [
    redis,        // 1st: DataSource
    subscriber,   // 2nd: Subscriber
    mockServer,   // 3rd: Server
    proxy,        // 4th: Server (after mockServer)
    client,       // 5th: Client (parallel)
  ],
});

Shutdown happens in reverse order: clients first, then servers, then non-network components.

Released under the MIT License.