Skip to content

Test a provider

A provider is worth testing at its contracts: does registering it validate config, do its actions validate input and output, and does the instance expose the actions, events, and entities you expect? This guide shows how to cover that with Vitest, and why you should leave the provider’s private internals alone.

Registering a provider type parses your config against its schema before setup runs. Assert both the happy path and that bad config rejects with a ProviderConfigValidationError:

import { describe, it, expect } from 'vitest';
import { ProviderConfigValidationError, ProviderRegistry } from '@grundlag/core';
import { messagingProvider } from './messaging.provider.js';
describe('messaging provider registration', () => {
it('sets up an instance from valid config', async () => {
const registry = new ProviderRegistry();
await registry.register(messagingProvider, { apiKey: 'test-key' });
const instance = await registry.get('messaging');
expect(instance?.id).toBe('messaging');
});
it('rejects invalid config before setup runs', async () => {
const registry = new ProviderRegistry();
const error = await registry.register(messagingProvider, { apiKey: 123 } as never).catch((error: unknown) => error);
expect(error).toBeInstanceOf(ProviderConfigValidationError);
expect((error as ProviderConfigValidationError).providerId).toBe('messaging');
});
});

The issues on the error carry the Zod problems, so you can assert on the specific field if a test needs to.

Call action.execute with a context and assert the output. Because defineAction wraps execute with schema validation, passing bad input throws an ActionInputValidationError and your implementation never runs — test both:

import { describe, it, expect } from 'vitest';
import { ActionInputValidationError, Logger, Services } from '@grundlag/core';
import { sendMessage } from './sendMessage.action.js';
describe('sendMessage', () => {
it('returns a message id for valid input', async () => {
const output = await sendMessage.execute({
input: { recipientId: 'r-1', body: 'hello' },
services: new Services(),
logger: new Logger(),
});
expect(output).toEqual({ messageId: expect.any(String) });
});
it('rejects invalid input before execute runs', async () => {
const error = await sendMessage
.execute({
input: { recipientId: 'r-1', body: '' },
services: new Services(),
logger: new Logger(),
})
.catch((error: unknown) => error);
expect(error).toBeInstanceOf(ActionInputValidationError);
expect((error as ActionInputValidationError).actionId).toBe('sendMessage');
});
});

You never build the validation wrapper yourself — asserting the error type is enough to prove the contract holds.

An action reaches shared capabilities through services. In a test, swap the real implementation for a fake with services.set(Token, fake) — the container returns your fake instead of instantiating the real service:

import { describe, it, expect } from 'vitest';
import { Logger, Services } from '@grundlag/core';
import { MessagingClient } from './messaging.client.js';
import { sendMessage } from './sendMessage.action.js';
describe('sendMessage with a fake client', () => {
it('delivers through the injected client', async () => {
const sent: unknown[] = [];
const services = new Services();
services.set(MessagingClient, {
deliver: async (draft) => {
sent.push(draft);
return 'm-123';
},
});
const output = await sendMessage.execute({
input: { recipientId: 'r-1', body: 'hello' },
services,
logger: new Logger(),
});
expect(output).toEqual({ messageId: 'm-123' });
expect(sent).toHaveLength(1);
});
});

If a test needs to confirm the instance registers what it should, look it up through the public registries rather than reaching inside:

const instance = await registry.get('messaging');
expect(instance?.actions.get('sendMessage')).toBeDefined();
expect(instance?.actions.list().map((action) => action.id)).toContain('sendMessage');

Aim every test at a public contract: config validation, an action’s input/output, an emitted event, the registered surface. Do not reach into private maps, caches, or helper functions — they are implementation detail, and coupling tests to them makes refactoring painful without proving anything a caller relies on. If a behaviour matters, it is observable through a primitive; test it there. See Define actions for the contract each action upholds.