Solving React dependency injection in userland for testability

Pablo Garcia
3 min readNov 17, 2021

--

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()
})

Result

--

--

Pablo Garcia

Staff Sofware Architect 2 at PayPal, M.S. in Computer Science w/specialization in Computing Systems, B.Eng. in Computer Software.