El conocimiento es el nuevo dinero.
Aprender es la nueva manera en la que inviertes
Acceso Cursos

Escribiendo menos tests y más largos

· 9 min de lectura
Escribiendo menos tests y más largos

Imagine que tenemos esta interfaz de usuario que muestra un spinner de carga hasta que se cargan algunos datos:

Traducción en español del artículo original de Kentc dodds Write fewer, longer tests publicado el 26 Augusto 2019
import * as React from 'react'
import * as api from './api'

function Course({courseId}) {
  const [state, setState] = React.useState({
    loading: false,
    course: null,
    error: null,
  })

  const {loading, course, error} = state

  React.useEffect(() => {
    setState({loading: true, course: null, error: null})
    api.getCourseInfo(courseId).then(
      data => setState({loading: false, course: data, error: null}),
      e => setState({loading: false, course: null, error: e}),
    )
  }, [courseId])

  return (
    <>
      <div role="alert" aria-live="polite">
        {loading ? 'Loading...' : error ? error.message : null}
      </div>
      {course ? <CourseInfo course={course} /> : null}
    </>
  )
}

function CourseInfo({course}) {
  const {title, subtitle, topics} = course
  return (
    <div>
      <h1>{title}</h1>
      <strong>{subtitle}</strong>
      <ul>
        {topics.map(t => (
          <li key={t}>{t}</li>
        ))}
      </ul>
    </div>
  )
}

export default Course

Hablemos de probar este componente. Voy a simular la llamada api.getCourseInfo(courseId) para que en realidad no hagamos ninguna solicitud de red para este test. Estas son algunas de las cosas que necesitaremos hacer test, para asegurarnos que funciona:

  1. Debe mostrar una rueda de cargando.
  2. Debería llamar a la función getCourseInfo correctamente.
  3. Debería mostrar el título.
  4. Debería mostrar el subtítulo.
  5. Deberías mostrar la lista de temas del curso.

Luego están los casos del error (Cuando la petición falla):

  1. Debe mostrar una rueda de cargando.
  2. Debería llamar a la función getCourseInfo correctamente.
  3. Debería mostrar el mensaje de error.
CPU
1 vCPU
MEMORIA
1 GB
ALMACENAMIENTO
10 GB
TRANSFERENCIA
1 TB
PRECIO
$ 4 mes
Para obtener el servidor GRATIS debes de escribir el cupon "LEIFER"

Muchas personas leen esa lista de requisitos para el test de un componente y los convierten en casos de prueba individuales. Tal vez haya leído acerca de algo que dice "Confirmar una sola vez por test, es una buena práctica". Vamos a tratar de hacerlo:

// 🛑 ESTE ES UN EJEMPLO DE COMO NO SE DEBERIA HACER !! 
import * as React from 'react'
import {render, wait, cleanup} from '@testing-library/react/pure'
import {getCourseInfo} from '../api'
import Course from '../course'

jest.mock('../api')

function buildCourse(overrides) {
  return {
    title: 'TEST_COURSE_TITLE',
    subtitle: 'TEST_COURSE_SUBTITLE',
    topics: ['TEST_COURSE_TOPIC'],
    ...overrides,
  }
}

describe('Course success', () => {
  const courseId = '123'
  const title = 'My Awesome Course'
  const subtitle = 'Learn super cool things'
  const topics = ['topic 1', 'topic 2']

  let utils
  beforeAll(() => {
    getCourseInfo.mockResolvedValueOnce(buildCourse({title, subtitle, topics}))
  })

  afterAll(() => {
    cleanup()
    jest.resetAllMocks()
  })

  it('should show a loading spinner', () => {
    utils = render(<Course courseId={courseId} />)
    expect(utils.getByRole('alert')).toHaveTextContent(/loading/i)
  })

  it('should call the getCourseInfo function properly', () => {
    expect(getCourseInfo).toHaveBeenCalledWith(courseId)
  })

  it('should render the title', async () => {
    expect(await utils.findByRole('heading')).toHaveTextContent(title)
  })

  it('should render the subtitle', () => {
    expect(utils.getByText(subtitle)).toBeInTheDocument()
  })

  it('should render the list of topics', () => {
    const topicElsText = utils
      .getAllByRole('listitem')
      .map(el => el.textContent)
    expect(topicElsText).toEqual(topics)
  })
})

describe('Course failure', () => {
  const courseId = '321'
  const message = 'TEST_ERROR_MESSAGE'

  let utils, alert
  beforeAll(() => {
    getCourseInfo.mockRejectedValueOnce({message})
  })

  afterAll(() => {
    cleanup()
    jest.resetAllMocks()
  })

  it('should show a loading spinner', () => {
    utils = render(<Course courseId={courseId} />)
    alert = utils.getByRole('alert')
    expect(alert).toHaveTextContent(/loading/i)
  })

  it('should call the getCourseInfo function properly', () => {
    expect(getCourseInfo).toHaveBeenCalledWith(courseId)
  })

  it('should render the error message', async () => {
    await wait(() => expect(alert).toHaveTextContent(message))
  })
})

Definitivamente recomiendo en contra de este enfoque de prueba. Hay algunos problemas con eso:

  1. Los tests no son para nada aislados. (read  Test Isolation with React Inglés)
  2. Las mutaciones de variables se comparten entre tests (read  Avoid Nesting when you're Testing Inglés)
  3. Pueden ocurrir cosas asincrónicas entre los test, lo que hace que reciba warnings de "act".
Nos queda claro que los primeros dos puntos son aplicables independientemente de lo que esté probando. El tercero es un pequeño detalle de implementación entre jest y act.

En cambio, sugiero que combinemos los tests así:

// ✅ Este es un ejemplo de cómo hacer las cosas.
import {render, screen, wait} from '@testing-library/react'
import * as React from 'react'

import {getCourseInfo} from '../api'
import Course from '../course'

jest.mock('../api')

afterEach(() => {
  jest.resetAllMocks()
})

function buildCourse(overrides) {
  return {
    title: 'TEST_COURSE_TITLE',
    subtitle: 'TEST_COURSE_SUBTITLE',
    topics: ['TEST_COURSE_TOPIC'],
    ...overrides,
  }
}

test('course loads and renders the course information', async () => {
  const courseId = '123'
  const title = 'My Awesome Course'
  const subtitle = 'Learn super cool things'
  const topics = ['topic 1', 'topic 2']

  getCourseInfo.mockResolvedValueOnce(buildCourse({title, subtitle, topics}))

  render(<Course courseId={courseId} />)

  expect(getCourseInfo).toHaveBeenCalledWith(courseId)
  expect(getCourseInfo).toHaveBeenCalledTimes(1)

  const alert = screen.getByRole('alert')
  expect(alert).toHaveTextContent(/loading/i)

  const titleEl = await screen.findByRole('heading')
  expect(titleEl).toHaveTextContent(title)

  expect(screen.getByText(subtitle)).toBeInTheDocument()

  const topicElsText = screen.getAllByRole('listitem').map(el => el.textContent)
  expect(topicElsText).toEqual(topics)
})

test('an error is rendered if there is a problem getting course info', async () => {
  const message = 'TEST_ERROR_MESSAGE'
  const courseId = '321'

  getCourseInfo.mockRejectedValueOnce({message})

  render(<Course courseId={courseId} />)

  expect(getCourseInfo).toHaveBeenCalledWith(courseId)
  expect(getCourseInfo).toHaveBeenCalledTimes(1)

  const alert = screen.getByRole('alert')
  expect(alert).toHaveTextContent(/loading/i)

  await wait(() => expect(alert).toHaveTextContent(message))
})

Ahora nuestros tests están completamente aislados, ya no hay referencias de variables mutables compartidas, hay menos anidamiento, por lo que leer el test es más fácil y ya no recibiremos la advertencia de act de React.

Sí, hemos violado de "un assert por test", pero esa regla se creó originalmente porque frameworks mal trabajo de brindarnos un poco de información y tú necesitas determinar que está causando este error, cuando este falle verás algo así:

FAIL  src/__tests__/course-better.js
  ● course loads and renders the course information

    Unable to find an element with the text: Learn super cool things. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.

    <body>
      <div>
        <div
          aria-live="polite"
          role="alert"
        />
        <div>
          <h1>
            My Awesome Course
          </h1>
          <ul>
            <li>
              topic 1
            </li>
            <li>
              topic 2
            </li>
          </ul>
        </div>
      </div>
    </body>

      40 |   expect(titleEl).toHaveTextContent(title)
      41 |
    > 42 |   expect(getByText(subtitle)).toBeInTheDocument()
         |          ^
      43 |
      44 |   const topicElsText = getAllByRole('listitem').map(el => el.textContent)
      45 |   expect(topicElsText).toEqual(topics)

      at getElementError (node_modules/@testing-library/dom/dist/query-helpers.js:22:10)
      at node_modules/@testing-library/dom/dist/query-helpers.js:76:13
      at node_modules/@testing-library/dom/dist/query-helpers.js:59:17
      at Object.getByText (src/__tests__/course-better.js:42:10)

Y la terminal, también se resaltará la sintaxis:


Gracias a nuestras increíbles herramientas, identificar qué assert fallo es fácil. ¡Ni siquiera te dije lo que rompí, pero apuesto a que sabrás dónde buscar si esto te sucediera! Y puede evitar los problemas descritos anteriormente. Si desea dejar las cosas aún más claras, puede agregar un comentario en el código de la assert para explicar que tan importante es o que está haciendo.

Conclusión

No se preocupe por tener tests largos. Cuando está pensando en sus dos usuarios y evita al usuario de prueba, porque entonces sus test a menudo involucrarán múltiples afirmaciones y eso es algo bueno. No separe arbitrariamente sus assert en bloques de tests individuales, no hay una buena razón para hacerlo.

Debo señalar que no recomendaría renderizar el mismo componente varias veces en un solo bloque del test (las re-renderizaciones están bien si está probando lo que sucede en las actualizaciones props, por ejemplo).

Recuerde el siguiente principio:

Piense en un test, como si fuera un tester manual e intente hacer que cada uno de sus casos de prueba incluya todas las partes de ese proceso. Esto a menudo da como resultado múltiples acciones y afirmaciones, lo cual está bien.

Existe el antiguo modelo "Arrange", "Act", "Assert" para estructurar los tests. Por lo general, sugiero que tenga un únicamente "Arrange" por tests, y tantos "Act" y "Assert" como sea necesario para que el test cubra el proceso y le genere confianza sobre lo testeado.

EXTRAS:

Todavía recibo la advertencia de act, aunque estoy usando las React Testing Library.

La utilidad act de React está integrada en la React Testing library. Hay muy pocas veces que debería tener que usarlo directamente si está usando los async de React Testing Library.

  1. Cuando utilice jest.useFakeTimers()
  2. Cuando utilice useImperativeHandle y  llame funciones que llaman a los actualizadores de estado directamente.
  3. Al probar hooks personalizados que emplee funciones, directamente que llaman a los actualizadores de estado.

En cualquier otro momento, debería estar resuelto por React Testing Library. Si todavía tienes la advertencia de act, entonces la razón más probable es que algo esté sucediendo después de que se complete el test, por lo que debería estar esperando.

Aquí hay un ejemplo de un test (usando el mismo ejemplo anterior) que sufre este problema:

// 🛑 ESTE ES UN EJEMPLO DE COMO NO HACERLO...
test('course shows loading screen', () => {
  getCourseInfo.mockResolvedValueOnce(buildCourse())
  render(<Course courseId="123" />)
  const alert = screen.getByRole('alert')
  expect(alert).toHaveTextContent(/loading/i)
})

Aquí estamos renderizando el componente Course y tratando de verificar que el mensaje de carga se muestre correctamente. El problema es que cuando renderizamos el componente , inmediatamente lanza una solicitud asíncrona. Nos estamos mockeando correctamente esta solicitud (que lo estamos haciendo, de lo contrario, nuestro test realmente hará la solicitud). Sin embargo, nuestra prueba se completa sincrónicamente antes de que la solicitud mock tenga la oportunidad de resolverse. Cuando finalmente lo hace, se llama a nuestro handler, que llama a la función de actualización de estado y recibimos la advertencia de act.

Hay tres formas de arreglar esta situación:

  • Esperar a que la promesa se resuelva.
  • Use wait de React Testing Library
  • Ponga esta assert en otra prueba (la premisa de este artículo).
// 1. Esperando que la promesa se resuelva
// ⚠️ Esta es una buena manera de resolver este problema, pero hay una mejor manera, sigue leyendo
test('course shows loading screen', async () => {
  const promise = Promise.resolve(buildCourse())
  getCourseInfo.mockImplementationOnce(() => promise)
  render(<Course courseId="123" />)
  const alert = screen.getByRole('alert')
  expect(alert).toHaveTextContent(/loading/i)
  await act(() => promise)
})

Esto en realidad no es tan malo. Recomendaría esto si no hay cambios observables en el DOM. Tuve una situación como así en una interfaz de usuario que construí donde implementé una actualización optimista (lo que significa que la actualización del DOM ocurrió antes de que finalizara la solicitud) y, por lo tanto, no tenía forma de esperar/afirmar los cambios en el DOM.

// 2. usando `wait` de react testing library
test('course shows loading screen', async () => {
  getCourseInfo.mockResolvedValueOnce(buildCourse())
  render(<Course courseId="123" />)
  const alert = screen.getByRole('alert')
  expect(alert).toHaveTextContent(/loading/i)
  await wait()
})

Esto realmente solo funciona si el mock que ha creado se resuelve de inmediato, lo cual es muy probable (especialmente si está utilizando mockResolvedValueOnce). Aquí no tiene que usar act directamente, pero este test básicamente ignora todo lo que sucedió durante ese tiempo de espera, por lo que realmente no lo recomiendo.

La última (y mejor) recomendación que tengo para ti es que incluyas esta afirmación en las otras pruebas de tu componente. No hay mucho valor en mantener esta afirmación por sí sola.

Puedes ver el código final en GitHub

Nota personal: Esta solución no es solo para React, la razón por lo cual hice la traducción es porque es un contenido super importante, en mi caso he implementado lo mismo usando Testing Library en Angular.