← Volver al listado de tecnologías

Configuración de Zustand con Testing

Por: Artiko
react-nativezustandtestingjesttypescript

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

📦 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:

Próximos Pasos

En el siguiente capítulo implementaremos:

¡El store está listo y completamente testeado! 🎉

🔗 Navegación