Adrián Ferrera

It is not uncommon to want to perform a test on a component in React that has a dependency on a React Context. Depending on the complexity of the context and its dependencies, we may be forced to create tests with low readability and high maintenance, or even skip the test we were planning to perform for our component.

When we work on creating Sustainable Code, we have to constantly balance cohesion and coupling. When we use a context, we create coupling between the Component and the wrapping Provider, but they are cohesive with each other: within the DOM tree, one doesn't make sense without the other.

In this article, we will see how to apply mocking strategies to perform a unit test easily.

Understanding what a Context is

A context is nothing more than a state definition in React whose information is accessible from any of its children through a hook. The component that implements this definition is known as a Provider and is essentially a component in a state.

Let's see an example of defining a context that indicates whether our browser has internet access. First, we will define the context:

interface ConnectionStateContextValue {
  online: boolean
}
type DefaultContextValue = ConnectionStateContextValue | undefined
export const ConnectionStateContext = React.createContext<DefaultContextValue>(undefined)

On the other hand, we will have the hook that describes how this context is going to be used by our components. Generally, we wrap it in a custom hook to reduce coupling and hide the implementation details:

export const useConnectionState = (): ConnectionStateContextValue | undefined => {
  return React.useContext(ConnectionStateContext)
}

We can be even more restrictive by indicating that our hook cannot be called without being wrapped in a Provider, thus eliminating the possibility of undefined:

export const useConnectionState = (): ConnectionStateContextValue => {
  const data = React.useContext(ConnectionStateContext)
  if (!data) {
    throw new Error(
      'useConnectionState has to be used within <ConnectionStateContext.Provider>'
    )
  }
  return data
}

And finally, we will create the component that implements this context, which follows the convention of being called Provider:

export const ConnectionStateProvider: React.FC<Props> = ({ children }) => {
  const [online, setOnline] = React.useState(navigator.onLine)

  React.useEffect(() => {
    const onlineEvent = (): void => {
      setOnline(true)
    }
    addEventListener('online', onlineEvent)

    const offlineEvent = (): void => {
      setOnline(false)
    }
    addEventListener('offline', offlineEvent)

    return () => {
      removeEventListener('online', onlineEvent)
      removeEventListener('offline', offlineEvent)
    }
  }, [])

  return (<ConnectionStateContext.Provider value={{ online }}>
    {children}
  </ConnectionStateContext.Provider>)
}

💡 There can be multiple implementations of the same context since, as we remember, the only requirement is that the component using it is wrapped in ConectionStateContext.Provider.

How a component use the context

On the component side, we just need to call the hook we defined earlier:

const MyUploader: React.FC = () => {
	const {online} = useConnectionState()

	return (<div>
		{!online && <div>Connection Lost</div> }
		<UploadForm disabled={!online}>
	</div>)
}

In this way, we can notify the user when our browser loses internet connection and disable the form.

We would like to test the behavior directly related to this component, so we will verify that when there is a connection, our message is displayed.

describe('MyUploader', ()=> {
	it('show connection lost message when browser is offline', () => {
		render(<MyUploader />)
		screen.getByText('Connection Lost') // ❌ Test fails because is not wrap in Provider
	})
})

When running the test, we see that we need to wrap the component in our Provider. At this point, two scenarios arise:

  • Replace the file that contains the hook using jest.mock or vi.mock.
  • Wrap the component in the Provider and see what happens.

Personally, I am not a fan of the first solution because replacing a file can lead to a synthetic test, losing a significant part of the integration of our components. It is good to be as close as possible to the real implementation.

describe('MyUploader', ()=> {
	it('show connection lost message when browser is offline', () => {
		render(<ConnectionStateProvider>
			<MyUploader />
		</ConnectionStateProvider>)
		screen.getByText('Connection Lost') // ❌ navigator does not exist
	})
})

In this case, the test indicates that navigator does not exist. Faced with this scenario, we could consider installing third-party libraries that replace the behavior of navigator or create a mock of it. However, neither of these options seems viable. So, is there another option?

This is where an important detail comes into play, which you probably overlooked:

💡 Multiple implementations of the same context can exist.

Creating our mock

At this point, we could consider creating a mock of the P_rovider_ using jest or vitest. However, we have internalized the use of libraries to replace code, and it shouldn't be the default solution to take.

When we create a context, it's because we want to access a state from any point in the application. That's why it makes sense to create a fake implementation since it will be used multiple times, and we don't want to define a specific replacement in each file.

To create these replacements, we rely on using a component in the tests.

describe('MyUploader', ()=> {
	it('show connection lost message when browser is offline', () => {
		render(
			<MyUploader />
    ) // ❌ Test fails because is not wrap in Provider
		screen.getByText('Connection Lost') 
	})
})

A simpler version to pass the test is to use the provider provided by the context itself:

describe('MyUploader', ()=> {
	it('show connection lost message when browser is offline', () => {
		const online = false
		render(
      <ConnectionContext.Provider value={{online}}>
			   <MyUploader />
      </ConnectionContext.Provider>
    )
		screen.getByText('Connection Lost') // ✅ Tests pass
	})
})

Sometimes, we might even consider extracting this provider to a component that is capable of simulating and hiding certain behavior:

describe('MyUploader', ()=> {

	const FakeConnectionProvider:React.FC<Partial<Props>> = ({children, online = true}) => {
		// ...Some aditional behaviour required at tests
		return (
			<ConnectionContext.Provider value={{online}}>
				{children}
	    </ConnectionContext.Provider>
		)
	} 
	
	it('uploader do some irrelevant stuff for this example', () => {
		render(
      <FakeConnectionProvider>
			   <MyUploader />
      </FakeConnectionProvider>
    )
		// expect
	})
})

This can be a viable approach when the provider is actually irrelevant to the test but still a dependency of our component. That's why we define a default value and apply the wrapping.

We could also add additional code if necessary in that provider, although this is usually a very isolated case. We must keep in mind that developing code specifically for a test is often considered a bad practice and a sign that we are approaching the test incorrectly.

Conclusion

  • We don't always need to rely on libraries.
  • Reduce mocks to increase the use of real code and test integration.
  • Testing integration is crucial at least.