Adrián Ferrera

No es nada extraño el querer realizar un test sobre un componente en React el cual tiene una dependencia hacia un React Context. Dependiendo de la complejidad del contexto y sus dependencias, nos vemos obligados a crear test cuya legibilidad es baja y su mantenimiento elevado o incluso a omitir el test que estábamos planteando hacer para nuestro componente.

Cuando trabajamos haciendo Código Sostenible tenemos que mantener en constante balance cohesión y acoplamiento. Cuando utilizamos un contexto estamos creando un acoplamiento entre el Componente y el Provider que lo envuelve, sin embargo, estos son cohesivos entre si: dentro del árbol DOM no tiene sentido el uno sin el otro.

En este artículo veremos como aplicar estrategias de mocking para realizar un test unitario de forma sencilla.

Entendiendo que es un Contexto

Un contexto no es otra cosa que una definición de un estado en React cuya información está accesible desde cualquiera de sus hijos a través de un hook. Al componente que implementa esta definición se le conoce como Provider y no es otra cosa que un componente en estado.

Veamos un ejemplo de la definición de un contexto el cual se encarga de indicarnos si nuestro navegador tiene acceso a internet. En primer lugar definiremos el contexto:

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

Por otra parte tendremos el hook de como va a ser utilizado este contexto por nuestros componentes. De forma general solemos envolverlo en un custom hook para reducir el acoplamiento y ocultar el tipo de implementación:

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

Podemos ser más restrictivos incluso, indicando que nuestro hook no puede llamarse sin estar envuelto en un Provider, eliminando así el tipo undefined de la ecuación:

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

Y finalmente crearemos el componente que implemente este contexto, el cual sigue la convención de ser llamado 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>)
}

💡 Pueden existir múltiples implementaciones de un mismo contexto, ya que recordemos que el único requisito es que el componente que lo usa esté envuelto en un ConectionStateContext.Provider.

Como usa el contexto un componente

Por la parte el componente únicamente tenemos que llamar al hook que hemos definido previamente:

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

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

De esta forma podemos indicar al usuario cuando nuestro navegador a perdido la conexión a internet y deshabilitar el formulario.

Nos gustaría probar el comportamiento directamente relacionado con este componente, así que comprobaremos que cuando hay conexión nuestro mensaje se muestra.

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

Al lanzar el test vemos que necesitamos envolver el componente en nuestro Provider. En este punto se nos plantean dos escenarios:

  • Reemplazar el fichero que contiene el hook haciendo uso de jest.mock o vi.mock
  • Envolver el componente en el Provider “y ver que ocurre”.

En lo personal no soy fan de la primera solución, ya que al reemplazar un fichero una prueba sintética y perdiendo mucha parte de la integración de nuestros componentes. Es bueno ser lo más cercano posible a la implementación real.

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

En este caso el test nos indica que navigator no existe. Frente a este escenario nos podríamos plantear instalar librerías de terceros que reemplacen el comportamiento de navigator, o crearnos un mock del mismo. Sin embargo ninguna de las dos opciones parece viable, por lo que ¿Existe otra opción?.

Es aquí donde entra en juego este detalle que muy probablemente pasaste por alto:

💡 Pueden existir múltiples implementaciones de un mismo contexto.

Creando nuestro mock

En este punto nos podemos plantear hacer un mock del provider con jest o vitest, sin embargo tenemos interiorizado el uso de librerías para reemplazar código y no debería ser la solución a tomar por defecto.

Cuando creamos un contexto es porque lo queremos acceder a un estado desde cualquier punto de la aplicación. Es por ello que tiene. sentido crear un fake, puesto que se utilizará muchas veces y no queremos definir en cada fichero un remplazo del mismo de forma específica.

Para poder crear estos reemplazos nos remitimos al uso de un componente desde los 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') 
	})
})

Una versión más simple de pasar el test es utilizar el Provider dado por el propio contexto:

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

En algunas ocasiones podríamos plantearnos incluso el extraer este Provider a un componente que fuese capaz de simular y ocultar cierto comportamiento:

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

Esto puede ser un approach viable cuando realmente el provider es irrelevante para el test, pero sigue siendo una de las dependencias de nuestro componente. Es por ello que definimos un valor por defecto y aplicamos la envoltura.

También podríamos añadir código adicional si fuese necesario en ese provider, sin embargo, esto suele ser un caso muy aislado. Debemos tener presente que desarrollar código para un test suele ser una mala práctica y un síntoma de que estamos enfocando mal el test.

Conclusión

  • No siempre necesitamos hacer uso de librerías
  • Reducir los mocks para aumentar el uso de código real y testar la integración
  • Al menos testar la integración es clave