Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

@suite-common/test-utils

This package provides shared test utilities for Suite. It also re-exports @testing-library/react, so tests can use it as a drop-in replacement.

Most unit tests should not create a Redux store or an application root. Create those only when the test intentionally covers the integration between business logic, Redux, and injected services.

Choose the test boundary first

flowchart TD
    A{What are you testing?}
    A -- One thunk, reducer, selector, or service --> B[Call it directly]
    A -- A connected part of the application --> C[Create a test composition root]
    C --> D[Run the behavior and assert the resulting state or UI]

Use a unit test when the subject can receive all its dependencies as arguments. This is the default for business logic.

Use an integration test when you intentionally want to run a connected part of the application and verify what its actions, reducers, middleware, and services produce together. For example, dispatch a user flow and assert the state that ends up in Redux.

Unit-test business logic directly

Do not create a store merely to obtain dispatch, getState, or extra. A thunk is still a function, so call that function with those three dependencies directly:

import { createMockDispatch } from '@suite-common/redux-utils/mocks';

const state: XyzThunkState = {
    // Only the state required by xyzThunk.
};
const extra: XyzThunkDeps = {
    services: {
        // Only the services required by xyzThunk.
    },
};
const getState = () => state;
const { actions, dispatch } = createMockDispatch({ getState, extra });

await xyzThunk()(dispatch, getState, extra);

expect(extra.services.someService).toHaveBeenCalled();
expect(actions).toEqual([
    expect.objectContaining({ type: xyzThunk.pending.type }),
    expect.objectContaining({ type: xyzThunk.fulfilled.type }),
]);

createMockDispatch is not a Redux store. It is a small function that records plain actions and runs nested thunks recursively with the same dispatch, getState, and extra. This keeps the test focused on the thunk contract without constructing unrelated application infrastructure.

Use the same principle for other business logic:

  • Call reducers with the previous state and an action.
  • Call selectors with the smallest state they declare.
  • Create services with explicit mocked dependencies and call the service directly.
  • Render hooks without application providers when the hook does not depend on them.

Integration-test with createTestCompositionRoot

Use createTestCompositionRoot when Redux integration is part of what the test should prove. It composes the test the same way an application composition root does: it creates the store, composes the services from it, and injects them. Thunks read their extra lazily, so the services are in place before anything is dispatched. The store belongs to the services; access it through root.services.store.

Every root declares the application contract it tests. The first type argument is the thunk dependency contract (void when nothing is injected), the second is the state shape. They type-check the composed services, the reducer and every dispatched thunk. Declare the state as an explicit type, such as an exported root-state type, instead of deriving it from the reducer with ReturnType<typeof reducer>: the contract then states what the tested code expects, and the reducer is checked against it. Pass static (non-service) extra dependencies such as thunks or actions as extra.

import { createTestCompositionRoot } from '@suite-common/test-utils';

const { services } = createTestCompositionRoot<CounterThunkDeps, CounterRootState>({
    reducer: {
        counter: counterReducer,
    },
    preloadedState: {
        counter: { value: 0 },
    },
    services: () => ({ analytics: mockAnalytics() }),
});

services.store.dispatch(incrementCounter());

expect(services.store.getState().counter.value).toBe(1);
expect(services.store.getActions()).toContainEqual(incrementCounter());

Declare only the services and state needed by the tested application slice. The test store also exposes getActions and clearActions when action-level assertions are useful.

When a tested service needs the store, compose it from the store passed to services:

const { services } = createTestCompositionRoot<SomeServiceDeps, State>({
    reducer,
    preloadedState,
    services: store => ({
        analytics: mockAnalytics(),
        someService: createSomeService({ dispatch: store.dispatch, getState: store.getState }),
    }),
});

root.extra is the exact extra the thunks receive ({ ...extra, services }).

The important difference from a thunk unit test is the assertion target: an integration test runs the Redux wiring and normally verifies the resulting state or rendered UI, not only whether one isolated function called another function.

Testing hooks

Use renderHook for a hook that does not depend on application providers:

import { renderHook } from '@suite-common/test-utils';

const { result } = renderHook(() => useStandaloneHook());

A hook that reads Redux state or injected services is an integration test. Create the test composition root and pass its services to renderHookWithStoreProvider:

import { createTestCompositionRoot, renderHookWithStoreProvider } from '@suite-common/test-utils';

const { services } = createTestCompositionRoot<CounterDeps, CounterRootState>({
    reducer: { counter: counterReducer },
    preloadedState: { counter: { value: 0 } },
    services: () => ({ analytics: mockAnalytics() }),
});

const { result } = renderHookWithStoreProvider(() => useCounter(), { services });

The provider supplies Redux from services.store and the injected services from services.

Building preloaded state

initPreloadedState merges a partial state into the initial state returned by a reducer. Use it when an integration test needs a complete preloaded state but should override only the relevant fields:

import { initPreloadedState } from '@suite-common/test-utils';

const preloadedState = initPreloadedState({
    rootReducer,
    partialState: {
        counter: { value: 10 },
    },
});

Pass the result to createTestCompositionRoot.