Ir al contenido principal
Traducción Beta No Oficial

Esta página fue traducida por PageTurner AI (beta). No está respaldada oficialmente por el proyecto. ¿Encontraste un error? Reportar problema →

Escritura de pruebas

Qué aprenderás
  • Prácticas recomendadas para probar aplicaciones que usan Redux
  • Ejemplos de configuración para pruebas

Principios rectores

Los principios rectores para probar lógica de Redux siguen de cerca los de React Testing Library:

Cuanto más se asemejen tus pruebas a cómo se usa tu software, más confianza te darán. - Kent C. Dodds

Como la mayoría del código Redux que escribes son funciones, y muchas son puras, son fáciles de probar sin simulación. Sin embargo, deberías evaluar si cada parte de tu código Redux necesita sus propias pruebas dedicadas. En la mayoría de escenarios, el usuario final no sabe ni le importa si se usa Redux en la aplicación. Por tanto, el código Redux puede tratarse como un detalle de implementación de la app, sin requerir pruebas explícitas en muchas circunstancias.

Nuestro consejo general para probar una app con Redux es:

  • Prefiere escribir pruebas de integración con todo funcionando conjuntamente. Para una app React con Redux, renderiza un <Provider> con una instancia real de store envolviendo los componentes bajo prueba. Las interacciones deben usar lógica Redux real, con llamadas API simuladas para que el código no cambie, y verifica que la UI se actualiza correctamente.

  • Si es necesario, usa pruebas unitarias básicas para funciones puras como reductores o selectores particularmente complejos. Pero en muchos casos, estos son detalles de implementación cubiertos por pruebas de integración.

  • ¡No intentes simular funciones selectoras o los hooks de React-Redux! Simular imports de librerías es frágil y no te da confianza de que tu código real funciona.

información

Para contexto sobre por qué recomendamos pruebas de integración:

Configuración del entorno de pruebas

Runners de pruebas

Redux puede probarse con cualquier runner de pruebas, ya que es JavaScript estándar. Una opción cada vez más común es Vitest (utilizado en los repositorios de librerías Redux), aunque Jest sigue siendo ampliamente usado.

Normalmente, tu runner necesita configurarse para compilar sintaxis JavaScript/TypeScript. Si vas a probar componentes de UI sin navegador, probablemente necesitarás configurar el runner para usar JSDOM y obtener un entorno DOM simulado.

Los ejemplos en esta página asumirán que usas Vitest, pero los patrones aplican sin importar qué runner utilices.

Consulta estos recursos para configuraciones típicas de runners:

Herramientas para pruebas de UI y red

El equipo de Redux recomienda usar Vitest en modo navegador o React Testing Library (RTL) para probar componentes React conectados a Redux.

React Testing Library es una utilidad completa y sencilla para pruebas DOM en React que fomenta buenas prácticas. Usa la función render de ReactDOM y act de react-dom/tests-utils. (La familia Testing Library también incluye adaptadores para otros frameworks populares).

El modo navegador de Vitest ejecuta pruebas de integración en un navegador real, eliminando la necesidad de un entorno DOM "simulado" (y permitiendo feedback visual y pruebas de regresión). Con React, también necesitarás vitest-browser-react, que incluye una utilidad render similar a la de RTL.

También recomendamos usar Mock Service Worker (MSW) para simular peticiones de red, ya que esto evita tener que modificar o simular la lógica de tu aplicación al escribir pruebas.

Pruebas de integración para componentes conectados y lógica Redux

Nuestra recomendación para probar componentes React conectados a Redux es mediante pruebas de integración que incluyan todo el sistema funcionando conjuntamente, con aserciones enfocadas a verificar que la aplicación se comporta como esperas cuando el usuario interactúa con ella de determinadas maneras.

Ejemplo de código de aplicación

Considera el siguiente slice userSlice, store y componente App:

features/users/usersSlice.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import { userAPI } from './userAPI'
import type { RootState } from '../../app/store'

export const fetchUser = createAsyncThunk('user/fetchUser', async () => {
const response = await userAPI.fetchUser()
return response.data
})

interface UserState {
name: string
status: 'idle' | 'loading' | 'complete'
}

const initialState: UserState = {
name: 'No user',
status: 'idle'
}

const userSlice = createSlice({
name: 'user',
initialState,
reducers: {},
extraReducers: builder => {
builder.addCase(fetchUser.pending, (state, action) => {
state.status = 'loading'
})
builder.addCase(fetchUser.fulfilled, (state, action) => {
state.status = 'complete'
state.name = action.payload
})
}
})

export const selectUserName = (state: RootState) => state.user.name
export const selectUserFetchStatus = (state: RootState) => state.user.status

export default userSlice.reducer
app/store.ts
import { combineReducers, configureStore } from '@reduxjs/toolkit'
import userReducer from '../features/users/userSlice'
// Create the root reducer independently to obtain the RootState and PreloadedState type
const rootReducer = combineReducers({
user: userReducer
})
export function setupStore(preloadedState?: PreloadedState) {
return configureStore({
reducer: rootReducer,
preloadedState
})
}
export type PreloadedState = Parameters<typeof rootReducer>[0]
export type RootState = ReturnType<typeof rootReducer>
export type AppStore = ReturnType<typeof setupStore>
export type AppDispatch = AppStore['dispatch']
app/hooks.ts
import { useDispatch, useSelector } from 'react-redux'
import type { AppDispatch, RootState } from './store'

// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
features/users/UserDisplay.tsx
import React from 'react'
import { useAppDispatch, useAppSelector } from '../../app/hooks'
import { fetchUser, selectUserName, selectUserFetchStatus } from './userSlice'

export default function UserDisplay() {
const dispatch = useAppDispatch()
const userName = useAppSelector(selectUserName)
const userFetchStatus = useAppSelector(selectUserFetchStatus)

return (
<div>
{/* Display the current user name */}
<div>{userName}</div>
{/* On button click, dispatch a thunk action to fetch a user */}
<button onClick={() => dispatch(fetchUser())}>Fetch user</button>
{/* At any point if we're fetching a user, display that on the UI */}
{userFetchStatus === 'loading' && <div>Fetching user...</div>}
</div>
)
}

Esta aplicación incluye thunks, reductores y selectores. Todos pueden probarse mediante una prueba de integración considerando:

  • Al cargar la aplicación inicialmente, no debería haber usuario - debemos ver "No user" en pantalla.

  • Tras hacer clic en el botón "Fetch user", esperamos que comience a obtener el usuario. Deberíamos ver "Fetching user..." en pantalla.

  • Después de un tiempo, debería recibirse el usuario. Ya no veríamos "Fetching user...", sino el nombre del usuario esperado según la respuesta de nuestra API.

Al enfocar nuestras pruebas en este flujo completo, evitamos simular partes innecesarias de la aplicación. También ganamos confianza en que el comportamiento crítico funciona como esperamos cuando los usuarios interactúan con la app.

Para probar el componente, lo renderizamos en el DOM con render y verificamos que responde a interacciones como esperamos que lo haga un usuario real.

Configurar una función reusable para pruebas

La función render de React Testing Library acepta un árbol de elementos React y los renderiza. Como en una aplicación real, los componentes conectados a Redux necesitan un componente <Provider> de React-Redux que los envuelva, con una store Redux real configurada.

Además, el código de prueba debe crear una instancia de store Redux separada para cada prueba, en lugar de reutilizar la misma instancia y resetear su estado. Esto evita fugas de valores entre pruebas.

En vez de copiar/pegar la misma creación de store y configuración de Provider en cada prueba, podemos usar la opción wrapper en la función render y exportar nuestra propia función personalizada renderWithProviders que crea una nueva store Redux y renderiza un <Provider>, como se explica en la documentación de configuración de React Testing Library.

La función de renderizado personalizada debe permitir:

  • Crear una nueva instancia de store Redux cada vez que se llama, con un valor opcional preloadedState para estado inicial

  • Pasar alternativamente una instancia de store de Redux ya creada

  • Transmitir opciones adicionales a la función render original de RTL

  • Envolver automáticamente el componente en prueba con un <Provider store={store}>

  • Devolver la instancia del store por si la prueba necesita despachar más acciones o verificar el estado

Por conveniencia, configuraremos también una instancia de usuario.

Una configuración típica de función de renderizado personalizada podría verse así:

utils/test-utils.tsx
import React, { PropsWithChildren } from 'react'
import { render } from '@testing-library/react'
import type { RenderOptions } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'
import { Provider } from 'react-redux'

import type { AppStore, RootState, PreloadedState } from '../app/store'
import { setupStore } from '../app/store'

// This type interface extends the default options for render from RTL, as well
// as allows the user to specify other things such as preloadedState, store.
interface ExtendedRenderOptions
extends Omit<RenderOptions, 'queries' | 'wrapper'> {
preloadedState?: PreloadedState
store?: AppStore
}

export function renderWithProviders(
ui: React.ReactElement,
extendedRenderOptions: ExtendedRenderOptions = {}
) {
const {
preloadedState = {},
// Automatically create a store instance if no store was passed in
store = setupStore(preloadedState),
...renderOptions
} = extendedRenderOptions

const Wrapper = ({ children }: PropsWithChildren) => (
<Provider store={store}>{children}</Provider>
)

// Return an object with the store, user, and all of RTL's query functions
return {
store,
user: userEvent.setup(),
...render(ui, { wrapper: Wrapper, ...renderOptions })
}
}

Escritura de pruebas de integración con componentes

Los archivos de prueba reales deben usar la función render personalizada para renderizar nuestros componentes conectados a Redux. Si el código que probamos implica realizar solicitudes de red, también debemos configurar MSW para simular las solicitudes esperadas con datos de prueba apropiados.

features/users/tests/UserDisplay.test.tsx
import React from 'react'
import { beforeAll, afterEach, afterAll, test, expect } from 'vitest'
import { http, HttpResponse, delay } from 'msw'
import { setupServer } from 'msw/node'
import { screen } from '@testing-library/react'
// We're using our own custom render function and not RTL's render.
import { renderWithProviders } from '../../../utils/test-utils'
import UserDisplay from '../UserDisplay'

// We use msw to intercept the network request during the test,
// and return the response 'John Smith' after 150ms
// when receiving a get request to the `/api/user` endpoint
export const handlers = [
http.get('/api/user', async () => {
await delay(150)
return HttpResponse.json('John Smith')
})
]

const server = setupServer(...handlers)

// Enable API mocking before tests.
beforeAll(() => server.listen())

// Reset any runtime request handlers we may add during the tests.
afterEach(() => server.resetHandlers())

// Disable API mocking after the tests are done.
afterAll(() => server.close())

test('fetches & receives a user after clicking the fetch user button', async () => {
const { user } = renderWithProviders(<UserDisplay />)

// should show no user initially, and not be fetching a user
expect(screen.getByText(/no user/i)).toBeInTheDocument()
expect(screen.queryByText(/Fetching user\.\.\./i)).not.toBeInTheDocument()

// after clicking the 'Fetch user' button, it should now show that it is fetching the user
await user.click(screen.getByRole('button', { name: /Fetch user/i }))
expect(screen.queryByText(/no user/i)).not.toBeInTheDocument()
expect(screen.getByText(/Fetching user\.\.\./i)).toBeInTheDocument()

// after some time, the user should be received
expect(await screen.findByText(/John Smith/i)).toBeInTheDocument()
expect(screen.queryByText(/no user/i)).not.toBeInTheDocument()
expect(screen.queryByText(/Fetching user\.\.\./i)).not.toBeInTheDocument()
})

En esta prueba, evitamos por completo probar directamente cualquier código de Redux, tratándolo como un detalle de implementación. Como resultado, somos libres de refactorizar la implementación, mientras nuestras pruebas continuarán pasando y evitarán falsos negativos (pruebas que fallan a pesar de que la aplicación aún se comporta como queremos). Podríamos cambiar nuestra estructura de estado, convertir nuestro slice para usar RTK-Query, o eliminar Redux por completo, y nuestras pruebas seguirán pasando. Tenemos un alto grado de confianza en que si cambiamos algún código y nuestras pruebas reportan un fallo, entonces nuestra aplicación realmente está rota.

Preparación del estado inicial para pruebas

Muchas pruebas requieren que ciertas partes del estado ya existan en el store de Redux antes de renderizar el componente. Con la función de renderizado personalizada, hay un par de formas diferentes de hacerlo.

Una opción es pasar un argumento preloadedState a la función de renderizado personalizada:

TodoList.test.tsx
test('Uses preloaded state to render', () => {
const initialTodos = [{ id: 5, text: 'Buy Milk', completed: false }]

const { getByText } = renderWithProviders(<TodoList />, {
preloadedState: {
todos: initialTodos
}
})
})

Otra opción es crear primero un store de Redux personalizado y despachar algunas acciones para construir el estado deseado, luego pasar esa instancia de store específica:

TodoList.test.tsx
test('Sets up initial state state with actions', () => {
const store = setupStore()
store.dispatch(todoAdded('Buy milk'))

const { getByText } = renderWithProviders(<TodoList />, { store })
})

También puedes extraer store del objeto devuelto por la función de renderizado personalizada, y despachar más acciones más tarde como parte de la prueba.

Vitest modo navegador

Configurar una función reusable para pruebas

Similar a RTL, el modo navegador de Vitest proporciona una función render para renderizar componentes en un navegador real. Pero como probamos una app React-Redux, debemos asegurar que el <Provider> esté incluido en el árbol renderizado.

Podemos crear una función render personalizada que envuelva el componente en un <Provider> y configure una store Redux, similar a la función personalizada de RTL mostrada anteriormente.

utils/test-utils.tsx
import React, { PropsWithChildren } from 'react'
import { render } from 'vitest-browser-react'
import type { RenderOptions } from 'vitest-browser-react'
import { Provider } from 'react-redux'

import type { AppStore, RootState, PreloadedState } from '../app/store'
import { setupStore } from '../app/store'

// This type interface extends the default options for render from vitest-browser-react, as well
// as allows the user to specify other things such as preloadedState, store.
interface ExtendedRenderOptions extends Omit<RenderOptions, 'wrapper'> {
preloadedState?: PreloadedState
store?: AppStore
}

export async function renderWithProviders(
ui: React.ReactElement,
extendedRenderOptions: ExtendedRenderOptions = {}
) {
const {
preloadedState = {},
// Automatically create a store instance if no store was passed in
store = setupStore(preloadedState),
...renderOptions
} = extendedRenderOptions

const Wrapper = ({ children }: PropsWithChildren) => (
<Provider store={store}>{children}</Provider>
)

const screen = await render(ui, { wrapper: Wrapper, ...renderOptions })
// Return an object with the store, and the result of rendering
return {
store,
...screen
}
}

Por conveniencia, podemos adjuntarla a page en nuestro archivo de configuración:

setup.ts
import { renderWithProviders } from './utils/test-utils'
import { page } from 'vitest/browser'

page.extend({ renderWithProviders })

declare module 'vitest/browser' {
interface BrowserPage {
renderWithProviders: typeof renderWithProviders
}
}

Luego podemos usarla en nuestras pruebas, similar a RTL:

features/users/tests/UserDisplay.test.tsx
import React from 'react'
import { test, expect } from 'vitest'
import { page } from 'vitest/browser'
import UserDisplay from '../UserDisplay'

test('fetches & receives a user after clicking the fetch user button', async () => {
const { store, ...screen } = await page.renderWithProviders(<UserDisplay />)

const noUserText = screen.getByText(/no user/i)
const fetchingUserText = screen.getByText(/Fetching user\.\.\./i)
const userNameText = screen.getByText(/John Smith/i)

// should show no user initially, and not be fetching a user
await expect.element(noUserText).toBeInTheDocument()
await expect.element(fetchingUserText).not.toBeInTheDocument()

// after clicking the 'Fetch user' button, it should now show that it is fetching the user
await screen.getByRole('button', { name: /fetch user/i }).click()
await expect.element(noUserText).not.toBeInTheDocument()
await expect.element(fetchingUserText).toBeInTheDocument()

// after some time, the user should be received
await expect.element(userNameText).toBeInTheDocument()
await expect.element(noUserText).not.toBeInTheDocument()
await expect.element(fetchingUserText).not.toBeInTheDocument()
})

Pruebas unitarias de funciones individuales

Si bien recomendamos usar pruebas de integración por defecto, ya que ejercitan toda la lógica de Redux trabajando junta, a veces puedes querer escribir pruebas unitarias para funciones individuales también.

Reductores

Los reductores son funciones puras que devuelven el nuevo estado después de aplicar la acción al estado anterior. En la mayoría de los casos, el reductor es un detalle de implementación que no necesita pruebas explícitas. Sin embargo, si tu reductor contiene una lógica particularmente compleja para la que quieras tener la confianza de las pruebas unitarias, los reductores pueden probarse fácilmente.

Como los reductores son funciones puras, probarlos debería ser directo. Llama al reductor con un state y action específicos, y verifica que el estado resultante coincide con lo esperado.

Ejemplo

import { createSlice, PayloadAction } from '@reduxjs/toolkit'

export type Todo = {
id: number
text: string
completed: boolean
}

const initialState: Todo[] = [{ text: 'Use Redux', completed: false, id: 0 }]

const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
todoAdded(state, action: PayloadAction<string>) {
state.push({
id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
completed: false,
text: action.payload
})
}
}
})

export const { todoAdded } = todosSlice.actions

export default todosSlice.reducer

puede probarse así:

import { test, expect } from 'vitest'
import reducer, { todoAdded, Todo } from './todosSlice'

test('should return the initial state', () => {
expect(reducer(undefined, { type: 'unknown' })).toEqual([
{ text: 'Use Redux', completed: false, id: 0 }
])
})

test('should handle a todo being added to an empty list', () => {
const previousState: Todo[] = []

expect(reducer(previousState, todoAdded('Run the tests'))).toEqual([
{ text: 'Run the tests', completed: false, id: 0 }
])
})

test('should handle a todo being added to an existing list', () => {
const previousState: Todo[] = [
{ text: 'Run the tests', completed: true, id: 0 }
]

expect(reducer(previousState, todoAdded('Use Redux'))).toEqual([
{ text: 'Run the tests', completed: true, id: 0 },
{ text: 'Use Redux', completed: false, id: 1 }
])
})

Dispatch (Despacho)

Los selectores también son generalmente funciones puras y, por lo tanto, pueden probarse utilizando el mismo enfoque básico que los reductores: configura un valor inicial, llama a la función selector con esas entradas y verifica que el resultado coincida con la salida esperada.

Sin embargo, dado que la mayoría de los selectores están memoizados para recordar sus últimas entradas, es posible que necesites vigilar casos donde un selector devuelve un valor en caché cuando esperabas que generara uno nuevo dependiendo de dónde se use en la prueba.

Creadores de acciones y thunks

En Redux, los creadores de acciones son funciones que devuelven objetos simples. Nuestra recomendación es no escribir creadores de acciones manualmente, sino generarlos automáticamente con createSlice o crearlos mediante createAction de @reduxjs/toolkit. Por ello, no deberías sentir la necesidad de probar los creadores de acciones de forma aislada (¡los mantenedores de Redux Toolkit ya lo han hecho por ti!).

El valor de retorno de los creadores de acciones se considera un detalle de implementación dentro de tu aplicación y, al seguir un estilo de pruebas de integración, no requieren pruebas explícitas.

Del mismo modo, para las thunks usando Redux Thunk, nuestra recomendación es no escribirlas manualmente, sino utilizar createAsyncThunk de @reduxjs/toolkit. La thunk maneja el despacho de los tipos de acción pending, fulfilled y rejected según el ciclo de vida de la thunk.

Consideramos que el comportamiento de las thunks es un detalle de implementación de la aplicación y recomendamos que se cubra probando el grupo de componentes (o toda la app) que las utiliza, en lugar de probar la thunk de forma aislada.

Nuestra recomendación es simular solicitudes asíncronas a nivel de fetch/xhr usando herramientas como msw, miragejs, jest-fetch-mock, fetch-mock o similares. Al simular solicitudes en este nivel, ninguna lógica de la thunk tiene que cambiar en una prueba: la thunk sigue intentando hacer una solicitud asíncrona "real", simplemente es interceptada. Consulta el ejemplo de "Prueba de integración" para ver cómo probar un componente que incluye internamente el comportamiento de una thunk.

información

Si prefieres o necesitas escribir pruebas unitarias para tus creadores de acciones o thunks, consulta las pruebas que utiliza Redux Toolkit para createAction y createAsyncThunk.

Middleware

Las funciones middleware envuelven el comportamiento de las llamadas a dispatch en Redux, por lo que para probar este comportamiento modificado necesitamos simular el comportamiento de la llamada a dispatch.

Ejemplo

Primero, necesitaremos una función middleware. Esto es similar al verdadero redux-thunk.

const thunkMiddleware =
({ dispatch, getState }) =>
next =>
action => {
if (typeof action === 'function') {
return action(dispatch, getState)
}

return next(action)
}

Necesitamos crear funciones falsas de getState, dispatch y next. Usamos jest.fn() para crear stubs, pero con otros frameworks de pruebas probablemente usarías Sinon.

La función invoke ejecuta nuestro middleware de la misma manera que lo hace Redux.

const create = () => {
const store = {
getState: jest.fn(() => ({})),
dispatch: jest.fn()
}
const next = jest.fn()

const invoke = action => thunkMiddleware(store)(next)(action)

return { store, next, invoke }
}

Probamos que nuestro middleware llame a las funciones getState, dispatch y next en el momento adecuado.

test('passes through non-function action', () => {
const { next, invoke } = create()
const action = { type: 'TEST' }
invoke(action)
expect(next).toHaveBeenCalledWith(action)
})

test('calls the function', () => {
const { invoke } = create()
const fn = jest.fn()
invoke(fn)
expect(fn).toHaveBeenCalled()
})

test('passes dispatch and getState', () => {
const { store, invoke } = create()
invoke((dispatch, getState) => {
dispatch('TEST DISPATCH')
getState()
})
expect(store.dispatch).toHaveBeenCalledWith('TEST DISPATCH')
expect(store.getState).toHaveBeenCalled()
})

En algunos casos, necesitarás modificar la función create para usar diferentes implementaciones simuladas de getState y next.

Más información

  • React Testing Library: React Testing Library es una solución muy ligera para probar componentes de React. Proporciona funciones de utilidad ligeras sobre react-dom y react-dom/test-utils, fomentando mejores prácticas de pruebas. Su principio rector principal es: "Cuanto más se asemejen tus pruebas a la forma en que se usa tu software, más confianza pueden darte".

  • Blogged Answers: The Evolution of Redux Testing Approaches: Reflexiones de Mark Erikson sobre cómo las pruebas de Redux han evolucionado desde el "aislamiento" hacia la "integración".

  • Testing Implementation Details: Artículo de Kent C. Dodds sobre por qué recomienda evitar probar los detalles de implementación.