Saltar al contenido

c

Probando aplicaciones React

Hay muchas formas diferentes de probar aplicaciones React. Echemos un vistazo a ellas a continuación.

Anteriormente, el curso utilizaba la librería Jest desarrollada por Facebook para probar componentes de React. Ahora estamos utilizando la nueva generación de herramientas de prueba de los desarrolladores de Vite llamada Vitest. Aparte de las configuraciones, las librerías proporcionan la misma interfaz de programación, por lo que prácticamente no hay diferencia en el código de prueba.

Comencemos instalando Vitest y la librería jsdom que simula un navegador web:

npm install --save-dev vitest jsdom

Además de Vitest, también necesitamos otra librería de pruebas que nos ayudará a renderizar componentes para fines de prueba. La mejor opción actual para esto es react-testing-library, que ha visto un rápido crecimiento en popularidad recientemente. También vale la pena extender el poder expresivo de las pruebas con la librería jest-dom.

Instalemos las librerías con el comando:

npm install --save-dev @testing-library/react @testing-library/jest-dom

Antes de que podamos hacer la primera prueba, necesitamos algunas configuraciones.

Agregamos un script al archivo package.json para ejecutar las pruebas:

{
  "scripts": {
    // ...
    "test": "vitest run"
  }
  // ...
}

Vamos a crear un archivo testSetup.js en la raíz del proyecto con el siguiente contenido

import { afterEach } from 'vitest'
import { cleanup } from '@testing-library/react'
import '@testing-library/jest-dom/vitest'

afterEach(() => {
  cleanup()
})

Ahora, luego de cada prueba, la función cleanup es ejecutada para resetear jsdom, que esta simulando al navegador.

Expandamos el archivo vite.config.js de la siguiente manera:

export default defineConfig({
  // ...
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: './testSetup.js', 
  }
})

Con globals: true, no es necesario importar palabras clave como describe, test y expect en las pruebas.

Primero escribamos pruebas para el componente que es responsable de renderizar una nota:

const Note = ({ note, toggleImportance }) => {
  const label = note.important
    ? 'make not important'
    : 'make important'

  return (
    <li className='note'>      {note.content}
      <button onClick={toggleImportance}>{label}</button>
    </li>
  )
}

Observa que el elemento li tiene el valor note para el atributo de CSS className, que podríamos usar para acceder al componente en nuestras pruebas.

Renderizando el componente para pruebas

Escribiremos nuestra prueba en el archivo src/components/Note.test.jsx, que está en el mismo directorio que el componente Note.

La primera prueba verifica que el componente muestra el contenido de la nota:

import { render, screen } from '@testing-library/react'
import Note from './Note'

test('renders content', () => {
  const note = {
    content: 'Component testing is done with react-testing-library',
    important: true
  }

  render(<Note note={note} />)

  const element = screen.getByText('Component testing is done with react-testing-library')
  expect(element).toBeDefined()
})

Después de la configuración inicial, la prueba renderiza el componente con el método render proporcionado por react-testing-library:

render(<Note note={note} />)

Normalmente, los componentes de React se procesan en el DOM. El método de renderizado que usamos renderiza los componentes en un formato que es adecuado para pruebas sin renderizarlos al DOM.

Podemos usar el objeto screen para acceder al componente renderizado. Utilizamos el método getByText de screen para buscar un elemento que tenga el contenido de la nota y asegurarnos de que existe:

  const element = screen.getByText('Component testing is done with react-testing-library')
  expect(element).toBeDefined()

La existencia de un elemento se verifica utilizando el comando expect de Vitest. Expect genera una aserción para su argumento, cuya validez se puede probar utilizando varias funciones de condición. Ahora utilizamos toBeDefined que prueba si el argumento element de expect existe.

Ejecuta el test con el comando npm test:

$ npm test

> notes-frontend@0.0.0 test
> vitest run


 RUN  v3.2.3 /home/vejolkko/repot/fullstack-examples/notes-frontend

 ✓ src/components/Note.test.jsx (1 test) 19ms
   ✓ renders content 18ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  14:31:54
   Duration  874ms (transform 51ms, setup 169ms, collect 19ms, tests 19ms, environment 454ms, prepare 87ms)

ESLint se queja de las palabras clave test y expect en las pruebas. El problema se resuelve añadiendo la siguiente configuración al archivo eslint.config.js:

// ...

export default [
  // ...
  {    files: ['**/*.test.{js,jsx}'],    languageOptions: {      globals: {        ...globals.vitest      }    }  }]

Así se informa a ESLint de que las palabras clave de Vitest están disponibles globalmente en los archivos de prueba.

Ubicación del archivo de prueba

En React hay (al menos) dos convenciones diferentes para la ubicación de los archivos de prueba. Creamos nuestros archivos de prueba de acuerdo con el estándar actual colocándolos en el mismo directorio que el componente que se está probando.

La otra convención es almacenar los archivos de prueba "normalmente" en su propio directorio separado test. Cualquiera que sea la convención que elijamos, es casi seguro que estará equivocada según la opinión de alguien.

Personalmente, no me gusta almacenar las pruebas y el código de la aplicación en el mismo directorio. Sin embargo, seguiremos este enfoque por ahora, ya que es la práctica más habitual en proyectos pequeños.

Búsqueda de contenido en un componente

El paquete react-testing-library ofrece muchas formas diferentes de investigar el contenido del componente que se está probando. En realidad, el expect en nuestra prueba no es necesario en absoluto:

import { render, screen } from '@testing-library/react'
import Note from './Note'

test('renders content', () => {
  const note = {
    content: 'Component testing is done with react-testing-library',
    important: true
  }

  render(<Note note={note} />)

  const element = screen.getByText('Component testing is done with react-testing-library')

  expect(element).toBeDefined()})

La prueba falla si getByText no encuentra el elemento que está buscando.

De forma predeterminada, getByText busca un elemento que contenga exactamente el texto proporcionado como parámetro, y nada más. Supongamos que un componente renderiza texto en un elemento HTML de la siguiente manera:

const Note = ({ note, toggleImportance }) => {
  const label = note.important
    ? 'make not important' : 'make important'

  return (
    <li className='note'>
      Your awesome note: {note.content}      <button onClick={toggleImportance}>{label}</button>
    </li>
  )
}

export default Note

El método getByText utilizado por la prueba no encuentra el elemento:

test('renders content', () => {
  const note = {
    content: 'Does not work anymore :(',
    important: true
  }

  render(<Note note={note} />)

  const element = screen.getByText('Does not work anymore :(')

  expect(element).toBeDefined()
})

Si queremos buscar un elemento que contenga el texto, podemos utilizar una opción adicional:

const element = screen.getByText(
  'Does not work anymore :(', { exact: false }
)

También podemos utilizar el método findByText:

const element = await screen.findByText('Does not work anymore :(')

Es importante observar que, a diferencia de los demás métodos ByText, findByText devuelve una promesa.

En otras situaciones resulta útil queryByText: el método devuelve el elemento, pero no lanza una excepción si no lo encuentra.

Podemos emplearlo, por ejemplo, para comprobar que algo no se renderiza en el componente:

test('does not render this', () => {
  const note = {
    content: 'This is a reminder',
    important: true
  }

  render(<Note note={note} />)

  const element = screen.queryByText('do not want this thing to be rendered')
  expect(element).toBeNull()
})

También existen otros métodos, como getByTestId, que busca elementos mediante identificadores creados expresamente para las pruebas.

También podríamos usar selectores CSS para encontrar elementos renderizados mediante el uso del método querySelector del objeto container, que es uno de los campos devueltos por el renderizado:

import { render, screen } from '@testing-library/react'
import Note from './Note'

test('renders content', () => {
  const note = {
    content: 'Component testing is done with react-testing-library',
    important: true
  }

  const { container } = render(<Note note={note} />)
  const div = container.querySelector('.note')  expect(div).toHaveTextContent(    'Component testing is done with react-testing-library'  )})

NB: Una forma más consistente de seleccionar elementos es usando un atributo de datos que esté específicamente definido para propósitos de prueba. Usando react-testing-library, podemos aprovechar el método getByTestId para seleccionar elementos con un atributo data-testid especificado.

Depurando pruebas

Normalmente nos encontramos con muchos tipos diferentes de problemas al escribir nuestras pruebas.

El objeto screen tiene el método debug que se puede utilizar para imprimir el HTML de un componente en el terminal. Si cambiamos la prueba de la siguiente manera:

import { render, screen } from '@testing-library/react'
import Note from './Note'

test('renders content', () => {
  const note = {
    content: 'Component testing is done with react-testing-library',
    important: true
  }

  render(<Note note={note} />)

  screen.debug()
  // ...

})

podemos ver el HTML generado por el componente en la consola:

console.log
  <body>
    <div>
      <li
        class="note"
      >
        Component testing is done with react-testing-library
        <button>
          make not important
        </button>
      </li>
    </div>
  </body>

También es posible utilizar el mismo método para imprimir el elemento que queramos en la consola:

import { render, screen } from '@testing-library/react'
import Note from './Note'

test('renders content', () => {
  const note = {
    content: 'Component testing is done with react-testing-library',
    important: true
  }

  render(<Note note={note} />)

  const element = screen.getByText('Component testing is done with react-testing-library')

  screen.debug(element)
  expect(element).toBeDefined()
})

Ahora el HTML del elemento que queríamos ver se imprime:

  <li
    class="note"
  >
    Component testing is done with react-testing-library
    <button>
      make not important
    </button>
  </li>

Clicando botones en las pruebas

Además de mostrar el contenido, el componente Note también se asegura de que cuando se clica al botón asociado con la nota, se llama a la función del controlador de eventos toggleImportance.

Instalemos la librería user-event que facilita un poco la simulación del input del usuario:

npm install --save-dev @testing-library/user-event

La prueba de esta funcionalidad se puede lograr así:

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'import Note from './Note'

// ...

test('clicking the button calls event handler once', async () => {
  const note = {
    content: 'Component testing is done with react-testing-library',
    important: true
  }
  
  const mockHandler = vi.fn()
  render(
    <Note note={note} toggleImportance={mockHandler} />  )

  const user = userEvent.setup()  const button = screen.getByText('make not important')  await user.click(button)
  expect(mockHandler.mock.calls).toHaveLength(1)})

Hay algunas cosas interesantes relacionadas con esta prueba. El controlador de eventos es la función mock definida con Vitest:

const mockHandler = vi.fn()

Se inicia una session (sesión) para interactuar con el componente renderizado:

const user = userEvent.setup()

La prueba encuentra el botón basada en el texto del componente renderizado y hace clic en el elemento:

const button = screen.getByText('make not important')
await user.click(button)

El clic ocurre con el método click de la librería userEvent.

La expectativa de la prueba utiliza toHaveLength para verificar que la mock function (función simulada) se haya llamado exactamente una vez:

expect(mockHandler.mock.calls).toHaveLength(1)

Las llamadas a la mock function son guardadas en el array mock.calls dentro del objeto de la mock function.

Mock objects and functions (Objetos y funciones simulados) son componentes stub (código auxiliar) comúnmente utilizados en pruebas que se utilizan para reemplazar las dependencias de los componentes que se están probando. Los simulacros permiten devolver respuestas codificadas de manera rígida y verificar cuántas veces se llaman las funciones simuladas y con qué parámetros.

En nuestro ejemplo, la función simulada es una opción perfecta ya que se puede utilizar fácilmente para verificar que el método se llame exactamente una vez.

Pruebas para el componente Togglable

Escribamos algunas pruebas para el componente Togglable. Las pruebas se muestran a continuación:

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Togglable from './Togglable'

describe('<Togglable />', () => {
  beforeEach(() => {
    render(
      <Togglable buttonLabel="show...">
        <div>togglable content</div>
      </Togglable>
    )
  })

  test('renders its children', () => {
    screen.getByText('togglable content')
  })

  test('at start the children are not displayed', () => {
    const element = screen.getByText('togglable content')
    expect(element).not.toBeVisible()
  })

  test('after clicking the button, children are displayed', async () => {
    const user = userEvent.setup()
    const button = screen.getByText('show...')
    await user.click(button)

    const element = screen.getByText('togglable content')
    expect(element).toBeVisible()
  })
})

La función beforeEach se llama antes de cada prueba y renderiza el componente Togglable.

La primera prueba verifica que el componente Togglable renderiza su componente hijo.

<div>
  togglable content
</div>

Las pruebas restantes utilizan toBeVisible para comprobar que el componente hijo de Togglable no es visible al principio, es decir, que el estilo del elemento div contiene { display: 'none' }. Otra prueba verifica que, al pulsar el botón, el componente pasa a ser visible y deja de tener asignado el estilo que lo oculta.

Agreguemos también una prueba que se pueda usar para verificar que el contenido visible se puede ocultar haciendo clic en el segundo botón del componente:

describe('<Togglable />', () => {

  // ...

  test('toggled content can be closed', async () => {
    const user = userEvent.setup()
    const button = screen.getByText('show...')
    await user.click(button)

    const closeButton = screen.getByText('cancel')
    await user.click(closeButton)

    const element = screen.getByText('togglable content')
    expect(element).not.toBeVisible()
  })
})

Probando los formularios

Ya usamos la función click de user-event en nuestras pruebas anteriores para hacer clic en los botones.

const user = userEvent.setup()
const button = screen.getByText('show...')
await user.click(button)

También podemos simular la entrada de texto con userEvent.

Hagamos una prueba para el componente NoteForm. El código del componente es el siguiente:

import { useState } from 'react'

const NoteForm = ({ createNote }) => {
  const [newNote, setNewNote] = useState('')

  const addNote = event => {
    event.preventDefault()
    createNote({
      content: newNote,
      important: true
    })

    setNewNote('')
  }

  return (
    <div>
      <h2>Create a new note</h2>

      <form onSubmit={addNote}>
        <input
          value={newNote}
          onChange={event => setNewNote(event.target.value)}
        />
        <button type="submit">save</button>
      </form>
    </div>
  )
}

export default NoteForm

El formulario funciona llamando a la función recibida como props createNote, con los detalles de la nueva nota.

La prueba es la siguiente:

import { render, screen } from '@testing-library/react'
import NoteForm from './NoteForm'
import userEvent from '@testing-library/user-event'

test('<NoteForm /> updates parent state and calls onSubmit', async () => {
  const createNote = vi.fn()
  const user = userEvent.setup()

  render(<NoteForm createNote={createNote} />)

  const input = screen.getByRole('textbox')
  const sendButton = screen.getByText('save')

  await user.type(input, 'testing a form...')
  await user.click(sendButton)

  expect(createNote.mock.calls).toHaveLength(1)
  expect(createNote.mock.calls[0][0].content).toBe('testing a form...')
})

Las pruebas tienen acceso al campo de input utilizando la función getByRole.

El método type de userEvent se utiliza para escribir texto en el campo de input.

La primera expectativa de la prueba asegura que al enviar el formulario el método createNote es llamado. La segunda expectativa verifica que el controlador de eventos se llama con los parámetros correctos, es decir, que se crea una nota con el contenido correcto cuando se llena el formulario.

Vale la pena mencionar que el buen viejo console.log funciona como de costumbre en las pruebas. Por ejemplo, si quieres ver cómo se ven las llamadas almacenadas por el objeto simulado, puedes hacer lo siguiente:

test('<NoteForm /> updates parent state and calls onSubmit', async() => {
  const user = userEvent.setup()
  const createNote = vi.fn()

  render(<NoteForm createNote={createNote} />)

  const input = screen.getByRole('textbox')
  const sendButton = screen.getByText('save')

  await user.type(input, 'testing a form...')
  await user.click(sendButton)

  console.log(createNote.mock.calls)})

En el medio de la ejecución de las pruebas, lo siguiente se imprime en la consola:

[ [ { content: 'testing a form...', important: true } ] ]

Sobre la búsqueda de elementos

Supongamos que el formulario tiene dos campos de input.

const NoteForm = ({ createNote }) => {
  // ...

  return (
    <div>
      <h2>Create a new note</h2>

      <form onSubmit={addNote}>
        <input
          value={newNote}
          onChange={event => setNewNote(event.target.value)}
        />
        <input          value={...}          onChange={...}        />        <button type="submit">save</button>
      </form>
    </div>
  )
}

Ahora la forma en la que nuestra prueba encuentra el campo de input

const input = screen.getByRole('textbox')

causaría un error:

error de node que muestra dos elementos con rol textbox ya que usamos getByRole

El mensaje de error sugiere utilizar getAllByRole. El test podría arreglarse de la siguiente forma:

const inputs = screen.getAllByRole('textbox')

await user.type(inputs[0], 'testing a form...')

El método getAllByRole ahora devuelve un array y el campo de input correcto es el primer elemento del array. Sin embargo, este enfoque es un poco sospechoso ya que depende del orden de los campos de input.

Si el campo input tuviera definido un label, podríamos localizarlo mediante getByLabelText. Por ejemplo, si añadimos una etiqueta al campo:

  // ...
  <label>    content    <input
      value={newNote}
      onChange={event => setNewNote(event.target.value)}
    />
  </label>  // ...

La prueba podría localizar el campo de la siguiente manera:

test('<NoteForm /> updates parent state and calls onSubmit', async () => {
  const user = userEvent.setup()
  const createNote = vi.fn()

  render(<NoteForm createNote={createNote} />) 

  const input = screen.getByLabelText('content')  const sendButton = screen.getByText('save')

  await user.type(input, 'testing a form...')
  await user.click(sendButton)

  expect(createNote.mock.calls).toHaveLength(1)
  expect(createNote.mock.calls[0][0].content).toBe('testing a form...')
})

A menudo, los campos de input tienen un texto de placeholder que indica al usuario qué tipo de input se espera. Agreguemos un placeholder a nuestro formulario:

const NoteForm = ({ createNote }) => {
  // ...

  return (
    <div>
      <h2>Create a new note</h2>

      <form onSubmit={addNote}>
        <input
          value={newNote}
          onChange={event => setNewNote(event.target.value)}
          placeholder='write note content here'        />
        <input
          value={...}
          onChange={...}
        />    
        <button type="submit">save</button>
      </form>
    </div>
  )
}

Ahora encontrar el campo de input correcto es fácil con el método getByPlaceholderText:

test('<NoteForm /> updates parent state and calls onSubmit', async () => {
  const user = userEvent.setup()
  const createNote = vi.fn()

  render(<NoteForm createNote={createNote} />) 

  const input = screen.getByPlaceholderText('write note content here')  const sendButton = screen.getByText('save')

  await user.type(input, 'testing a form...')
  await user.click(sendButton)

  expect(createNote.mock.calls).toHaveLength(1)
  expect(createNote.mock.calls[0][0].content).toBe('testing a form...')
})

La forma más flexible de encontrar elementos en pruebas es el método querySelector del objeto container, que es devuelto por render, como se mencionó anteriormente en esta parte. Se puede usar cualquier selector CSS con este método para buscar elementos en las pruebas.

Por ejemplo, podríamos definir un id único para el campo de input:

const NoteForm = ({ createNote }) => {
  // ...

  return (
    <div>
      <h2>Create a new note</h2>

      <form onSubmit={addNote}>
        <input
          value={newNote}
          onChange={event => setNewNote(event.target.value)}
          id='note-input'        />
        <input
          value={...}
          onChange={...}
        />    
        <button type="submit">save</button>
      </form>
    </div>
  )
}

El elemento input ahora podría ser encontrado en la prueba de la siguiente manera:

const { container } = render(<NoteForm createNote={createNote} />)

const input = container.querySelector('#note-input')

Sin embargo, nos adheriremos al enfoque de usar getByPlaceholderText en la prueba.

Cobertura de las pruebas

Podemos encontrar fácilmente la cobertura de nuestras pruebas ejecutándolas con el comando

npm test -- --coverage

La primera vez que ejecutes el comando, Vitest te preguntará si quieres instalar la librería requerida @vitest/coverage-v8. Instálala y ejecuta el comando de nuevo:

salida del terminal de cobertura de las pruebas

Se generará un informe HTML en el directorio coverage. El informe nos dirá las líneas de código no probado en cada componente:

reporte HTML de cobertura de las pruebas

Añadamos el directorio coverage/ al archivo .gitignore para excluir su contenido del control de versiones:

//...

coverage/

Puedes encontrar el código para nuestra aplicación actual en su totalidad en la rama part5-8 de este repositorio de GitHub.

Pruebas de integración del Frontend

En la parte anterior del material del curso, escribimos pruebas de integración para el backend que probaron su lógica y conectaron la base de datos a través de la API proporcionada por el backend. Al escribir estas pruebas, tomamos la decisión consciente de no escribir pruebas unitarias, ya que el código para ese backend es bastante simple, y es probable que los errores en nuestra aplicación ocurran en escenarios más complicados para los que las pruebas unitarias no son adecuadas.

Hasta ahora, todas nuestras pruebas para el frontend han sido pruebas unitarias que han validado el correcto funcionamiento de componentes individuales. Las pruebas unitarias son útiles a veces, pero incluso un conjunto completo de pruebas unitarias no es suficiente para validar que la aplicación funciona como un todo.

También podríamos realizar pruebas de integración para el frontend. Las pruebas de integración prueban la colaboración de múltiples componentes. Es considerablemente más difícil que las pruebas unitarias, ya que por ejemplo, tendríamos que simular datos del servidor. Elegimos concentrarnos en hacer pruebas de extremo a extremo para probar toda la aplicación, en la que trabajaremos en el último capítulo de esta parte.

Pruebas de instantáneas

Vitest ofrece una alternativa completamente diferente a las pruebas "tradicionales" llamada pruebas de instantáneas o snapshot testing. La característica interesante de las pruebas de instantáneas es que los desarrolladores no necesitan definir ninguna prueba ellos mismos, simplemente es suficiente adoptar las pruebas de instantáneas.

El principio fundamental es comparar el código HTML definido por el componente después de que haya cambiado con el código HTML que existía antes de que se cambiara.

Si la instantánea nota algún cambio en el HTML definido por el componente, entonces es una nueva funcionalidad o un "error" causado por accidente. Las pruebas instantáneas notifican al desarrollador si cambia el código HTML del componente. El desarrollador tiene que decirle a Jest si el cambio fue deseado o no. Si el cambio en el código HTML es inesperado, implica la gran posibilidad de tener un error y el desarrollador puede darse cuenta de estos problemas potenciales fácilmente gracias a las pruebas de instantáneas.