Testing
netron-react ships testing utilities (under
@omnitron-dev/netron-react/test) that let you drive components
without a real backend.
TestNetronProvider
import { TestNetronProvider } from '@omnitron-dev/netron-react/test';
render(
<TestNetronProvider
testConfig={{
mocks: [
{ service: 'users', method: 'getUser', response: { id: '1', email: 'a@b.c' } },
{ service: 'users', method: 'list', response: [{ id: '1', email: 'a@b.c' }] },
],
}}
>
<UserCard userId="1" />
</TestNetronProvider>
);
await screen.findByText('a@b.c');
No real transport — TestNetronProvider builds a test client
(via createTestClient) whose invoke is intercepted by the
mocks array. Hooks behave identically (useQuery,
useMutation, useService).
Each mock entry is a MockResponse: { service, method, response?, error?, delay? }. Provide error to make the matching call
reject, and delay to simulate latency.
Custom mock responses
Each entry in mocks returns a static response for one
service.method. To branch on arguments, override the client's
invoke — it's a plain method. createMockService builds an
object of async method implementations you can dispatch to:
import { createTestClient, createMockService, TestNetronProvider }
from '@omnitron-dev/netron-react/test';
const usersMock = createMockService<UserService>({
getUser: async (id: string) => {
if (id === 'missing') {
throw new TitanError({ code: ErrorCode.NOT_FOUND, message: 'not found' });
}
return { id, email: `${id}@example.com` };
},
});
const client = createTestClient();
// Dispatch every call to the mock service object:
client.invoke = (async (_service, method, args) =>
(usersMock as Record<string, (...a: unknown[]) => Promise<unknown>>)[method](...args)
) as typeof client.invoke;
render(
<TestNetronProvider client={client}>
<UserCard userId="missing" />
</TestNetronProvider>
);
createMockService(implementations) returns a plain object of
async methods. It is not auto-wired into the provider —
dispatch to it via invoke as above (or pass vi.fn()
implementations so you can assert calls).
Mock latency
render(
<TestNetronProvider
testConfig={{
mocks: [
{ service: 'orders', method: 'getOrder', response: order, delay: 100 },
],
}}
>
<OrderCard orderId="1" />
</TestNetronProvider>
);
A per-mock delay (or testConfig.defaultDelay) lets you assert
intermediate loading states before the response settles.
Multi-backend tests
For multi-backend apps, build a MultiBackendProvider around a
mock multi-backend client in your test setup — the test utils
focus on the single-client path; the multi-backend hooks
(useBackendService, …) read from whatever
<MultiBackendProvider> you supply.
import { MultiBackendProvider } from '@omnitron-dev/netron-react';
render(
<MultiBackendProvider client={mockMultiBackendClient} autoConnect={false}>
<Dashboard />
</MultiBackendProvider>
);
See Multi-backend for the production
MultiBackendProvider API the mock client must satisfy.
Cache control in tests
import { createTestClient, TestNetronProvider }
from '@omnitron-dev/netron-react/test';
const client = createTestClient({
mocks: [{ service: 'users', method: 'getUser', response: user }],
config: { cache: { staleTime: 0, cacheTime: 0 } }, // disable cache for predictability
});
render(<TestNetronProvider client={client}><App /></TestNetronProvider>);
createTestClient(config?) builds the client; pass cache tuning
through config. With staleTime: 0, every mount re-fetches —
useful for asserting "fetched N times" without caching surprises.
Loading + error states
it('shows skeleton while loading', async () => {
render(
<TestNetronProvider
testConfig={{
mocks: [{ service: 'users', method: 'getUser', response: user, delay: 100 }],
}}
>
<UserCard userId="1" />
</TestNetronProvider>
);
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
await screen.findByText('a@b.c');
expect(screen.queryByTestId('skeleton')).not.toBeInTheDocument();
});
it('shows error on failure', async () => {
render(
<TestNetronProvider
testConfig={{
mocks: [{
service: 'users',
method: 'getUser',
error: new TitanError({ code: ErrorCode.NOT_FOUND }),
}],
}}
>
<UserCard userId="1" />
</TestNetronProvider>
);
await screen.findByText(/not found/i);
});
Mutation assertions
Spy on the client's invoke to assert a mutation fired with the
right arguments — the proxy calls invoke(service, method, args):
it('calls invite on submit', async () => {
const user = userEvent.setup();
const client = createTestClient({
mocks: [{ service: 'users', method: 'invite', response: { id: 'new', email: 'x@y.z' } }],
});
const invokeSpy = vi.spyOn(client, 'invoke');
render(
<TestNetronProvider client={client}>
<InviteForm />
</TestNetronProvider>
);
await user.type(screen.getByLabelText('Email'), 'x@y.z');
await user.click(screen.getByRole('button', { name: 'Invite' }));
await waitFor(() => {
expect(invokeSpy).toHaveBeenCalledWith('users', 'invite', ['x@y.z'], expect.anything());
});
});
useMutation exposes isLoading while the mutation is in flight
(plus isSuccess / isError / data).
Suspense + error boundary tests
Suspense is the suspense: true option on useQuery — it makes
the hook throw the in-flight fetch to the nearest <Suspense>,
and escalates errors to the nearest error boundary:
it('falls back to error boundary on render error', () => {
render(
<TestNetronProvider
testConfig={{
mocks: [{ service: 'users', method: 'getUser', error: new Error('boom') }],
}}
>
<ErrorBoundary fallback={() => <div>caught</div>}>
<Suspense fallback={<div>loading</div>}>
{/* SuspenseUserCard calls useQuery([...], { suspense: true }) */}
<SuspenseUserCard userId="1" />
</Suspense>
</ErrorBoundary>
</TestNetronProvider>
);
return waitFor(() => expect(screen.getByText('caught')).toBeInTheDocument());
});
Integration tests against a real backend
For higher confidence, point tests at a running Titan dev server:
import { NetronReactClient, NetronProvider } from '@omnitron-dev/netron-react';
const realClient = new NetronReactClient({
url: process.env.TEST_API_URL ?? 'http://localhost:3001',
transport: 'http',
});
beforeAll(async () => {
await realClient.connect();
// Seed test data via direct RPC
await realClient.invoke('test-utils', 'reset', []);
});
afterAll(() => realClient.disconnect());
it('full flow', async () => {
render(<NetronProvider client={realClient}><SignInForm /></NetronProvider>);
// ...
});
Run alongside the omnitron daemon's test stack:
omnitron stack start my-project test
pnpm test:integration
omnitron stack stop my-project test
Deterministic clock
For time-sensitive behaviour (refetchInterval, retries,
debounce):
import { createTestClient, advanceTimersAndFlush }
from '@omnitron-dev/netron-react/test';
vi.useFakeTimers();
const client = createTestClient({ /* ... */ });
render(<NetronProvider client={client}><Polling /></NetronProvider>);
await screen.findByText('initial');
await advanceTimersAndFlush(30_000); // trigger refetch interval + flush microtasks
await screen.findByText('refreshed');
vi.useRealTimers();
advanceTimersAndFlush(ms) advances vitest fake timers and
flushes pending microtasks; nextTick() awaits a single tick.
Best practices
- Mock per test file, not globally. Sharing mocks across tests causes order dependencies.
- Reset mocks (
vi.clearAllMocks()) inbeforeEachif you share acrossitblocks. - Assert calls + UI. Calling-the-mock isn't proof; the user-visible result is.
staleTime: 0in tests for predictability.- One test per behaviour, not per method. "Shows loading, then data" + "shows error on failure" are two tests.
- Integration tests for the critical happy path; unit tests with mocks for edge cases.
Anti-patterns
- Mocking
fetchdirectly. Bypasses the client logic; useTestNetronProvider. - Stale mocks shared across tests. State leaks; mysterious failures.
- Testing implementation details. "Calls
cache.set" is fragile; "shows the updated value" is robust. - Real backend in unit tests. Slow, flaky, hard to seed — reserve for integration suite.
See also
- netron-react — hooks under test
- Multi-backend —
MultiBackendProvider - Caching —
staleTimetuning for tests