b
Estado complejo, fetch y pruebas
Sigamos ampliando la versión Zustand de la aplicación de notas.
Para facilitar el desarrollo, cambiemos el estado inicial para que ya contenga algunas notas:
const initialNotes = [ { id: 1, content: 'Zustand is less complex than Redux', important: true, }, { id: 2, content: 'React app benefits from custom hooks', important: false, }, { id: 3, content: 'Remember to sleep well', important: true, } ]
const useNoteStore = create((set) => ({
notes: initialNotes,
// ...
}Estado más complejo
Implementemos el filtrado de las notas que se muestran en la aplicación, permitiendo restringir las notas visibles. El filtro se implementa mediante botones de opción:

Surge la pregunta de cuál es la mejor forma de gestionar el estado del filtro. Hay dos opciones: crear un store de Zustand separado para el filtro o añadirlo al store existente. Ambas son razonables. Las buenas prácticas recomiendan mantener los elementos que no guardan relación en stores separados. Sin embargo, la lista de notas y el filtro están tan vinculados que colocaremos ambos en el mismo store:
const useNoteStore = create((set) => ({
notes: initialNotes,
filter: 'all', actions: {
add: note => set(
state => ({ notes: state.notes.concat(note) })
),
toggleImportance: id => set(
state => ({
notes: state.notes.map(note =>
note.id === id ? { ...note, important: !note.important } : note
)
})
),
setFilter: value => set(() => ({ filter: value })) }
}))
export const useNotes = () => useNoteStore((state) => state.notes)
export const useFilter = () => useNoteStore((state) => state.filter)export const useNoteActions = () => useNoteStore((state) => state.actions)El componente que establece el valor del filtro:
import { useNoteActions } from './store'
const VisibilityFilter = () => {
const { setFilter } = useNoteActions()
return (
<div>
<input
type="radio"
name="filter"
onChange={() => setFilter('all')}
defaultChecked
/>
all
<input
type="radio"
name="filter"
onChange={() => setFilter('important')}
/>
important
<input
type="radio"
name="filter"
onChange={() => setFilter('nonimportant')}
/>
not important
</div>
)
}
export default VisibilityFilterEl componente App renderiza el filtro:
const App = () => (
<div>
<NoteForm />
<VisibilityFilter /> <NoteList />
</div>
)El filtrado de las notas mostradas podría manejarse en el componente NoteList, por ejemplo de la siguiente manera:
import { useNotes, useFilter } from './store'
import Note from './Note'
const NoteList = () => {
const notes = useNotes()
const filter = useFilter()
const notesToShow = notes.filter(note => { if (filter === 'important') return note.important if (filter === 'nonimportant') return !note.important return true })
return (
<ul>
{notesToShow.map(note => ( <Note key={note.id} note={note} />
))}
</ul>
)
}Se llega a una mejor solución incluyendo la lógica de filtrado directamente en la función useNotes del store:
import { create } from 'zustand'
const useNoteStore = create((set) => ({
// ...
}))
export const useNotes = () => { const notes = useNoteStore((state) => state.notes) const filter = useNoteStore((state) => state.filter) if (filter === 'important') return notes.filter(n => n.important) if (filter === 'nonimportant') return notes.filter(n => !n.important) return notes}La función useNotes devuelve siempre una lista de notas filtradas de la forma deseada. El consumidor de la función, el componente NoteList, ni siquiera necesita ser consciente de la existencia del filtro:
import { useNotes } from './store'
import Note from './Note'
const NoteList = () => {
// component gets always the properly filtered set of notes
const notes = useNotes()
return (
<ul>
{notes.map(note => (
<Note key={note.id} note={note} />
))}
</ul>
)
}¡La solución es elegante!
Una posible solución alternativa
Una alternativa sería implementar el filtrado directamente dentro de una función selectora, de modo que tanto las notas como el filtro se lean en una sola llamada useNoteStore:
export const useNotes = () => useNoteStore(({ notes, filter }) => { if (filter === 'important') return notes.filter(n => n.important) if (filter === 'nonimportant') return notes.filter(n => !n.important) return notes })Sin embargo, este enfoque no funciona, ya que conduce a un bucle infinito de renderizado cuando se cambia el filtro.
El motivo es el siguiente: Zustand compara el valor de retorno del selector utilizando el operador ===. Dado que notes.filter(...) crea una nueva array en cada renderizado, React siempre lo interpreta como un nuevo estado y activa otro renderizado, que nuevamente crea una nueva array, y así sucesivamente.
La solución consiste en añadir useShallow, que sustituye la comparación === por una comparación superficial: compara uno a uno los elementos del array. Si el contenido no ha cambiado, devuelve la referencia anterior en lugar de crear una nueva, por lo que React considera estable el estado y no vuelve a renderizar.
import { useShallow } from 'zustand/react/shallow' //... export const useNotes = () => useNoteStore(useShallow(({ notes, filter }) => { if (filter === 'important') return notes.filter(n => n.important) if (filter === 'nonimportant') return notes.filter(n => !n.important) return notes }))La solución funciona, pero es un poco más difícil de entender. En el material del curso utilizamos la versión presentada anteriormente con dos llamadas useNoteStore independientes.
El código actual de la aplicación está disponible en su totalidad en GitHub, en la rama part6-3.
Datos al servidor
Extendamos la aplicación para que las notas se almacenen en un backend. Utilizaremos el JSON Server que conocemos de la parte 2.
Guarde el estado inicial de la base de datos en el archivo db.json en la raíz del proyecto:
{
"notes": [
{
"id": 1,
"content": "Zustand is less complex than Redux",
"important": true
},
{
"id": 2,
"content": "React app benefits from custom hooks",
"important": false
},
{
"id": 3,
"content": "Remember to sleep well",
"important": true
}
]
}Instalar el servidor JSON:
npm install json-server --save-devy agregue la siguiente línea a la sección scripts de package.json:
"scripts": {
"server": "json-server -p 3001 db.json",
// ...
}Inicie el servidor JSON con el comando npm run server.
Fetch API
En el desarrollo de software, a menudo hay que considerar si implementar una determinada característica utilizando una librería externa o aprovechar las soluciones nativas proporcionadas por el entorno. Ambos enfoques tienen sus propias ventajas y desafíos.
En partes anteriores del curso hemos utilizado la librería Axios para realizar solicitudes HTTP. Veamos ahora una alternativa basada en la Fetch API nativa.
Es típico que una librería externa como Axios se implemente utilizando otras librerías externas. Por ejemplo, si instala Axios en un proyecto con el comando npm install axios, la salida de la consola es:
$ npm install axios
added 23 packages, and audited 302 packages in 1s
71 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilitiesPor lo tanto, el comando instalaría no solo la librería de Axios sino también más de 20 paquetes npm más que Axios necesita para funcionar.
La Fetch API permite realizar solicitudes HTTP de forma similar a Axios, pero sin instalar librerías externas. Mantener una aplicación resulta más sencillo cuando hay menos librerías que actualizar y la seguridad también mejora al reducirse su superficie de ataque potencial. La seguridad y el mantenimiento se tratan en la parte 7 del curso.
En la práctica, la realización de solicitudes se realiza mediante la función fetch(). La sintaxis utilizada tiene algunas diferencias respecto a Axios. Pronto también notaremos que Axios se encargó de algunas cosas por nosotros y nos hizo la vida más fácil. Sin embargo, ahora usaremos la API Fetch porque es una solución nativa ampliamente utilizada con la que todo desarrollador Full Stack debería estar familiarizado.
Obtención de datos del servidor
Creemos una función que obtenga datos del backend en el archivo src/services/notes.js:
const baseUrl = 'http://localhost:3001/notes'
const getAll = async () => {
const response = await fetch(baseUrl)
if (!response.ok) {
throw new Error('Failed to fetch notes')
}
const data = await response.json()
return data
}
export default { getAll }Veamos más de cerca la implementación de la función getAll. Las notas ahora se obtienen del backend llamando a la función fetch(), a la que se le ha dado la URL del backend como argumento. El tipo de solicitud no se especifica por separado, por lo que fetch realiza la acción predeterminada, que es una solicitud GET.
Cuando llega la respuesta, comprobamos si la solicitud se realizó correctamente mirando el campo response.ok y arrojamos un error si es necesario:
if (!response.ok) {
throw new Error('Failed to fetch notes')
}El atributo response.ok obtiene el valor true si la solicitud se realizó correctamente, es decir, si el código de estado de respuesta está en el rango 200-299. Para todos los demás códigos de estado, como 404 o 500, obtiene el valor false.
Ten en cuenta que fetch no genera automáticamente un error aunque el código de estado de la respuesta sea, por ejemplo, 404. El manejo de errores debe implementarse manualmente, como acabamos de hacer.
Si la solicitud tuvo éxito, los datos contenidos en la respuesta se convierten al formato JSON:
const data = await response.json()fetch no convierte automáticamente los datos que puedan acompañar a la respuesta al formato JSON; la conversión debe realizarse manualmente. También vale la pena señalar que response.json() es una función asincrónica, por lo que se debe usar la palabra clave await con ella.
Simplifiquemos un poco el código devolviendo los datos devueltos por la función response.json() directamente:
const getAll = async () => {
const response = await fetch(baseUrl)
if (!response.ok) {
throw new Error('Failed to fetch notes')
}
return await response.json()}Agreguemos una función al store que se puede usar para inicializar el estado con notas obtenidas del servidor:
const useNoteStore = create((set) => ({
notes: [], filter: '',
actions: {
// ...
setFilter: value => set(() => ({ filter: value })),
initialize: notes => set(() => ({ notes })) }
}))Implementemos la inicialización de notas en el componente App; como es habitual al recuperar datos de un servidor, utilizamos el hook useEffect:
const App = () => {
const { initialize } = useNoteActions()
useEffect(() => { noteService.getAll().then(notes => initialize(notes)) }, [initialize])
return (
<div>
<NoteForm />
<VisibilityFilter />
<NoteList />
</div>
)
}Por lo tanto, las notas se obtienen del servidor usando la función getAll() que definimos y luego se almacenan usando la función initialize del store. Estas acciones se realizan en el hook useEffect, lo que significa que se ejecutan durante el primer renderizado del componente de la aplicación.
Veamos un pequeño detalle. Hemos añadido la función initialize al array de dependencias del hook useEffect. Si intentamos utilizar un array de dependencias vacío, ESLint muestra la advertencia React Hook useEffect has a missing dependency: 'initialize'. ¿Qué está ocurriendo?
El código funcionaría igual aunque utilizáramos un array de dependencias vacío, porque initialize hace referencia a la misma función durante toda la ejecución. Sin embargo, es una buena práctica incluir como dependencias todas las variables y funciones utilizadas por useEffect que estén definidas dentro del componente. Esto ayuda a evitar errores inesperados.
Envío de datos al servidor
A continuación, implementemos la funcionalidad para enviar una nueva nota al servidor. Al mismo tiempo podemos practicar cómo realizar una solicitud POST usando la función fetch().
Extendamos el código de comunicación del servidor en src/services/notes.js de la siguiente manera:
const baseUrl = 'http://localhost:3001/notes'
const getAll = async () => {
const response = await fetch(baseUrl)
if (!response.ok) {
throw new Error('Failed to fetch notes')
}
return await response.json()
}
const createNew = async (content) => { const response = await fetch(baseUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content, important: false }), }) if (!response.ok) { throw new Error('Failed to create note') } return await response.json()}
export default { getAll, createNew }Veamos más de cerca la implementación de la función createNew. El primer parámetro de la función fetch() especifica la URL a la que se realiza la solicitud. El segundo parámetro es un objeto que define los demás detalles de la solicitud, como el tipo de solicitud, los encabezados y los datos enviados con la solicitud. Podemos aclarar aún más el código almacenando el objeto que define los detalles de la solicitud en una variable auxiliar options separada:
const createNew = async (content) => {
const options = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content, important: false }), } const response = await fetch(baseUrl, options)
if (!response.ok) {
throw new Error('Failed to create note')
}
return await response.json()
}Miremos más de cerca el objeto options:
- method define el tipo de solicitud, que en este caso es POST
- headers define los encabezados de solicitud. Adjuntamos el encabezado 'Content-Type': 'application/json' a la solicitud para que el servidor sepa que los datos incluidos con la solicitud están en formato JSON y pueda manejar la solicitud correctamente.
- body contiene los datos que se enviarán con la solicitud. El campo no puede contener directamente un objeto JavaScript; primero debe convertirse en una cadena JSON llamando a JSON.stringify().
Al igual que con la solicitud GET, aquí también verificamos el código de estado de respuesta para detectar errores:
if (!response.ok) {
throw new Error('Failed to create note')
}Si la solicitud tiene éxito, JSON Server devuelve la nota recién creada, para la cual también generó un id único. Los datos contenidos en la respuesta aún deben convertirse al formato JSON usando la función response.json():
return await response.json()Luego cambiemos el componente NoteForm de nuestra aplicación para que se envíe una nueva nota al backend. La función addNote del componente cambia ligeramente:
import { useNoteActions } from './store'
import noteService from './services/notes'
const NoteForm = () => {
const { add } = useNoteActions()
const addNote = async (e) => {
e.preventDefault()
const content = e.target.note.value
const newNote = await noteService.createNew(content) add(newNote)
e.target.reset()
}
return (
<form onSubmit={addNote}>
<input name="note" />
<button type="submit">add</button>
</form>
)
}
export default NoteFormCuando se crea una nueva nota en el backend llamando a la función createNew(), obtenemos un objeto que describe la nota, para la cual el backend ha generado un id.
El código actual de la aplicación está disponible en su totalidad en GitHub, en la rama part6-4.
Acciones asíncronas
Nuestro enfoque es bastante bueno, pero en cierto sentido desafortunado, ya que la comunicación con el servidor ocurre dentro del código de las funciones que definen los componentes. Sería mejor si la comunicación pudiera abstraerse de los componentes, de modo que solo necesiten llamar a una función apropiada que proporciona el store.
Queremos que App inicialice el estado de la aplicación de la siguiente manera:
const App = () => {
const { initialize } = useNoteActions()
useEffect(() => {
initialize() }, [initialize])
return (
<div>
<NoteForm />
<VisibilityFilter />
<NoteList />
</div>
)
}NoteForm a su vez crea una nueva nota como esta:
const NoteForm = () => {
const { add } = useNoteActions()
const addNote = async (e) => {
e.preventDefault()
const content = e.target.note.value
await add(content) e.target.reset()
}
return (
<form onSubmit={addNote}>
<input name="note" />
<button type="submit">add</button>
</form>
)
}El cambio a store.js es el siguiente:
import { create } from 'zustand'
import noteService from './services/notes'
const useNoteStore = create((set) => ({
notes: [],
filter: '',
actions: {
add: async (content) => { const newNote = await noteService.createNew(content) set(state => ({ notes: state.notes.concat(newNote) }))
},
initialize: async () => { const notes = await noteService.getAll() set(() => ({ notes }))
},
// ...
}
}))Las funciones add y initialize se han convertido en funciones asincrónicas, que primero llaman a la función noteService adecuada y luego actualizan el estado.
La solución es elegante; La gestión del estado y la comunicación con el servidor están completamente separadas fuera de los componentes de React.
Finalicemos la aplicación sincronizando el cambio de importancia con el servidor.
noteService.js se amplía de la siguiente manera:
const update = async (id, note) => {
const response = await fetch(`${baseUrl}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(note),
})
if (!response.ok) {
throw new Error('Failed to update note')
}
return await response.json()
}
export default { getAll, createNew, update } El cambio a la función toggleImportance del store es el siguiente:
const useNoteStore = create((set) => ({
notes: [],
filter: '',
actions: {
add: async (content) => {
const newNote = await noteService.createNew(content)
set(state => ({ notes: state.notes.concat(newNote) }))
},
toggleImportance: async (id) => { const note = useNoteStore.getState().notes.find(n => n.id === id) const updated = await noteService.update( id, { ...note, important: !note.important } ) set(state => ({ notes: state.notes.map(n => n.id === id ? updated : n) })) }, setFilter: value => set(() => ({ filter: value })),
initialize: async () => {
const notes = await noteService.getAll()
set(() => ({ notes }))
}
}
}))Hay un detalle digno de mención en la nueva función. La función recibe el id de la nota como parámetro. Sin embargo, la nota modificada debe enviarse al backend. Se puede encontrar llamando a la función getState del store:
const note = useNoteStore.getState().notes.find(n => n.id === id)Los stores de Zustand también tienen otras funciones auxiliares, que pueden resultar útiles en algunas situaciones.
Sin embargo, cambiemos también la definición del store para que también pasemos el parámetro get a la función dada a create, a través de la cual podemos acceder a los valores de estado cuando sea necesario:
const useNoteStore = create((set, get) => ({ notes: [],
filter: '',
actions: {
toggleImportance: async (id) => {
const note = get().notes.find(n => n.id === id) const updated = await noteService.update(
id, { ...note, important: !note.important }
)
set(state => ({
notes: state.notes.map(n => n.id === id ? updated : n)
}))
},
// ...
}
}))La función get devuelve el estado actual del store. Por ejemplo, la llamada get().notes proporciona las notas actuales del store. La función get es funcionalmente equivalente a llamar a useNoteStore.getState(), pero es la forma más idiomática de referirse al estado del store desde las propias funciones del store.
El código de la aplicación está en GitHub en la rama part6-5.
Middlewares
Al desarrollar una aplicación, a menudo nos encontramos con situaciones en las que es difícil entender por qué la aplicación se comporta de forma inesperada. El estado cambia como resultado de alguna llamada a una función de acción, pero no está claro qué llamada cambió qué y en qué orden. El registro tradicional de funciones individuales en la consola sólo ayuda de forma limitada.
Zustand admite los llamados middlewares, que se pueden utilizar para agregar funcionalidad a los stores de forma transparente, sin tocar la propia lógica del store. La idea del middleware es simple: "envuelve" el store y puede, por ejemplo, registrar automáticamente cada cambio de estado.
La forma de las funciones del middleware es algo críptica. A continuación se muestra un logger que siempre imprime el estado antiguo y nuevo del store cada vez que cambia el estado:
const logger = (config) => (set, get) => config(
(...args) => {
console.log('prev state', get());
set(...args);
console.log('next state', get());
},
get
);El middleware se activa "envolviendo" la función dada al create de Zustand como parámetro:
const useNoteStore = create(logger((set, get) => ({ notes: [],
filter: '',
actions: {
// ...
}
})))Ahora, cada vez que cambia el estado del store, siempre podemos ver en la consola cómo cambia el estado:

En la práctica, nuestro middleware definido funciona reemplazando la función original set con la función
(...args) => {
console.log('prev state', get());
set(...args);
console.log('next state', get());
}que además de llamar a set, también imprime el estado antiguo y nuevo (accesible a través de la función get) en la consola. El segundo parámetro es el antiguo get sin cambios.
Zustand también tiene un middleware devtools listo para usar que integra el store con la extensión Redux DevTools del navegador. Devtools es una herramienta de desarrollo extremadamente útil, ya que le permite realizar un seguimiento visual de los cambios de estado.
La configuración es sencilla:
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
const useNoteStore = create(devtools((set, get) => ({ notes: [],
filter: '',
actions: {
// ...
}
})))Cuando la extensión Redux DevTools está instalada en el navegador, el estado del store y sus cambios se pueden inspeccionar en las herramientas de desarrollo del navegador:

Pruebas de los stores de Zustand
Finalmente, veamos cómo probar los stores de Zustand con Vitest.
Para simplificar, comencemos con el store del contador:
import { create } from 'zustand'
const useCounterStore = create(set => ({
counter: 0,
actions: {
increment: () => set(state => ({ counter: state.counter + 1 })),
decrement: () => set(state => ({ counter: state.counter - 1 })),
zero: () => set(() => ({ counter: 0 })),
}
}))
export const useCounter = () => useCounterStore(state => state.counter)
export const useCounterControls = () => useCounterStore(state => state.actions)
export default useCounterStoreAgregamos una exportación a la definición de las pruebas, a través de la cual la prueba puede acceder al store.
Instalemos Vitest:
npm install --save-dev vitestImplementemos la prueba en el archivo store.test.js:
import { beforeEach, describe, expect, it } from 'vitest'
import useCounterStore from './store'
beforeEach(() => {
useCounterStore.setState({ counter: 0 })
})
describe('counter store', () => {
it('initial state is 0', () => {
expect(useCounterStore.getState().counter).toBe(0)
})
it('increment increases counter by 1', () => {
useCounterStore.getState().actions.increment()
expect(useCounterStore.getState().counter).toBe(1)
})
it('decrement decreases counter by 1', () => {
useCounterStore.getState().actions.decrement()
expect(useCounterStore.getState().counter).toBe(-1)
})
it('zero resets counter to 0', () => {
useCounterStore.getState().actions.increment()
useCounterStore.getState().actions.increment()
useCounterStore.getState().actions.zero()
expect(useCounterStore.getState().counter).toBe(0)
})
})Las pruebas son bastante sencillas y utilizan la función getState del store, que les permite leer el estado del store y ejecutar las funciones del store.
Antes de cada prueba, el store se restablece a su estado inicial en el bloque beforeEach usando la función setState del store.
En nuestro caso, restablecer el store a su estado inicial es sencillo, aunque no siempre lo es. La documentación de Zustand describe cómo crear una versión de los stores para las pruebas que se restablece automáticamente antes de cada una. Sin embargo, el método es lo bastante complejo e innecesario para nuestro caso como para omitirlo por ahora.
Por tanto, las pruebas utilizan el store directamente. Si se ha implementado una lógica más compleja mediante hooks personalizados, puede ser necesario escribir pruebas que también los utilicen. En el contador se accede al store mediante los hooks useCounter y useCounterControls:
const useCounterStore = create(set => ({
// ...
}))
// hightlight-start
export const useCounter = () => useCounterStore(state => state.counter)
export const useCounterControls = () => useCounterStore(state => state.actions)
// hightlight-endEn este caso, los hooks no contienen ninguna lógica, simplemente exponen por separado el valor almacenado en el store y las funciones del store. Por lo tanto, el método de prueba que utilizamos anteriormente es perfectamente correcto.
Sin embargo, hagamos otra versión de las pruebas a modo de ejemplo, donde el store se usa exactamente de la misma manera que la aplicación.
useCounter y useCounterControls son hooks de React, por lo que probarlos requiere la Librería de prueba de React y la librería jsdom:
npm install --save-dev @testing-library/react jsdomAgreguemos la configuración del entorno de prueba a vite.config.js:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: { environment: 'jsdom', },})Las pruebas son las siguientes:
import { beforeEach, describe, expect, it } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import useCounterStore, { useCounter, useCounterControls } from './store'
beforeEach(() => {
useCounterStore.setState({ counter: 0 })
})
describe('counter hooks', () => {
it('useCounter returns initial value of 0', () => {
const { result } = renderHook(() => useCounter())
expect(result.current).toBe(0)
})
it('increment updates counter', () => {
const { result: counter } = renderHook(() => useCounter())
const { result: controls } = renderHook(() => useCounterControls())
act(() => controls.current.increment())
expect(counter.current).toBe(1)
})
it('decrement updates counter', () => {
const { result: counter } = renderHook(() => useCounter())
const { result: controls } = renderHook(() => useCounterControls())
act(() => controls.current.decrement())
expect(counter.current).toBe(-1)
})
it('zero resets counter', () => {
const { result: counter } = renderHook(() => useCounter())
const { result: controls } = renderHook(() => useCounterControls())
act(() => {
controls.current.increment()
controls.current.increment()
controls.current.zero()
})
expect(counter.current).toBe(0)
})
})Hay algunas cosas interesantes en la prueba. Al comienzo de las pruebas, los hooks se representan usando la función renderHook:
const { result: counter } = renderHook(() => useCounter())
const { result: controls } = renderHook(() => useCounterControls())De esta forma la prueba obtiene acceso a los valores devueltos por los hooks, que se almacenan en las variables counter y controls.
Los hooks se llaman envolviendo la llamada dentro de la función act:
act(() => {
controls.current.increment()
controls.current.increment()
controls.current.zero()
})Finalmente, se produce la expectativa de la prueba:
expect(counter.current).toBe(0)Como podemos ver, para acceder al hook en sí aún necesitamos tomar el campo current del objeto devuelto por renderHook, que corresponde al valor actual del hook.
¿Qué es acto?
act es una función auxiliar que garantiza que todas las actualizaciones de estado y sus efectos secundarios se hayan procesado antes de que continúe el código de prueba.
Cuando se produce un cambio de estado en un componente o enlace de React, React no actualiza el estado inmediatamente sino que pone las actualizaciones en cola. act obliga a ejecutar estas actualizaciones en cola.
Sin actuar, una prueba podría verificar el estado antes de que React haya tenido tiempo de actualizarlo, lo que provocaría que la prueba falle o dé resultados incorrectos.
La librería de pruebas de React incluye muchas de sus funciones (como fireEvent, userEvent) automáticamente, pero cuando se prueban enlaces directamente, generalmente es necesario.
Las pruebas mediante hooks utilizan la librería de pruebas de React y representan los hooks en un contexto real de React usando jsdom. Este enfoque es considerablemente más lento que las pruebas que utilizan el store directamente, por lo que si los enlaces no contienen lógica compleja, puede ser suficiente ejecutar las pruebas utilizando el store directamente.
El código que contiene las pruebas de contador de Zustand está disponible en GitHub.
Pruebas del store de notas
Probar el store de la aplicación de notas es un caso algo más desafiante, ya que el store contiene funciones asincrónicas que llaman al servidor:
import { create } from 'zustand'
import noteService from './services/notes'
const useNoteStore = create(set => ({
notes: [],
filter: '',
actions: {
add: async (content) => {
const newNote = await noteService.createNew(content) set(state => ({ notes: state.notes.concat(newNote) }))
},
toggleImportance: async (id) => {
const note = useNoteStore.getState().notes.find(n => n.id === id)
const updated = await noteService.update( id, { ...note, important: !note.important } ) set(state => ({
notes: state.notes.map(n => n.id === id ? updated : n)
}))
},
setFilter: value => set(() => ({ filter: value })),
initialize: async () => {
const notes = await noteService.getAll() set(() => ({ notes }))
}
}
}))
export const useNotes = () => {
const notes = useNoteStore((state) => state.notes)
const filter = useNoteStore((state) => state.filter)
if (filter === 'important') return notes.filter(n => n.important)
if (filter === 'nonimportant') return notes.filter(n => !n.important)
return notes
}
export const useFilter = () => useNoteStore((state) => state.filter)
export const useNoteActions = () => useNoteStore((state) => state.actions)Esta vez useNotes también contiene una cantidad significativa de lógica, por lo que las pruebas probablemente deberían realizarse mediante enlaces con la librería de pruebas React.
Instalemos las librerías necesarias:
npm install --save-dev vitest @testing-library/react jsdomAgreguemos la configuración del entorno de prueba a vite.config.js:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: { environment: 'jsdom', },})La primera parte de las pruebas es la siguiente:
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
vi.mock('./services/notes', () => ({
default: {
getAll: vi.fn(),
createNew: vi.fn(),
update: vi.fn(),
}
}))
import noteService from './services/notes'
import useNoteStore, { useNotes, useFilter, useNoteActions } from './store'
beforeEach(() => {
useNoteStore.setState({ notes: [], filter: '' })
vi.clearAllMocks()
})
describe('useNoteActions', () => {
it('initialize loads notes from service', async () => {
const mockNotes = [{ id: 1, content: 'Test', important: false }]
noteService.getAll.mockResolvedValue(mockNotes)
const { result } = renderHook(() => useNoteActions())
await act(async () => {
await result.current.initialize()
})
const { result: notesResult } = renderHook(() => useNotes())
expect(notesResult.current).toEqual(mockNotes)
})
it('add appends a new note', async () => {
const newNote = { id: 2, content: 'New note', important: false }
noteService.createNew.mockResolvedValue(newNote)
const { result } = renderHook(() => useNoteActions())
await act(async () => {
await result.current.add('New note')
})
const { result: notesResult } = renderHook(() => useNotes())
expect(notesResult.current).toContainEqual(newNote)
})
it('toggleImportance flips important flag', async () => {
const note = { id: 1, content: 'Test', important: false }
useNoteStore.setState({ notes: [note] })
noteService.update.mockResolvedValue({ ...note, important: true })
const { result } = renderHook(() => useNoteActions())
await act(async () => {
await result.current.toggleImportance(1)
})
const { result: notesResult } = renderHook(() => useNotes())
expect(notesResult.current[0].important).toBe(true)
})
})Hay mucho que digerir en las pruebas. Las pruebas crean, usando Vitest, una versión simulada del noteService responsable de comunicarse con el servidor:
import { describe, it, expect, beforeEach, vi } from 'vitest'
vi.mock('./services/notes', () => ({
default: {
getAll: vi.fn(),
createNew: vi.fn(),
update: vi.fn(),
}
}))vi.mock reemplaza el noteService en el módulo ./services/notes con su propia versión, donde todas las funciones se reemplazan con funciones simuladas devueltas por vi.fn.
Antes de cada prueba, el store se restablece a su estado inicial y se borran las funciones simuladas:
beforeEach(() => {
useNoteStore.setState({ notes: [], filter: '' })
vi.clearAllMocks()
})Al comienzo de cada prueba, al noteService simulado se le indica a través de la función mockResolvedValue cómo debe comportarse en el contexto de la prueba:
it('initialize loads notes from service', async () => {
const mockNotes = [{ id: 1, content: 'Test', important: false }] noteService.getAll.mockResolvedValue(mockNotes)
const { result } = renderHook(() => useNoteActions())
await act(async () => {
await result.current.initialize()
})
const { result: notesResult } = renderHook(() => useNotes())
expect(notesResult.current).toEqual(mockNotes)
})Primero, la prueba establece que, cuando se llama a noteService.getAll, se devuelven al store las notas del array mockNotes.
Lo que se está probando es la llamada a la función initialize:
await act(async () => {
await result.current.initialize()
})Dado que se trata de una función asincrónica, se debe esperar la finalización de la llamada con la palabra clave await.
Finalmente, la prueba verifica que el estado del store contiene la misma lista de notas que devolvió la función simulada noteService.getAll:
const { result: notesResult } = renderHook(() => useNotes())
expect(notesResult.current).toEqual(mockNotes)Las otras pruebas siguen el mismo patrón: primero, se define lo que devuelve la función llamada noteService del store y luego se ejecuta la prueba real.
La segunda parte de las pruebas verifica que el filtrado funciona correctamente:
describe('useNotes filtering', () => {
const notes = [
{ id: 1, content: 'A', important: true },
{ id: 2, content: 'B', important: false },
]
beforeEach(() => {
useNoteStore.setState({ notes })
})
it('returns all notes with no filter', () => {
const { result } = renderHook(() => useNotes())
expect(result.current).toHaveLength(2)
})
it('filters important notes', () => {
useNoteStore.setState({ notes, filter: 'important' })
const { result } = renderHook(() => useNotes())
expect(result.current).toEqual([notes[0]])
})
it('filters nonimportant notes', () => {
useNoteStore.setState({ notes, filter: 'nonimportant' })
const { result } = renderHook(() => useNotes())
expect(result.current).toEqual([notes[1]])
})
})El estado se inicializa con dos notas, una de las cuales es importante y la otra no. Los tres casos de prueba verifican que useNotes devuelva las notas correctas para todos los valores de filtro.
El código final de la aplicación está en GitHub en la rama part6-6.

