Listado

Detalles de implementación en los tests, un artículo de Kent C. Dodds

Volvemos a apoyarnos en uno de nuestros grandes referentes del desarrollo para hablar de nuevo del testing: Kent C.Dodds*. Si hace unos meses hablamos de la importancia de hacer tests de calidad a partir de uno de sus artículos, hoy traemos una nueva traducción de su blog para hablar precisamente de los detalles de implementación en los tests.

Autor/a

Fecha de publicación

20/2/2024

Compartir

Twitter

LinkedIn

Esta es una traducción (libre) del artículo Testing Implementation Details de Kent C. Dodds.

En la época en la que usaba Enzyme (como todo el mundo en ese momento), trataba con mucho cuidado ciertas APIs. Evitaba completamente el shallow rendering, nunca usaba APIs como [.rr-code]instance()[.rr-code], [.rr-code]state()[.rr-code], o [.rr-code]find('ComponentName')[.rr-code]. Y en las revisiones de código de las pull requests de otras personas explicaba una y otra vez por qué es importante evitar estas APIs. La razón es que permiten que tus tests comprueben detalles de implementación de tus componentes. La gente a menudo me pregunta qué quiero decir con "detalles de implementación". Es decir, ¡ya es bastante difícil hacer tests! ¿Por qué tenemos que crear todas estas reglas para hacerlo más difícil?

¿Por qué está mal probar detalles de implementación?

Hay dos razones por las que es importante evitar probar detalles de implementación. Los tests que prueban detalles de implementación:

  1. Pueden romperse cuando se refactoriza el código de la aplicación. Falsos negativos
  2. Pueden no fallar cuando se rompe el código de la aplicación. Falsos positivos
Para ser claros, el test es: "¿funciona el software?". Si el test pasa correctamente, significa que el resultado es "positivo" (el software funciona). Si no lo hace, significa que el test es "negativo" (el software no funciona). El término "falso" se refiere a que el test ha dado un resultado incorrecto, es decir, que el software está roto pero pasa el test (falso positivo) o que el software funciona pero no pasa el test (falso negativo).

Veamos cada uno de estos casos, utilizando como ejemplo el siguiente componente de acordeón simple:


// accordion.js
import * as React from 'react'
import AccordionContents from './accordion-contents'

class Accordion extends React.Component {
  state = { openIndex: 0 }
  setOpenIndex = openIndex => this.setState({ openIndex })
  render() {
    const { openIndex } = this.state
    return (
      <div>
        {this.props.items.map((item, index) => (
          <>
            <button onClick={() => this.setOpenIndex(index)}>
              {item.title}
            </button>
            {index === openIndex ? (
              <AccordionContents>{item.contents}</AccordionContents>
            ) : null}
          </>
        ))}
      </div>
    )
  }
}

export default Accordion

Si te estás preguntando por qué estoy usando un anticuado componente de clase y no un componente funcional moderno (con hooks) para estos ejemplos, continúa leyendo, es una revelación interesante (que algunos de los que habéis experimentado con Enzyme puede que ya estéis esperando).

Y aquí tenemos un test que comprueba detalles de implementación:


// __tests__/accordion.enzyme.js
import * as React from 'react'
// if you're wondering why not shallow,
// then please read <https://kcd.im/shallow>
import Enzyme, {mount} from 'enzyme'
import EnzymeAdapter from 'enzyme-adapter-react-16'
import Accordion from '../accordion'

// Setup enzyme's react adapter
Enzyme.configure({adapter: new EnzymeAdapter()})

test('setOpenIndex sets the open index state properly', () => {
  const wrapper = mount(<Accordion items={[]} />)
  expect(wrapper.state('openIndex')).toBe(0)
  wrapper.instance().setOpenIndex(1)
  expect(wrapper.state('openIndex')).toBe(1)
})

test('Accordion renders AccordionContents with the item contents', () => {
  const hats = {title: 'Favorite Hats', contents: 'Fedoras are classy'}
  const footware = {
    title: 'Favorite Footware',
    contents: 'Flipflops are the best',
  }
  const wrapper = mount(<Accordion items={[hats, footware]} />)
  expect(wrapper.find('AccordionContents').props().children).toBe(hats.contents)
})

Levanta la mano si has visto (o escrito) tests como este en tu base de código (🙌).

Bien, ahora vamos a ver cómo se rompen las cosas con estos tests...

Falsos negativos al refactorizar

Una cantidad sorprendente de gente encuentra desagradable hacer tests, especialmente tests de UI. ¿Por qué? Hay varios motivos pero una razón importante que he escuchado muchas veces es que la gente pierde muchísimo tiempo arreglando tests. "¡Cada vez que hago un cambio en el código, los test se rompen!" ¡Esto es un verdadero lastre para la productividad! Veamos cómo nuestros tests son víctimas de este problema tan frustrante.

Digamos que llego y refactorizo ese acordeón para prepararlo para que se puedan desplegar varios elementos al mismo tiempo. Un refactor no cambia para nada el comportamiento actual, sólo cambia la implementación. Así que vamos a cambiar la implementación de manera que no cambie el comportamiento.

Digamos que estamos trabajando en añadir la habilidad de que se desplieguen a la vez múltiples elementos así que cambiamos nuestro estado interno de [.rr-code]openIndex[.rr-code] a [.rr-code]openIndexes[.rr-code]:


class Accordion extends React.Component {
- state = {openIndex: 0}
- setOpenIndex = openIndex => this.setState({openIndex})
+ state = {openIndexes: [0]}
+ setOpenIndex = openIndex => this.setState({openIndexes: [openIndex]})
  render() {
-   const {openIndex} = this.state
+   const {openIndexes} = this.state
    return (
      <div>
        {this.props.items.map((item, index) => (
          <>
            <button onClick={() => this.setOpenIndex(index)}>
              {item.title}
            </button>
-           {index === openIndex ? (
+           {openIndexes.includes(index) ? (
              <AccordionContents>{item.contents}</AccordionContents>
            ) : null}
          </>
        ))}
      </div>
    )
  }
}

Genial, hacemos una comprobación rápida en la app y todo sigue funcionando correctamente, así que cuando volvamos a este componente más adelante para soportar la apertura de múltiples acordeones, ¡será pan comido! Entonces lanzamos los tests y 💥boom💥 se han roto. ¿Cuál se ha roto? [.rr-code]setOpenIndex[.rr-code]pone el estado del índice abierto correctamente.

¿Cuál es el mensaje de error?


expect(received).toBe(expected)

Expected value to be (using ===):
  0
Received:
  undefined

¿Ese fallo en el test nos advierte de un problema real? ¡No! El componente sigue funcionando bien.

Esto es lo que se llama un falso negativo. Significa que tenemos un fallo en los tests, pero porque hay un test roto, no porque el código de la aplicación esté roto. Sinceramente, no puedo pensar en una situación de fallo de test más molesta. Bueno, vamos a seguir adelante y arreglar nuestro test:


test('setOpenIndex sets the open index state properly', () => {
  const wrapper = mount()
- expect(wrapper.state('openIndex')).toEqual(0)
+ expect(wrapper.state('openIndexes')).toEqual([0])
  wrapper.instance().setOpenIndex(1)
- expect(wrapper.state('openIndex')).toEqual(1)
+ expect(wrapper.state('openIndexes')).toEqual([1])
})

Qué podemos aprender: Los tests que comprueban detalles de implementación pueden dar falsos negativos cuando se refactoriza el código. Esto da lugar a tests frágiles y frustrantes que parecen romperse solo con mirar el código.

Falsos positivos

Vale, ahora digamos que tus compañeros están trabajando en el componente  [.rr-code]Accordion[.rr-code] y ven este código:


<button onClick={() => this.setOpenIndex(index)}>{item.title}</button>

Inmediatamente, sus sentimientos de optimización del rendimiento prematuros entran en acción y se dicen "¡oye! las inline arrow functions en el  [.rr-code]render[.rr-code] empeoran el rendimiento, así que ¡voy a corregirlo! Creo que esto debería funcionar, lo cambiaré muy rápido y ejecutaré los tests".


<button onClick={this.setOpenIndex}>{item.title}</button>

Bien. Ejecutan los tests y... ✅✅ ¡genial! Hacen el commit del código sin comprobarlo en el navegador porque los tests dan confianza, ¿no? Ese commit va en una PR sin relación alguna que cambia miles de líneas de código y es comprensible que se pierda. El acordeón se rompe en producción y Nancy no puede comprar sus entradas para ver Wicked en Salt Lake en febrero. Nancy llora y tu equipo se siente fatal.

¿Qué ha fallado? ¡¿No teníamos un test para comprobar que el estado cambia cuando se llama a  [.rr-code]setOpenIndex[.rr-code]  que el contenido del acordeón se muestra correctamente?! Pues sí. Pero el problema es que no había un test para comprobar que el botón estaba conectado a [.rr-code]setOpenIndex[.rr-code] correctamente.

Esto se llama falso positivo. Significa que no hemos tenido un fallo en los tests, ¡pero deberíamos! ¿Entonces cómo nos cubrimos para asegurarnos de que esto no vuelva a ocurrir? Tenemos que añadir otro test para comprobar que al hacer clic en el botón se actualiza el estado correctamente. Y luego necesito añadir un umbral de cobertura del código del 100% para que no volvamos a cometer este fallo. Ah, ¡y debería escribir una docena de plugins de ESLint para asegurarme de que la gente no usa estas APIs que animan a probar detalles de implementación!

...Pero voy a dejarlo estar... Ufff, estoy tan cansado de todos estos falsos positivos y negativos, que casi preferiría no escribir tests en absoluto. ¡BORRAD TODOS LOS TESTS! ¿No estaría bien que tuviéramos una herramienta con un mayor pozo del éxito (pit of success)? Pues sí. Y, adivina, ¡tenemos esa herramienta!

Tests libres de detalles de implementación

Así que podríamos reescribir todos estos tests con Enzyme, limitándonos a APIs libres de detalles de implementación, pero en lugar de eso, voy a usar React Testing Library que hará muy difícil incluir detalles de implementación en mis tests. ¡Vamos a comprobarlo!


// __tests__/accordion.rtl.js
import '@testing-library/jest-dom/extend-expect'
import * as React from 'react'
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Accordion from '../accordion'

test('can open accordion items to see the contents', () => {
  const hats = {title: 'Favorite Hats', contents: 'Fedoras are classy'}
  const footware = {
    title: 'Favorite Footware',
    contents: 'Flipflops are the best',
  }
  render(<Accordion items={[hats, footware]} />)

  expect(screen.getByText(hats.contents)).toBeInTheDocument()
  expect(screen.queryByText(footware.contents)).not.toBeInTheDocument()

  userEvent.click(screen.getByText(footware.title))

  expect(screen.getByText(footware.contents)).toBeInTheDocument()
  expect(screen.queryByText(hats.contents)).not.toBeInTheDocument()
})

¡Genial! Un solo test que verifica todo el comportamiento realmente bien. Y este test pasa si mi estado se llama  [.rr-code]openIndex [.rr-code], [.rr-code]openIndexes[.rr-code] o [.rr-code]tacosAreTasty🌮[.rr-code]. ¡Bien! Nos hemos librado de ese falso negativo. Y si no configuro mi click handler correctamente, este test fallará. Genial, ¡también me he librado de ese falso positivo! Y no he tenido que memorizar ninguna lista de reglas. Simplemente uso la herramienta de modo idiomático y tengo un test que realmente me puede dar confianza de que mi acordeón está funcionando como el usuario quiere.

Entonces... ¿qué son los detalles de implementación?

Esta es la definición más sencilla que se me ocurre:

Detalles de implementación son cosas que los usuarios de tu código normalmente no usarán, verán o ni siquiera conocerán.

Así que la primera pregunta a la que debemos responder es: "¿Quién es el usuario de este código?". Bueno, el usuario final que estará interactuando con nuestro componente en el navegador es definitivamente un usuario. Estarán observando e interactuando con los botones y contenidos renderizados. Pero también tenemos al desarrollador que renderizará el acordeón con props (en nuestro caso, una lista de elementos). Así que los componentes React suelen tener dos usuarios: los usuarios finales y los desarrolladores. Los usuarios finales y los desarrolladores son los dos "usuarios" que el código de nuestra aplicación debe tener en cuenta.

Genial, entonces ¿qué partes de nuestro código utiliza, ve y conoce cada uno de estos usuarios? El usuario final verá/interactuará con lo que renderizamos en el método  [.rr-code]render[.rr-code]. El desarrollador verá/interactuará con las props que pase al componente. Así que nuestro test normalmente sólo debería ver/interactuar con las props que se pasan y el resultado renderizado.

Esto es precisamente lo que hacen los tests de React Testing Library. Le damos nuestro propio elemento React del componente  [.rr-code]Accordion[.rr-code] con nuestras props falsas, luego interactuamos con el resultado renderizado buscando el contenido que se mostrará al usuario (o asegurando que no se mostrará) y haciendo clic en los botones que se renderizan.

Consideremos ahora el test de Enzyme. Con Enzyme, accedemos al  [.rr-code]state[.rr-code] de [.rr-code]openIndex[.rr-code]. Esto no es algo que preocupe a ninguno de nuestros usuarios directamente. No saben que se llame así, no saben si el índice abierto se almacena como un valor primitivo único o como un array, y francamente no les importa. Tampoco saben ni les importa el método  [.rr-code]setOpenIndex[.rr-code] específicamente. Y aun así nuestro test conoce ambos detalles de implementación.

Esto es lo que hace que nuestro test de Enzyme sea propenso a falsos negativos. Porque al hacer que nuestro test utilice el componente de forma diferente a como lo hacen los usuarios finales y los desarrolladores, creamos un tercer usuario que el código de nuestra aplicación debe tener en cuenta: ¡los tests! Y, francamente, los tests son un usuario que no interesa a nadie. No quiero que el código de mi aplicación tenga en cuenta los tests. Qué pérdida de tiempo. No quiero tests que se escriban porque sí. Los tests automatizados deben verificar que el código de la aplicación funciona para los usuarios de producción.

Cuanto más se parezcan tus tests a la forma en que se utiliza tu software, más confianza te darán. - Kent C. Dodds

Más información en Evita al Usuario Test.

¿Y qué pasa con los hooks?

Bueno, por lo que parece, Enzyme sigue teniendo muchos problemas con los hooks. Resulta que cuando estás probando detalles de implementación, un cambio en la implementación tiene un gran impacto en tus tests. Esto es un grave problema porque si estás migrando componentes de clase a componentes funcionales con hooks, entonces tus tests no pueden ayudarte a saber que no has roto nada en el proceso.

Por otro lado, ¿React Testing Library? Funciona de cualquier manera. Mira el enlace de codesandbox al final para verlo en acción. Me gusta llamar a los tests que escribes con React Testing Library:

Libres de detalles de implementación y amigables para el refactor.

Conclusión

Entonces, ¿cómo evitas probar detalles de implementación? Utilizar las herramientas adecuadas es un buen comienzo. Aquí tienes un proceso para saber qué probar. Seguir este proceso te ayudará a tener la mentalidad correcta cuando hagas tests y, naturalmente, evitarás los detalles de implementación:

  1. ¿Qué parte de tu código sin tests sería realmente mala si se rompiera? (El proceso de comprobación)
  2. Trata de reducirlo a una unidad o unas pocas unidades de código (Al hacer clic en el botón "checkout", se envía una solicitud con los artículos del carrito a /checkout).
  3. Observa el código y considera quiénes son los "usuarios" (el desarrollador que crea el formulario de pago, el usuario final que hace clic en el botón).
  4. Escribe una lista de instrucciones para que ese usuario pruebe manualmente ese código para asegurarse de que no está roto. (Renderizar el formulario con algunos datos falsos en el carrito, hacer clic en el botón de pago, asegurarse de que la API /checkout ha sido llamada con los datos correctos, responder con una respuesta correcta falsa, asegurarse de que el mensaje de éxito se muestra).
  5. Convierte esta lista de instrucciones en un test automatizado.

Espero que te sirva de ayuda. Si realmente quieres llevar tus tests al siguiente nivel, te recomiendo totalmente que obtengas una licencia Pro de TestingJavaScript.com 🏆

¡Buena Suerte!

P.D. Si quieres jugar con todo esto, aquí está el codesandbox.

P.P.D. Como ejercicio para ti... ¿Qué pasa con el segundo test de Enzyme si cambio el nombre del componente  [.rr-code]AccordionContest[.rr-code]?

* Kent C. Dodds está detrás de proyectos como React Testing Library o Remix, además de ser un gurú del desarrollo y la enseñanza en general. Puedes leer el artículo original aquí.

Relacionados

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

Extendiendo el principio de colocalización

Hace un tiempo publicamos un artículo sobre el principio de colocalización, una traducción libre del artículo “Colocation” de Kent C. Dodds. Hoy nos gustaría profundizar en este principio, explicando a dónde nos ha llevado en RedRadix.

26/3/2024

Button Text