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


 DEV  v1.3.1 /Users/mluukkai/opetus/2024-fs/part3/notes-frontend

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

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  17:05:37
   Duration  812ms (transform 31ms, setup 220ms, collect 11ms, tests 14ms, environment 395ms, prepare 70ms)


 PASS  Waiting for file changes...

Eslint se queja de las palabras clave test y expect en las pruebas. El problema se puede resolver instalando eslint-plugin-vitest-globals:

npm install --save-dev eslint-plugin-vitest-globals

y habilitando el plugin al editar el archivo .eslint.cjs de la siguiente manera:

module.exports = {
  root: true,
  env: {
    browser: true,
    es2020: true,
    "vitest-globals/env": true  },
  extends: [
    'eslint:recommended',
    'plugin:react/recommended',
    'plugin:react/jsx-runtime',
    'plugin:react-hooks/recommended',
    'plugin:vitest-globals/recommended',  ],
  // ...
}

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 esta forma de almacenar pruebas y código de aplicación en el mismo directorio. La razón por la que elegimos seguir esta convención es que está configurada de forma predeterminada en las aplicaciones creadas por Vite o create-react-app.

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.

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. Agreguemos el nombre de clase CSS togglableContent al div que devuelve los componentes hijos.

const Togglable = forwardRef((props, ref) => {
  // ...

  return (
    <div>
      <div style={hideWhenVisible}>
        <button onClick={toggleVisibility}>
          {props.buttonLabel}
        </button>
      </div>
      <div style={showWhenVisible} className="togglableContent">        {props.children}
        <button onClick={toggleVisibility}>cancel</button>
      </div>
    </div>
  )
})

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 />', () => {
  let container

  beforeEach(() => {
    container = render(
      <Togglable buttonLabel="show...">
        <div className="testDiv" >
          togglable content
        </div>
      </Togglable>
    ).container
  })

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

  test('at start the children are not displayed', () => {
    const div = container.querySelector('.togglableContent')
    expect(div).toHaveStyle('display: none')
  })

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

    const div = container.querySelector('.togglableContent')
    expect(div).not.toHaveStyle('display: none')
  })
})

La función beforeEach se llama antes de cada prueba, la cual luego renderiza el componente Togglable y guarda el campo container del valor devuelto.

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

<div className="testDiv">
  togglable content
</div>

Las pruebas restantes utilizan el método toHaveStyle para verificar que el componente hijo del componente Togglable no es visible inicialmente, comprobando que el estilo del elemento div contiene { display: 'none' }. Otra prueba verifica que cuando se presiona el botón, el componente es visible, lo que significa que el estilo para ocultarlo ya no está asignado al componente.

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 div = container.querySelector('.togglableContent')
    expect(div).toHaveStyle('display: none')
  })
})

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 handleChange = (event) => {
    setNewNote(event.target.value)
  }

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

    setNewNote('')
  }

  return (
    <div className="formDiv">
      <h2>Create a new note</h2>

      <form onSubmit={addNote}>
        <input
          value={newNote}
          onChange={handleChange}
        />
        <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 className="formDiv">
      <h2>Create a new note</h2>

      <form onSubmit={addNote}>
        <input
          value={newNote}
          onChange={handleChange}
        />
        <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.

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 className="formDiv">
      <h2>Create a new note</h2>

      <form onSubmit={addNote}>
        <input
          value={newNote}
          onChange={handleChange}
          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', () => {
  const createNote = vi.fn()

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

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

  userEvent.type(input, 'testing a form...')
  userEvent.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 className="formDiv">
      <h2>Create a new note</h2>

      <form onSubmit={addNote}>
        <input
          value={newNote}
          onChange={handleChange}
          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.

Antes de continuar, analicemos un par de detalles. 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 que la prueba utiliza no encuentra al 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()
})

El método getByText busca a un elemento que tenga exactamente el mismo texto que se proporciona como parámetro, y nada más. Si queremos buscar un elemento que contenga el texto, podríamos usar una opción adicional:

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

o podríamos usar el método findByText:

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

Es importante tener en cuenta que, a diferencia de los otros métodos ByText, findByText ¡devuelve una promesa!

Existen situaciones en las que otra forma del método queryByText es útil. El método devuelve el elemento pero no genera una excepción si no se lo encuentra.

Por ejemplo, podríamos utilizar el método para asegurarnos de que algo no se está renderizando 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()
})

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

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.