Solving React dependency injection in userland for testability
Using React hooks and context we can implement a simple dependency injection pattern to replace components’ dependencies when testing.
The fact of the matter is that dependency injection is a hard problem to solve and there are multiple solutions out there but here is one that doesn’t require too much code and can increase testability.
Problem Example
function useCustomHook() {
const [state, setState] = React.useState('loading...')
const timeoutIdRef = React.useRef(null)
React.useEffect(() => {
timeoutIdRef.current = setTimeout(
() => setState('this is taking longer than usual...'),
3000
)
}, [])
fetch('some/url').then(response => {
clearTimeout(timeoutIdRef.current)
setState(response.json())
})
return state
}function App() {
const [value] = useCustomHook()
return <>{value}</>
}
How do we test this?
As we can see, <App/>
is not a pure component as it doesn’t rely on the arguments it receives but on a local state within its own scope.
One way to tackle this problem is to refactor <App/>
into a pure component (e.g. passing value
as a prop and call useCustomHook
at the parent level), and then use composition to pass internal components as children
. I hope you can see why this is not sufficient. You can’t do this as easy for nested components, it is not scalable, etc.
Another way to handle this is by exporting the custom hook and using mocks for testing. This works, but it is not as flexible or straightforward if you are using Storybook.
If we use a state management library like Redux, this issue might blur away because we can select the state and propagate it through a single store. But having localized state makes everything complicated if you are not using Context.
Solution
We can create a custom Provider to replace dependencies within components for testing purposes.
const defaultDependencies = {};
const DependenciesContext = createContext(defaultDependencies);const useDependencies = () => useContext(DependenciesContext);export const DIProvider = memo(function DIProvider({
children,
...customDependencies
}) {
const upstreamDependencies = useDependencies();return (
<DependenciesContext.Provider value={{
...upstreamDependencies,
...customDependencies
}}>
{children}
</DependenciesContext.Provider>
);
});
And we can consume it using a custom hook inside the components:
export const useDI = (dependencies) => {
const upstreamDependencies = useDependencies(); if (Object.keys(upstreamDependencies).length > 0) {
return { ...dependencies, ...upstreamDependencies };
} return dependencies;
};
What is interesting to note here is that we won’t (shouldn’t… mustn’t!) replace the dependencies in production, so there is no real overhead.
And our components would look like this:
function useCustomHook() {
...
}function App() {
const deps = useDI({ useCustomHook }).
const [value] = deps.useCustomHook()
return <>{value}</>
}
And now our test looks like this:
test('check loading state', () => {
const { getByText } = render((
<DIProvider useCustomHook={() => ['loading']} >
<App />
</DIProvider>
)) expect(() => getByText('loading')).not.toThrow()
})test('check loaded state', () => {
const { getByText } = render((
<DIProvider useCustomHook={() => ['some value']} >
<App />
</DIProvider>
)) expect(() => getByText('some value')).not.toThrow()
})