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.
Test config validation
Section titled “Test config validation”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.
Test an action through execute
Section titled “Test an action through execute”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.
Inject fakes with services.set
Section titled “Inject fakes with services.set”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); });});Test the exposed surface
Section titled “Test the exposed surface”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');Test behaviour, not internals
Section titled “Test behaviour, not internals”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.