Listado

Independencia de tests con React

Un testing de calidad es un pilar fundamental del desarrollo en RedRadix, es por ello que ponemos a vuestra disposición otra traducción de uno de los artículos del blog de Kent C. Dodds - cabeza detrás de proyectos como React Testing Library o Remix, y gurú del desarrollo y la enseñanza en general. Porque no sólo es importante añadir tests a tu código, sino que éstos aseguren los casos de uso en los que tu producto debe brillar.

Autor/a

Fecha de publicación

5/7/2023

Compartir

Twitter

LinkedIn

Esta es una traducción (libre) del artículo Test Isolation with React de Kent C. Dodds.

La inspiración para este post viene de ver tests de React como estos:


const utils = render(<Foo />)

test('test 1', () => {
  // use utils here
})

test('test 2', () => {
  // use utils here too
})

Así que me gustaría hablar sobre la importancia de la independencia o aislamiento de los tests y guiarte hacia una mejor manera de escribir tests que aumente su fiabilidad, simplifique el código y aumente tu confianza en los tests.

Tomemos como ejemplo este simple componente:


import React, { useRef } from 'react'

function Counter(props) {
  const initialProps = useRef(props).current
  const { initialCount = 0, maxClicks = 3 } = props

  const [count, setCount] = React.useState(initialCount)
  const tooMany = count >= maxClicks

  const handleReset = () => setCount(initialProps.initialCount)
  const handleClick = () => setCount(currentCount => currentCount + 1)

  return (
    <div>
      <button onClick={handleClick} disabled={tooMany}>
        Count: {count}
      </button>
      {tooMany
      	? <button onClick={handleReset}>reset</button>
        : null}
    </div>
  )
}

export { Counter }

Aquí la versión renderizada del mismo:

Nuestra primera suite de tests

Empecemos con una primera suite de tests como la que inspiró este post:


// gives us the toHaveTextContent/toHaveAttribute matchers
import '@testing-library/jest-dom/extend-expect'
import {render} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'

import {Counter} from '../counter'

const {getByText} = render(<Counter maxClicks={4} initialCount={3} />)
const counterButton = getByText(/^count/i)

test('the counter is initialized to the initialCount', () => {
  expect(counterButton).toHaveTextContent('3')
})

test('when clicked, the counter increments the click', () => {
  userEvent.click(counterButton)
  expect(counterButton).toHaveTextContent('4')
})

test(`the counter button is disabled when it's hit the maxClicks`, () => {
  userEvent.click(counterButton)
  expect(counterButton).toHaveAttribute('disabled')
})

test(`the counter button does not increment the count when clicked when it's hit the maxClicks`, () => {
  expect(counterButton).toHaveTextContent('4')
})

test(`the reset button has been rendered and resets the count when it's hit the maxClicks`, () => {
  userEvent.click(getByText(/reset/i))
  expect(counterButton).toHaveTextContent('3')
})

En primer lugar, a partir de @testing-library/react@9.0.0 este estilo de testing ni siquiera funcionaría correctamente, pero imaginemos por un momento que lo hiciera.

Estos tests nos dan un 100% de cobertura de la funcionalidad del componente y verifican exactamente lo que dicen que van a verificar. El problema sería que comparten un estado mutable. Pero… ¿cuál es el estado mutable que están compartiendo? ¡El componente!

Un test hace click sobre el botón del contador y los otros se apoyan en ese hecho para pasar. Si borráramos (o nos saltáramos con [.rr-code].skip[.rr-code] el segundo test (’cuando se hace click, el contador se incrementa’) todos los siguientes tests se romperían:

Esto supone un problema puesto que estos tests no pueden ser refactorizados de manera fiable, o no es posible ejecutar un único test aislado con propósitos de debugging porque no sabemos qué tests están impactando en el funcionamiento de qué otros. Para alguien que llega con la intención de hacer algún cambio a un test puede resultar muy confuso que otros tests sin relación aparente comiencen a romperse.

Mejor

Intentemos entonces algo diferente y veamos cómo cambian las cosas:


import '@testing-library/jest-dom/extend-expect'
import {render} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'

import {Counter} from '../counter'

let getByText, counterButton

beforeEach(() => {
  const utils = render(<Counter maxClicks={4} initialCount={3} />)
  getByText = utils.getByText
  counterButton = utils.getByText(/^count/i)
})

test('the counter is initialized to the initialCount', () => {
  expect(counterButton).toHaveTextContent('3')
})

test('when clicked, the counter increments the click', () => {
  userEvent.click(counterButton)
  expect(counterButton).toHaveTextContent('4')
})

test(`the counter button is disabled when it's hit the maxClicks`, () => {
  userEvent.click(counterButton)
  expect(counterButton).toHaveAttribute('disabled')
})

test(`the counter button does not increment the count when clicked when it's hit the maxClicks`, () => {
  userEvent.click(counterButton)
  userEvent.click(counterButton)
  expect(counterButton).toHaveTextContent('4')
})

test(`the reset button has been rendered and resets the count when it's hit the maxClicks`, () => {
  userEvent.click(counterButton)
  userEvent.click(getByText(/reset/i))
  expect(counterButton).toHaveTextContent('3')
})

De esta manera, cada test está completamente aislado del resto. Es posible borrar o saltar cualquiera de los tests y el resto de ellos seguirá pasando. La diferencia mayor y fundamental sería que cada uno de los tests tiene una instancia propia del contador con la que trabajar y que es desmontada después de cada test (esto ocurre automáticamente gracias a React Testing Library). Este cambio menor se traduce en una reducción significativa de la complejidad de nuestros tests.

Un argumento de algunas personas contra esta aproximación sería esgrimir que es más lenta que la aproximación anterior. No estoy totalmente seguro de cómo responder a eso… ¿Como cuánto de más lento? ¿Unos pocos milisegundos? En tal caso, ¿y qué? Entonces tu componente probablemente debería ser optimizado pues ese hecho en sí ya es un mal indicador. Sé que esta aproximación hace que se vaya acumulando tiempo en la ejecución, pero no debería importar en absoluto esperar unos cuantos segundos extra a cambio de la confianza añadida y la mejora en el mantenimiento que se proporciona. Además, a menudo no será necesario ejecutar la base de tests al completo gracias al fantástico soporte al watch mode que tiene Jest.

Todavía mejor

Aún así, no estoy del todo convencido con los tests que tenemos arriba. No soy un gran fan del [.rr-code]beforeEach[.rr-code] y el hecho de compartir variables entre tests. Siento que conducen a tests que son más difíciles de entender. Intentémoslo de nuevo:


import '@testing-library/jest-dom/extend-expect'
import {render} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'

import {Counter} from '../counter'

function renderCounter(props) {
  const utils = render(<Counter maxClicks={4} initialCount={3} {...props} />)
  const counterButton = utils.getByText(/^count/i)
  return {...utils, counterButton}
}

test('the counter is initialized to the initialCount', () => {
  const {counterButton} = renderCounter()
  expect(counterButton).toHaveTextContent('3')
})

test('when clicked, the counter increments the click', () => {
  const {counterButton} = renderCounter()
  userEvent.click(counterButton)
  expect(counterButton).toHaveTextContent('4')
})

test(`the counter button is disabled when it's hit the maxClicks`, () => {
  const {counterButton} = renderCounter({
    maxClicks: 4,
    initialCount: 4,
  })
  expect(counterButton).toHaveAttribute('disabled')
})

test(`the counter button does not increment the count when clicked when it's hit the maxClicks`, () => {
  const {counterButton} = renderCounter({
    maxClicks: 4,
    initialCount: 4,
  })
  userEvent.click(counterButton)
  expect(counterButton).toHaveTextContent('4')
})

test(`the reset button has been rendered and resets the count when it's hit the maxClicks`, () => {
  const {getByText, counterButton} = renderCounter()
  userEvent.click(counterButton)
  userEvent.click(getByText(/reset/i))
  expect(counterButton).toHaveTextContent('3')
})

Aquí hemos aumentado las repeticiones de código, pero ahora cada test está aislado no solo técnica sino visualmente. Puedes mirar un test y ver exactamente lo que hace sin tener que preocuparte de qué hooks se están ejecutando en su interior. Esto implica una mejora muy significativa de tu capacidad para poder refactorizar, quitar o añadir tests.

Todavía incluso mejor

Me gusta el punto al que hemos llegado, pero creo que necesitamos llevar nuestro planteamiento un paso más allá antes de quedarnos completamente satisfechos con el resultado. Hemos dividido nuestros tests por funcionalidad, pero en lo que queremos ganar confianza realmente es en el caso de uso para el que nuestro componente va a ser utilizado.

Nuestro contador permite ser clickado hasta que se alcanza un número máximo, y entonces requiere ser reseteado. Este es el caso que estamos intentando verificar y en el que queremos ganar confianza. Cuando estoy testeando, estoy mucho más interesado en casos de uso que en funcionalidad específica; así que, ¿cómo crees que cambiarían estos tests si nos preocupáramos más por el caso de uso que por la funcionalidad específica?


import '@testing-library/jest-dom/extend-expect'
import {render} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'

import { Counter } from '../counter'

test('allows clicks until the maxClicks is reached, then requires a reset', () => {
  const { getByText } = render(<Counter maxClicks={4} initialCount={3} />)
  const counterButton = getByText(/^count/i)

  // the counter is initialized to the initialCount
  expect(counterButton).toHaveTextContent('3')

  // when clicked, the counter increments the click
  userEvent.click(counterButton)
  expect(counterButton).toHaveTextContent('4')

  // the counter button is disabled when it's hit the maxClicks
  expect(counterButton).toHaveAttribute('disabled')
  // the counter button no longer increments the count when clicked.
  userEvent.click(counterButton)
  expect(counterButton).toHaveTextContent('4')

  // the reset button has been rendered and is clickable
  userEvent.click(getByText(/reset/i))

  // the counter is reset to the initialCount
  expect(counterButton).toHaveTextContent('3')

  // the counter can be clicked and increment the count again
  userEvent.click(counterButton)
  expect(counterButton).toHaveTextContent('4')
})

Yo adoro este tipo de tests, me ayuda a evitar pensar en la funcionalidad y enfocarme más en lo que estoy intentando conseguir con el componente. Además, creamos automáticamente una documentación mucho más explícita que la producida por medio de los tests anteriores.

En el pasado, la razón por la que no se hacía esto (tener múltiples aserciones dentro de un único test) era que resultaba difícil poder descubrir en qué punto del test se había producido el error. Pero hoy en día se dispone de una gestión de errores mucho más avanzada y resulta muy sencillo identificar en qué punto el test se ha roto. Por ejemplo:

Aquí el extracto de código es especialmente útil. Muestra no sólo el número de línea, sino también el código que se encuentra en torno a la aserción fallida, que incluye nuestros comentarios y más código, con el objetivo de proporcionarnos contexto en torno al mensaje de error, algo que no era posible con las primeras versiones que hemos mostrado de los tests.

Pero atención, esto no significa que no debas separar diferentes casos de uso para un componente. Hay muchas razones por las que querrías hacerlo y la mayor parte del tiempo será lo que hagas. Simplemente pon más foco en los casos de uso que en la funcionalidad y de manera automática estarás cubriendo la mayor parte del código que te concierne.

Será entonces cuando podrás añadir unos pocos tests extra para cubrir los casos límite.

Conclusión

¡Espero que te haya resultado útil este artículo! Puedes encontrar el código del ejemplo aquí. Intenta mantener tus tests aislados los unos de los otros y pon foco en los casos de uso en lugar de en la funcionalidad, ¡así tendrás una experiencia de testing mucho mejor! ¡Buena suerte y buen testing!

Relacionados

Grafana, una apuesta segura para la visualización de datos

Hablamos de una de nuestras herramientas favoritas para crear visualizaciones de datos y os explicamos cómo dar los primeros pasos para sacarle todo el partido.

28/5/2024

Menos es más: Cómo una infraestructura simple puede soportar un gran negocio (y una carrera)

En este artículo te mostramos cómo una pequeña inversión en DevOps e Infraestructura puede ayudarte a estar preparado para cualquier contingencia y lograr grandes beneficios. Te presentamos un caso real de éxito en el que una empresa ha logrado optimizar su entorno de trabajo, mejorar su seguridad y reducir costes.

5/4/2024

Button Text