← Volver al listado de tecnologías
Configuración de Zustand con Testing
Capítulo 4: Configuración de Zustand con Testing
En este capítulo configuraremos Zustand para gestión de estado y estableceremos las bases para testing unitario desde el inicio del proyecto.
🎯 Objetivos del Capítulo
- Instalar y configurar Zustand con TypeScript
- Configurar Jest y React Native Testing Library
- Crear nuestro primer store con testing unitario
- Establecer patrones de testing para stores
- Configurar DevTools para debugging
📦 Instalación de Dependencias
Zustand y Testing
# Zustand para gestión de estado
npm install zustand
# Testing dependencies
npm install --save-dev jest @testing-library/react-native @testing-library/jest-native
# Mock Service Worker para testing de APIs
npm install --save-dev msw
# Utilidades adicionales para testing
npm install --save-dev @types/jest jest-environment-node
Configuración de Jest
Actualiza tu package.json para incluir la configuración de Jest:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"jest": {
"preset": "react-native",
"setupFilesAfterEnv": [
"@testing-library/jest-native/extend-expect",
"<rootDir>/src/test/setup.ts"
],
"testMatch": [
"**/__tests__/**/*.(ts|tsx|js)",
"**/*.(test|spec).(ts|tsx|js)"
],
"collectCoverageFrom": [
"src/**/*.{ts,tsx}",
"!src/**/*.d.ts",
"!src/test/**/*"
],
"coverageReporters": ["text", "lcov", "html"],
"transformIgnorePatterns": [
"node_modules/(?!(react-native|@react-native|react-native-reanimated|zustand)/)"
]
}
}
🧪 Configuración de Testing
Setup de Testing
Crea el archivo de configuración de testing:
// src/test/setup.ts
import 'react-native-gesture-handler/jestSetup';
// Mock de AsyncStorage
jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock')
);
// Mock de react-native-reanimated
jest.mock('react-native-reanimated', () => {
const Reanimated = require('react-native-reanimated/mock');
Reanimated.default.call = () => {};
return Reanimated;
});
// Silenciar warnings de testing
const originalWarn = console.warn;
beforeAll(() => {
console.warn = (...args) => {
if (
typeof args[0] === 'string' &&
args[0].includes('Warning: ReactDOM.render is no longer supported')
) {
return;
}
originalWarn.call(console, ...args);
};
});
afterAll(() => {
console.warn = originalWarn;
});
Utilidades de Testing
// src/test/utils.tsx
import React, { ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react-native';
// Custom render que incluye providers necesarios
const AllTheProviders = ({ children }: { children: React.ReactNode }) => {
return <>{children}</>;
};
const customRender = (
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
) => render(ui, { wrapper: AllTheProviders, ...options });
// Re-export everything
export * from '@testing-library/react-native';
// Override render method
export { customRender as render };
🏪 Primer Store con Zustand
Definición de Tipos
// src/types/store.ts
export interface Todo {
id: string;
title: string;
description: string;
completed: boolean;
createdAt: Date;
updatedAt: Date;
userId: string;
}
export interface TodoState {
todos: Todo[];
loading: boolean;
error: string | null;
}
export interface TodoActions {
addTodo: (todo: Omit<Todo, 'id' | 'createdAt' | 'updatedAt'>) => void;
updateTodo: (id: string, updates: Partial<Todo>) => void;
deleteTodo: (id: string) => void;
toggleTodo: (id: string) => void;
clearTodos: () => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
}
export type TodoStore = TodoState & TodoActions;
Store de Todos
// src/stores/todoStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Todo, TodoStore } from '@/types/store';
const initialState = {
todos: [],
loading: false,
error: null,
};
export const useTodoStore = create<TodoStore>()(
devtools(
persist(
(set, get) => ({
...initialState,
addTodo: (todoData) => {
const newTodo: Todo = {
...todoData,
id: Date.now().toString(),
createdAt: new Date(),
updatedAt: new Date(),
};
set(
(state) => ({
todos: [...state.todos, newTodo],
error: null,
}),
false,
'addTodo'
);
},
updateTodo: (id, updates) => {
set(
(state) => ({
todos: state.todos.map((todo) =>
todo.id === id
? { ...todo, ...updates, updatedAt: new Date() }
: todo
),
error: null,
}),
false,
'updateTodo'
);
},
deleteTodo: (id) => {
set(
(state) => ({
todos: state.todos.filter((todo) => todo.id !== id),
error: null,
}),
false,
'deleteTodo'
);
},
toggleTodo: (id) => {
const { updateTodo } = get();
const todo = get().todos.find((t) => t.id === id);
if (todo) {
updateTodo(id, { completed: !todo.completed });
}
},
clearTodos: () => {
set(() => ({ todos: [], error: null }), false, 'clearTodos');
},
setLoading: (loading) => {
set(() => ({ loading }), false, 'setLoading');
},
setError: (error) => {
set(() => ({ error, loading: false }), false, 'setError');
},
}),
{
name: 'todo-storage',
storage: {
getItem: async (name) => {
const value = await AsyncStorage.getItem(name);
return value ? JSON.parse(value) : null;
},
setItem: async (name, value) => {
await AsyncStorage.setItem(name, JSON.stringify(value));
},
removeItem: async (name) => {
await AsyncStorage.removeItem(name);
},
},
// Solo persistir el estado, no las acciones
partialize: (state) => ({
todos: state.todos,
}),
}
),
{
name: 'todo-store',
}
)
);
// Selectores para optimizar re-renders
export const selectTodos = (state: TodoStore) => state.todos;
export const selectCompletedTodos = (state: TodoStore) =>
state.todos.filter((todo) => todo.completed);
export const selectPendingTodos = (state: TodoStore) =>
state.todos.filter((todo) => !todo.completed);
export const selectTodoById = (id: string) => (state: TodoStore) =>
state.todos.find((todo) => todo.id === id);
export const selectLoading = (state: TodoStore) => state.loading;
export const selectError = (state: TodoStore) => state.error;
🧪 Testing del Store
Tests Unitarios del Store
// src/stores/__tests__/todoStore.test.ts
import { renderHook, act } from '@testing-library/react-native';
import { useTodoStore } from '../todoStore';
import { Todo } from '@/types/store';
// Mock AsyncStorage
jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock')
);
describe('TodoStore', () => {
beforeEach(() => {
// Reset store antes de cada test
const { result } = renderHook(() => useTodoStore());
act(() => {
result.current.clearTodos();
});
});
describe('addTodo', () => {
it('should add a new todo', () => {
const { result } = renderHook(() => useTodoStore());
const todoData = {
title: 'Test Todo',
description: 'Test Description',
completed: false,
userId: 'user1',
};
act(() => {
result.current.addTodo(todoData);
});
expect(result.current.todos).toHaveLength(1);
expect(result.current.todos[0]).toMatchObject(todoData);
expect(result.current.todos[0].id).toBeDefined();
expect(result.current.todos[0].createdAt).toBeInstanceOf(Date);
expect(result.current.todos[0].updatedAt).toBeInstanceOf(Date);
});
it('should clear error when adding todo', () => {
const { result } = renderHook(() => useTodoStore());
act(() => {
result.current.setError('Some error');
});
expect(result.current.error).toBe('Some error');
act(() => {
result.current.addTodo({
title: 'Test',
description: 'Test',
completed: false,
userId: 'user1',
});
});
expect(result.current.error).toBeNull();
});
});
describe('updateTodo', () => {
it('should update existing todo', () => {
const { result } = renderHook(() => useTodoStore());
// Agregar todo inicial
act(() => {
result.current.addTodo({
title: 'Original Title',
description: 'Original Description',
completed: false,
userId: 'user1',
});
});
const todoId = result.current.todos[0].id;
const originalUpdatedAt = result.current.todos[0].updatedAt;
// Actualizar todo
act(() => {
result.current.updateTodo(todoId, {
title: 'Updated Title',
completed: true,
});
});
const updatedTodo = result.current.todos[0];
expect(updatedTodo.title).toBe('Updated Title');
expect(updatedTodo.completed).toBe(true);
expect(updatedTodo.description).toBe('Original Description'); // No cambió
expect(updatedTodo.updatedAt.getTime()).toBeGreaterThan(
originalUpdatedAt.getTime()
);
});
it('should not update non-existent todo', () => {
const { result } = renderHook(() => useTodoStore());
act(() => {
result.current.updateTodo('non-existent', { title: 'Updated' });
});
expect(result.current.todos).toHaveLength(0);
});
});
describe('deleteTodo', () => {
it('should delete existing todo', () => {
const { result } = renderHook(() => useTodoStore());
// Agregar dos todos
act(() => {
result.current.addTodo({
title: 'Todo 1',
description: 'Description 1',
completed: false,
userId: 'user1',
});
result.current.addTodo({
title: 'Todo 2',
description: 'Description 2',
completed: false,
userId: 'user1',
});
});
expect(result.current.todos).toHaveLength(2);
const todoIdToDelete = result.current.todos[0].id;
act(() => {
result.current.deleteTodo(todoIdToDelete);
});
expect(result.current.todos).toHaveLength(1);
expect(result.current.todos[0].title).toBe('Todo 2');
});
});
describe('toggleTodo', () => {
it('should toggle todo completion status', () => {
const { result } = renderHook(() => useTodoStore());
act(() => {
result.current.addTodo({
title: 'Test Todo',
description: 'Test Description',
completed: false,
userId: 'user1',
});
});
const todoId = result.current.todos[0].id;
expect(result.current.todos[0].completed).toBe(false);
act(() => {
result.current.toggleTodo(todoId);
});
expect(result.current.todos[0].completed).toBe(true);
act(() => {
result.current.toggleTodo(todoId);
});
expect(result.current.todos[0].completed).toBe(false);
});
});
describe('clearTodos', () => {
it('should clear all todos', () => {
const { result } = renderHook(() => useTodoStore());
// Agregar algunos todos
act(() => {
result.current.addTodo({
title: 'Todo 1',
description: 'Description 1',
completed: false,
userId: 'user1',
});
result.current.addTodo({
title: 'Todo 2',
description: 'Description 2',
completed: true,
userId: 'user1',
});
});
expect(result.current.todos).toHaveLength(2);
act(() => {
result.current.clearTodos();
});
expect(result.current.todos).toHaveLength(0);
expect(result.current.error).toBeNull();
});
});
describe('loading and error states', () => {
it('should handle loading state', () => {
const { result } = renderHook(() => useTodoStore());
expect(result.current.loading).toBe(false);
act(() => {
result.current.setLoading(true);
});
expect(result.current.loading).toBe(true);
act(() => {
result.current.setLoading(false);
});
expect(result.current.loading).toBe(false);
});
it('should handle error state', () => {
const { result } = renderHook(() => useTodoStore());
expect(result.current.error).toBeNull();
act(() => {
result.current.setError('Something went wrong');
});
expect(result.current.error).toBe('Something went wrong');
expect(result.current.loading).toBe(false);
act(() => {
result.current.setError(null);
});
expect(result.current.error).toBeNull();
});
});
});
Tests de Selectores
// src/stores/__tests__/todoSelectors.test.ts
import { renderHook, act } from '@testing-library/react-native';
import {
useTodoStore,
selectTodos,
selectCompletedTodos,
selectPendingTodos,
selectTodoById,
} from '../todoStore';
describe('Todo Selectors', () => {
beforeEach(() => {
const { result } = renderHook(() => useTodoStore());
act(() => {
result.current.clearTodos();
});
});
it('should select all todos', () => {
const { result } = renderHook(() => useTodoStore());
act(() => {
result.current.addTodo({
title: 'Todo 1',
description: 'Description 1',
completed: false,
userId: 'user1',
});
result.current.addTodo({
title: 'Todo 2',
description: 'Description 2',
completed: true,
userId: 'user1',
});
});
const todos = selectTodos(result.current);
expect(todos).toHaveLength(2);
});
it('should select completed todos', () => {
const { result } = renderHook(() => useTodoStore());
act(() => {
result.current.addTodo({
title: 'Todo 1',
description: 'Description 1',
completed: false,
userId: 'user1',
});
result.current.addTodo({
title: 'Todo 2',
description: 'Description 2',
completed: true,
userId: 'user1',
});
result.current.addTodo({
title: 'Todo 3',
description: 'Description 3',
completed: true,
userId: 'user1',
});
});
const completedTodos = selectCompletedTodos(result.current);
expect(completedTodos).toHaveLength(2);
expect(completedTodos.every((todo) => todo.completed)).toBe(true);
});
it('should select pending todos', () => {
const { result } = renderHook(() => useTodoStore());
act(() => {
result.current.addTodo({
title: 'Todo 1',
description: 'Description 1',
completed: false,
userId: 'user1',
});
result.current.addTodo({
title: 'Todo 2',
description: 'Description 2',
completed: true,
userId: 'user1',
});
result.current.addTodo({
title: 'Todo 3',
description: 'Description 3',
completed: false,
userId: 'user1',
});
});
const pendingTodos = selectPendingTodos(result.current);
expect(pendingTodos).toHaveLength(2);
expect(pendingTodos.every((todo) => !todo.completed)).toBe(true);
});
it('should select todo by id', () => {
const { result } = renderHook(() => useTodoStore());
act(() => {
result.current.addTodo({
title: 'Target Todo',
description: 'Target Description',
completed: false,
userId: 'user1',
});
result.current.addTodo({
title: 'Other Todo',
description: 'Other Description',
completed: true,
userId: 'user1',
});
});
const targetId = result.current.todos[0].id;
const selectedTodo = selectTodoById(targetId)(result.current);
expect(selectedTodo).toBeDefined();
expect(selectedTodo?.title).toBe('Target Todo');
expect(selectedTodo?.id).toBe(targetId);
});
it('should return undefined for non-existent todo id', () => {
const { result } = renderHook(() => useTodoStore());
const selectedTodo = selectTodoById('non-existent')(result.current);
expect(selectedTodo).toBeUndefined();
});
});
🔧 Hook Personalizado para Testing
// src/test/hooks/useTodoStoreTest.ts
import { renderHook, act } from '@testing-library/react-native';
import { useTodoStore } from '@/stores/todoStore';
import { Todo } from '@/types/store';
export const useTodoStoreTest = () => {
const { result } = renderHook(() => useTodoStore());
const addTestTodo = (overrides: Partial<Todo> = {}) => {
const todoData = {
title: 'Test Todo',
description: 'Test Description',
completed: false,
userId: 'test-user',
...overrides,
};
act(() => {
result.current.addTodo(todoData);
});
return result.current.todos[result.current.todos.length - 1];
};
const clearStore = () => {
act(() => {
result.current.clearTodos();
});
};
return {
store: result.current,
addTestTodo,
clearStore,
};
};
🛠️ DevTools y Debugging
Configuración de DevTools
// src/stores/devtools.ts
import { subscribeWithSelector } from 'zustand/middleware';
// Helper para logging de acciones en desarrollo
export const withLogging = <T>(config: any) =>
subscribeWithSelector(
devtools(config, {
name: 'todo-store',
trace: __DEV__,
})
);
// Middleware para logging manual
export const logAction = (actionName: string, payload?: any) => {
if (__DEV__) {
console.log(`🏪 Store Action: ${actionName}`, payload);
}
};
Debugging en Tests
// src/test/debug.ts
import { useTodoStore } from '@/stores/todoStore';
export const debugStore = () => {
const store = useTodoStore.getState();
console.log('📊 Current Store State:', {
todosCount: store.todos.length,
loading: store.loading,
error: store.error,
todos: store.todos.map((todo) => ({
id: todo.id,
title: todo.title,
completed: todo.completed,
})),
});
};
🎯 Scripts de Testing
Agrega estos scripts útiles a tu package.json:
{
"scripts": {
"test:store": "jest src/stores",
"test:unit": "jest --testPathPattern=__tests__",
"test:watch:store": "jest src/stores --watch",
"test:coverage:store": "jest src/stores --coverage"
}
}
✅ Verificación
Ejecuta los tests para verificar que todo funciona:
# Ejecutar todos los tests del store
npm run test:store
# Ejecutar tests en modo watch
npm run test:watch:store
# Ver coverage del store
npm run test:coverage:store
📝 Resumen
En este capítulo hemos:
- ✅ Configurado Zustand con TypeScript
- ✅ Establecido testing unitario desde el inicio
- ✅ Creado un store completo con persistencia
- ✅ Implementado selectores optimizados
- ✅ Desarrollado tests comprehensivos
- ✅ Configurado DevTools para debugging
Próximos Pasos
En el siguiente capítulo implementaremos:
- Store más complejo con múltiples entidades
- Testing de persistencia con AsyncStorage
- Patterns avanzados de Zustand
- Integración con React components
¡El store está listo y completamente testeado! 🎉