Prerequisites
- Understanding of Redux fundamentals and TypeScript π
- Knowledge of Jest testing framework and React Testing Library β‘
- Familiarity with Redux Toolkit and async patterns π»
What you'll learn
- Test Redux stores, actions, and reducers with comprehensive coverage π―
- Master testing async thunks, middleware, and complex state transitions ποΈ
- Handle Redux integration testing with React components π
- Create maintainable test suites for Redux applications β¨
π― Introduction
Welcome to the Redux testing command center! πͺ If testing regular state management were like checking a simple light switch, then testing Redux would be like testing an entire electrical control system - complete with power distribution (store), control panels (actions), circuit breakers (reducers), and automated systems (middleware) that all need to work together flawlessly to power your entire application!
Redux applications involve complex state management with multiple moving parts: action creators, reducers, middleware, async operations, and store subscriptions. Testing these components requires understanding both the individual pieces and how they integrate together to form a cohesive state management system.
By the end of this tutorial, youβll be a master of Redux testing, capable of thoroughly testing everything from simple synchronous actions to complex async thunks with error handling, retry logic, and optimistic updates. Youβll learn to test Redux in isolation and integration, ensuring your state management is bulletproof. Letβs build some rock-solid Redux tests! π
π Understanding Redux Testing Fundamentals
π€ Why Redux Testing Is Essential
Redux testing ensures predictable state management, proper action handling, and reliable data flow throughout your application.
// π Setting up comprehensive Redux testing environment
import { configureStore, createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { Provider } from 'react-redux';
import React from 'react';
// Types for our Redux examples
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user' | 'moderator';
isActive: boolean;
createdAt: string;
}
interface Todo {
id: string;
text: string;
completed: boolean;
priority: 'low' | 'medium' | 'high';
dueDate?: string;
assignedTo?: string;
}
interface ApiError {
message: string;
code: number;
field?: string;
}
interface PaginationMeta {
page: number;
limit: number;
total: number;
totalPages: number;
}
// Auth slice for user authentication
interface AuthState {
user: User | null;
token: string | null;
loading: boolean;
error: string | null;
loginAttempts: number;
lastLoginAttempt: string | null;
}
const initialAuthState: AuthState = {
user: null,
token: null,
loading: false,
error: null,
loginAttempts: 0,
lastLoginAttempt: null
};
// Async thunk for login
export const loginUser = createAsyncThunk<
{ user: User; token: string },
{ email: string; password: string },
{ rejectValue: ApiError }
>(
'auth/loginUser',
async (credentials, { rejectWithValue }) => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(credentials)
});
if (!response.ok) {
const errorData = await response.json();
return rejectWithValue(errorData);
}
const data = await response.json();
// Store token in localStorage
localStorage.setItem('token', data.token);
return data;
} catch (error) {
return rejectWithValue({
message: 'Network error occurred',
code: 0
});
}
}
);
// Async thunk for logout
export const logoutUser = createAsyncThunk<
void,
void,
{ rejectValue: ApiError }
>(
'auth/logoutUser',
async (_, { rejectWithValue }) => {
try {
await fetch('/api/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
localStorage.removeItem('token');
} catch (error) {
return rejectWithValue({
message: 'Logout failed',
code: 500
});
}
}
);
const authSlice = createSlice({
name: 'auth',
initialState: initialAuthState,
reducers: {
clearError: (state) => {
state.error = null;
},
resetLoginAttempts: (state) => {
state.loginAttempts = 0;
state.lastLoginAttempt = null;
},
updateUserProfile: (state, action: PayloadAction<Partial<User>>) => {
if (state.user) {
state.user = { ...state.user, ...action.payload };
}
},
setToken: (state, action: PayloadAction<string>) => {
state.token = action.payload;
}
},
extraReducers: (builder) => {
builder
.addCase(loginUser.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(loginUser.fulfilled, (state, action) => {
state.loading = false;
state.user = action.payload.user;
state.token = action.payload.token;
state.error = null;
state.loginAttempts = 0;
state.lastLoginAttempt = null;
})
.addCase(loginUser.rejected, (state, action) => {
state.loading = false;
state.error = action.payload?.message || 'Login failed';
state.loginAttempts += 1;
state.lastLoginAttempt = new Date().toISOString();
})
.addCase(logoutUser.pending, (state) => {
state.loading = true;
})
.addCase(logoutUser.fulfilled, (state) => {
state.loading = false;
state.user = null;
state.token = null;
state.error = null;
state.loginAttempts = 0;
state.lastLoginAttempt = null;
})
.addCase(logoutUser.rejected, (state, action) => {
state.loading = false;
state.error = action.payload?.message || 'Logout failed';
});
}
});
export const { clearError, resetLoginAttempts, updateUserProfile, setToken } = authSlice.actions;
export const authReducer = authSlice.reducer;
// Todos slice for task management
interface TodosState {
items: Todo[];
loading: boolean;
error: string | null;
filter: 'all' | 'active' | 'completed';
sortBy: 'createdAt' | 'priority' | 'dueDate';
searchQuery: string;
pagination: PaginationMeta;
}
const initialTodosState: TodosState = {
items: [],
loading: false,
error: null,
filter: 'all',
sortBy: 'createdAt',
searchQuery: '',
pagination: {
page: 1,
limit: 10,
total: 0,
totalPages: 0
}
};
// Async thunk for fetching todos
export const fetchTodos = createAsyncThunk<
{ todos: Todo[]; pagination: PaginationMeta },
{ page?: number; limit?: number; filter?: string },
{ rejectValue: ApiError }
>(
'todos/fetchTodos',
async (params = {}, { rejectWithValue }) => {
try {
const { page = 1, limit = 10, filter = 'all' } = params;
const queryParams = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
filter
});
const response = await fetch(`/api/todos?${queryParams}`);
if (!response.ok) {
const errorData = await response.json();
return rejectWithValue(errorData);
}
return await response.json();
} catch (error) {
return rejectWithValue({
message: 'Failed to fetch todos',
code: 500
});
}
}
);
// Async thunk for creating todo
export const createTodo = createAsyncThunk<
Todo,
Omit<Todo, 'id'>,
{ rejectValue: ApiError }
>(
'todos/createTodo',
async (todoData, { rejectWithValue }) => {
try {
const response = await fetch('/api/todos', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify(todoData)
});
if (!response.ok) {
const errorData = await response.json();
return rejectWithValue(errorData);
}
return await response.json();
} catch (error) {
return rejectWithValue({
message: 'Failed to create todo',
code: 500
});
}
}
);
// Async thunk for updating todo
export const updateTodo = createAsyncThunk<
Todo,
{ id: string; updates: Partial<Todo> },
{ rejectValue: ApiError }
>(
'todos/updateTodo',
async ({ id, updates }, { rejectWithValue }) => {
try {
const response = await fetch(`/api/todos/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify(updates)
});
if (!response.ok) {
const errorData = await response.json();
return rejectWithValue(errorData);
}
return await response.json();
} catch (error) {
return rejectWithValue({
message: 'Failed to update todo',
code: 500
});
}
}
);
// Async thunk for deleting todo
export const deleteTodo = createAsyncThunk<
string,
string,
{ rejectValue: ApiError }
>(
'todos/deleteTodo',
async (id, { rejectWithValue }) => {
try {
const response = await fetch(`/api/todos/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (!response.ok) {
const errorData = await response.json();
return rejectWithValue(errorData);
}
return id;
} catch (error) {
return rejectWithValue({
message: 'Failed to delete todo',
code: 500
});
}
}
);
const todosSlice = createSlice({
name: 'todos',
initialState: initialTodosState,
reducers: {
setFilter: (state, action: PayloadAction<TodosState['filter']>) => {
state.filter = action.payload;
state.pagination.page = 1; // Reset to first page when filtering
},
setSortBy: (state, action: PayloadAction<TodosState['sortBy']>) => {
state.sortBy = action.payload;
},
setSearchQuery: (state, action: PayloadAction<string>) => {
state.searchQuery = action.payload;
state.pagination.page = 1; // Reset to first page when searching
},
clearError: (state) => {
state.error = null;
},
toggleTodoCompleted: (state, action: PayloadAction<string>) => {
const todo = state.items.find(item => item.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
},
addTodoOptimistic: (state, action: PayloadAction<Todo>) => {
state.items.unshift(action.payload);
},
removeTodoOptimistic: (state, action: PayloadAction<string>) => {
state.items = state.items.filter(item => item.id !== action.payload);
}
},
extraReducers: (builder) => {
builder
// Fetch todos
.addCase(fetchTodos.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchTodos.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload.todos;
state.pagination = action.payload.pagination;
state.error = null;
})
.addCase(fetchTodos.rejected, (state, action) => {
state.loading = false;
state.error = action.payload?.message || 'Failed to fetch todos';
})
// Create todo
.addCase(createTodo.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(createTodo.fulfilled, (state, action) => {
state.loading = false;
// Remove optimistic todo if it exists, add real one
state.items = state.items.filter(item => item.id !== 'temp-id');
state.items.unshift(action.payload);
state.error = null;
})
.addCase(createTodo.rejected, (state, action) => {
state.loading = false;
state.error = action.payload?.message || 'Failed to create todo';
// Remove optimistic todo
state.items = state.items.filter(item => item.id !== 'temp-id');
})
// Update todo
.addCase(updateTodo.fulfilled, (state, action) => {
const index = state.items.findIndex(item => item.id === action.payload.id);
if (index !== -1) {
state.items[index] = action.payload;
}
})
.addCase(updateTodo.rejected, (state, action) => {
state.error = action.payload?.message || 'Failed to update todo';
})
// Delete todo
.addCase(deleteTodo.fulfilled, (state, action) => {
state.items = state.items.filter(item => item.id !== action.payload);
})
.addCase(deleteTodo.rejected, (state, action) => {
state.error = action.payload?.message || 'Failed to delete todo';
});
}
});
export const {
setFilter,
setSortBy,
setSearchQuery,
clearError: clearTodosError,
toggleTodoCompleted,
addTodoOptimistic,
removeTodoOptimistic
} = todosSlice.actions;
export const todosReducer = todosSlice.reducer;
// Root state type
export interface RootState {
auth: AuthState;
todos: TodosState;
}
// Store configuration
export const createTestStore = (preloadedState?: Partial<RootState>) => {
return configureStore({
reducer: {
auth: authReducer,
todos: todosReducer
},
preloadedState,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['persist/PERSIST']
}
})
});
};
// Selectors
export const selectUser = (state: RootState) => state.auth.user;
export const selectIsAuthenticated = (state: RootState) => !!state.auth.token;
export const selectAuthLoading = (state: RootState) => state.auth.loading;
export const selectAuthError = (state: RootState) => state.auth.error;
export const selectTodos = (state: RootState) => state.todos.items;
export const selectTodosLoading = (state: RootState) => state.todos.loading;
export const selectTodosError = (state: RootState) => state.todos.error;
export const selectTodosFilter = (state: RootState) => state.todos.filter;
export const selectFilteredTodos = (state: RootState) => {
const { items, filter, searchQuery } = state.todos;
let filtered = items;
// Apply filter
switch (filter) {
case 'active':
filtered = filtered.filter(todo => !todo.completed);
break;
case 'completed':
filtered = filtered.filter(todo => todo.completed);
break;
default:
// 'all' - no filtering
break;
}
// Apply search
if (searchQuery.trim()) {
filtered = filtered.filter(todo =>
todo.text.toLowerCase().includes(searchQuery.toLowerCase())
);
}
return filtered;
};
export const selectTodoStats = (state: RootState) => {
const todos = state.todos.items;
const total = todos.length;
const completed = todos.filter(t => t.completed).length;
const active = total - completed;
const highPriority = todos.filter(t => t.priority === 'high').length;
return { total, completed, active, highPriority };
};
// Custom middleware for logging
export const loggerMiddleware: any = (store: any) => (next: any) => (action: any) => {
console.log('Dispatching:', action.type);
const result = next(action);
console.log('Next state:', store.getState());
return result;
};
// Test utilities
export const createMockStore = (initialState: Partial<RootState> = {}) => {
return createTestStore({
auth: { ...initialAuthState, ...initialState.auth },
todos: { ...initialTodosState, ...initialState.todos }
});
};
export const renderWithRedux = (
component: React.ReactElement,
{ store = createMockStore(), ...renderOptions } = {}
) => {
const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<Provider store={store}>{children}</Provider>
);
return {
...render(component, { wrapper: Wrapper, ...renderOptions }),
store
};
};
β Testing Actions and Action Creators
π¬ Testing Synchronous Actions
Basic action testing verifies that actions are created correctly with proper types and payloads.
// π Comprehensive action creator testing
describe('Auth Actions', () => {
// β
Testing synchronous action creators
describe('Synchronous Actions', () => {
it('should create clearError action', () => {
const action = clearError();
expect(action).toEqual({
type: 'auth/clearError',
payload: undefined
});
});
it('should create resetLoginAttempts action', () => {
const action = resetLoginAttempts();
expect(action).toEqual({
type: 'auth/resetLoginAttempts',
payload: undefined
});
});
it('should create updateUserProfile action', () => {
const userUpdate = { name: 'Updated Name', email: '[email protected]' };
const action = updateUserProfile(userUpdate);
expect(action).toEqual({
type: 'auth/updateUserProfile',
payload: userUpdate
});
});
it('should create setToken action', () => {
const token = 'abc123token';
const action = setToken(token);
expect(action).toEqual({
type: 'auth/setToken',
payload: token
});
});
});
// β
Testing action creators with complex payloads
describe('Complex Payload Actions', () => {
it('should handle partial user profile updates', () => {
const partialUpdate = { name: 'New Name' };
const action = updateUserProfile(partialUpdate);
expect(action.payload).toEqual(partialUpdate);
expect(action.type).toBe('auth/updateUserProfile');
});
it('should handle empty user profile updates', () => {
const emptyUpdate = {};
const action = updateUserProfile(emptyUpdate);
expect(action.payload).toEqual(emptyUpdate);
expect(action.type).toBe('auth/updateUserProfile');
});
it('should handle user profile updates with all fields', () => {
const fullUpdate: Partial<User> = {
id: 'user-123',
name: 'Full Name',
email: '[email protected]',
role: 'admin',
isActive: false,
createdAt: '2023-01-01T00:00:00Z'
};
const action = updateUserProfile(fullUpdate);
expect(action.payload).toEqual(fullUpdate);
});
});
});
describe('Todos Actions', () => {
// β
Testing todos synchronous actions
describe('Filter and Search Actions', () => {
it('should create setFilter action', () => {
const filter = 'completed';
const action = setFilter(filter);
expect(action).toEqual({
type: 'todos/setFilter',
payload: filter
});
});
it('should create setSortBy action', () => {
const sortBy = 'priority';
const action = setSortBy(sortBy);
expect(action).toEqual({
type: 'todos/setSortBy',
payload: sortBy
});
});
it('should create setSearchQuery action', () => {
const searchQuery = 'important task';
const action = setSearchQuery(searchQuery);
expect(action).toEqual({
type: 'todos/setSearchQuery',
payload: searchQuery
});
});
it('should handle all filter types', () => {
const filters: Array<TodosState['filter']> = ['all', 'active', 'completed'];
filters.forEach(filter => {
const action = setFilter(filter);
expect(action.payload).toBe(filter);
expect(action.type).toBe('todos/setFilter');
});
});
it('should handle all sort types', () => {
const sortOptions: Array<TodosState['sortBy']> = ['createdAt', 'priority', 'dueDate'];
sortOptions.forEach(sortBy => {
const action = setSortBy(sortBy);
expect(action.payload).toBe(sortBy);
expect(action.type).toBe('todos/setSortBy');
});
});
});
// β
Testing optimistic update actions
describe('Optimistic Update Actions', () => {
it('should create addTodoOptimistic action', () => {
const todo: Todo = {
id: 'temp-123',
text: 'Optimistic todo',
completed: false,
priority: 'medium'
};
const action = addTodoOptimistic(todo);
expect(action).toEqual({
type: 'todos/addTodoOptimistic',
payload: todo
});
});
it('should create removeTodoOptimistic action', () => {
const todoId = 'temp-123';
const action = removeTodoOptimistic(todoId);
expect(action).toEqual({
type: 'todos/removeTodoOptimistic',
payload: todoId
});
});
it('should create toggleTodoCompleted action', () => {
const todoId = 'todo-456';
const action = toggleTodoCompleted(todoId);
expect(action).toEqual({
type: 'todos/toggleTodoCompleted',
payload: todoId
});
});
});
});
// β
Testing action types and payloads validation
describe('Action Type Safety', () => {
it('should have correct action types for auth actions', () => {
expect(clearError().type).toBe('auth/clearError');
expect(resetLoginAttempts().type).toBe('auth/resetLoginAttempts');
expect(updateUserProfile({}).type).toBe('auth/updateUserProfile');
expect(setToken('').type).toBe('auth/setToken');
});
it('should have correct action types for todos actions', () => {
expect(setFilter('all').type).toBe('todos/setFilter');
expect(setSortBy('createdAt').type).toBe('todos/setSortBy');
expect(setSearchQuery('').type).toBe('todos/setSearchQuery');
expect(clearTodosError().type).toBe('todos/clearError');
expect(toggleTodoCompleted('').type).toBe('todos/toggleTodoCompleted');
expect(addTodoOptimistic({} as Todo).type).toBe('todos/addTodoOptimistic');
expect(removeTodoOptimistic('').type).toBe('todos/removeTodoOptimistic');
});
it('should preserve payload types', () => {
const userUpdate = { name: 'Test User' };
const action = updateUserProfile(userUpdate);
// TypeScript should ensure these types match
expect(action.payload.name).toBe('Test User');
expect(typeof action.payload.name).toBe('string');
});
});
π Testing Async Thunks
β‘ Testing Async Action Creators
Async thunks require mocking external dependencies and testing various success/failure scenarios.
// π Comprehensive async thunk testing
describe('loginUser Async Thunk', () => {
beforeEach(() => {
global.fetch = jest.fn();
localStorage.clear();
});
afterEach(() => {
jest.resetAllMocks();
});
// β
Testing successful login
it('should handle successful login', async () => {
const mockResponse = {
user: {
id: 'user-123',
name: 'John Doe',
email: '[email protected]',
role: 'user' as const,
isActive: true,
createdAt: '2023-01-01T00:00:00Z'
},
token: 'abc123token'
};
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse)
} as Response);
const credentials = { email: '[email protected]', password: 'password123' };
const dispatch = jest.fn();
const getState = jest.fn();
const extra = {};
const thunk = loginUser(credentials);
const result = await thunk(dispatch, getState, extra);
expect(result.type).toBe('auth/loginUser/fulfilled');
expect(result.payload).toEqual(mockResponse);
expect(mockFetch).toHaveBeenCalledWith('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(credentials)
});
expect(localStorage.getItem('token')).toBe('abc123token');
});
// β
Testing login failure with API error
it('should handle login failure with API error', async () => {
const errorResponse = {
message: 'Invalid credentials',
code: 401,
field: 'email'
};
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch.mockResolvedValue({
ok: false,
json: () => Promise.resolve(errorResponse)
} as Response);
const credentials = { email: '[email protected]', password: 'wrongpassword' };
const dispatch = jest.fn();
const getState = jest.fn();
const extra = {};
const thunk = loginUser(credentials);
const result = await thunk(dispatch, getState, extra);
expect(result.type).toBe('auth/loginUser/rejected');
expect(result.payload).toEqual(errorResponse);
expect(localStorage.getItem('token')).toBeNull();
});
// β
Testing network error
it('should handle network error', async () => {
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch.mockRejectedValue(new Error('Network error'));
const credentials = { email: '[email protected]', password: 'password123' };
const dispatch = jest.fn();
const getState = jest.fn();
const extra = {};
const thunk = loginUser(credentials);
const result = await thunk(dispatch, getState, extra);
expect(result.type).toBe('auth/loginUser/rejected');
expect(result.payload).toEqual({
message: 'Network error occurred',
code: 0
});
});
});
describe('logoutUser Async Thunk', () => {
beforeEach(() => {
global.fetch = jest.fn();
localStorage.setItem('token', 'existing-token');
});
afterEach(() => {
jest.resetAllMocks();
localStorage.clear();
});
it('should handle successful logout', async () => {
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch.mockResolvedValue({
ok: true
} as Response);
const dispatch = jest.fn();
const getState = jest.fn();
const extra = {};
const thunk = logoutUser();
const result = await thunk(dispatch, getState, extra);
expect(result.type).toBe('auth/logoutUser/fulfilled');
expect(result.payload).toBeUndefined();
expect(mockFetch).toHaveBeenCalledWith('/api/auth/logout', {
method: 'POST',
headers: {
'Authorization': 'Bearer existing-token'
}
});
expect(localStorage.getItem('token')).toBeNull();
});
it('should handle logout failure', async () => {
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch.mockRejectedValue(new Error('Server error'));
const dispatch = jest.fn();
const getState = jest.fn();
const extra = {};
const thunk = logoutUser();
const result = await thunk(dispatch, getState, extra);
expect(result.type).toBe('auth/logoutUser/rejected');
expect(result.payload).toEqual({
message: 'Logout failed',
code: 500
});
expect(localStorage.getItem('token')).toBeNull(); // Token should still be removed
});
});
describe('Todos Async Thunks', () => {
beforeEach(() => {
global.fetch = jest.fn();
localStorage.setItem('token', 'test-token');
});
afterEach(() => {
jest.resetAllMocks();
});
// β
Testing fetchTodos
describe('fetchTodos', () => {
it('should fetch todos successfully', async () => {
const mockResponse = {
todos: [
{
id: 'todo-1',
text: 'First todo',
completed: false,
priority: 'high' as const
},
{
id: 'todo-2',
text: 'Second todo',
completed: true,
priority: 'low' as const
}
],
pagination: {
page: 1,
limit: 10,
total: 2,
totalPages: 1
}
};
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse)
} as Response);
const params = { page: 1, limit: 10, filter: 'all' };
const dispatch = jest.fn();
const getState = jest.fn();
const extra = {};
const thunk = fetchTodos(params);
const result = await thunk(dispatch, getState, extra);
expect(result.type).toBe('todos/fetchTodos/fulfilled');
expect(result.payload).toEqual(mockResponse);
expect(mockFetch).toHaveBeenCalledWith('/api/todos?page=1&limit=10&filter=all');
});
it('should handle fetch todos error', async () => {
const errorResponse = {
message: 'Failed to fetch todos',
code: 500
};
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch.mockResolvedValue({
ok: false,
json: () => Promise.resolve(errorResponse)
} as Response);
const dispatch = jest.fn();
const getState = jest.fn();
const extra = {};
const thunk = fetchTodos();
const result = await thunk(dispatch, getState, extra);
expect(result.type).toBe('todos/fetchTodos/rejected');
expect(result.payload).toEqual(errorResponse);
});
});
// β
Testing createTodo
describe('createTodo', () => {
it('should create todo successfully', async () => {
const newTodo = {
text: 'New todo',
completed: false,
priority: 'medium' as const
};
const createdTodo = {
id: 'todo-123',
...newTodo
};
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(createdTodo)
} as Response);
const dispatch = jest.fn();
const getState = jest.fn();
const extra = {};
const thunk = createTodo(newTodo);
const result = await thunk(dispatch, getState, extra);
expect(result.type).toBe('todos/createTodo/fulfilled');
expect(result.payload).toEqual(createdTodo);
expect(mockFetch).toHaveBeenCalledWith('/api/todos', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer test-token'
},
body: JSON.stringify(newTodo)
});
});
it('should handle create todo validation error', async () => {
const invalidTodo = {
text: '',
completed: false,
priority: 'medium' as const
};
const errorResponse = {
message: 'Text is required',
code: 400,
field: 'text'
};
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch.mockResolvedValue({
ok: false,
json: () => Promise.resolve(errorResponse)
} as Response);
const dispatch = jest.fn();
const getState = jest.fn();
const extra = {};
const thunk = createTodo(invalidTodo);
const result = await thunk(dispatch, getState, extra);
expect(result.type).toBe('todos/createTodo/rejected');
expect(result.payload).toEqual(errorResponse);
});
});
// β
Testing updateTodo
describe('updateTodo', () => {
it('should update todo successfully', async () => {
const todoId = 'todo-123';
const updates = { text: 'Updated todo text', completed: true };
const updatedTodo = {
id: todoId,
text: 'Updated todo text',
completed: true,
priority: 'high' as const
};
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(updatedTodo)
} as Response);
const dispatch = jest.fn();
const getState = jest.fn();
const extra = {};
const thunk = updateTodo({ id: todoId, updates });
const result = await thunk(dispatch, getState, extra);
expect(result.type).toBe('todos/updateTodo/fulfilled');
expect(result.payload).toEqual(updatedTodo);
expect(mockFetch).toHaveBeenCalledWith(`/api/todos/${todoId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer test-token'
},
body: JSON.stringify(updates)
});
});
});
// β
Testing deleteTodo
describe('deleteTodo', () => {
it('should delete todo successfully', async () => {
const todoId = 'todo-123';
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch.mockResolvedValue({
ok: true
} as Response);
const dispatch = jest.fn();
const getState = jest.fn();
const extra = {};
const thunk = deleteTodo(todoId);
const result = await thunk(dispatch, getState, extra);
expect(result.type).toBe('todos/deleteTodo/fulfilled');
expect(result.payload).toBe(todoId);
expect(mockFetch).toHaveBeenCalledWith(`/api/todos/${todoId}`, {
method: 'DELETE',
headers: {
'Authorization': 'Bearer test-token'
}
});
});
it('should handle delete todo error', async () => {
const todoId = 'todo-123';
const errorResponse = {
message: 'Todo not found',
code: 404
};
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch.mockResolvedValue({
ok: false,
json: () => Promise.resolve(errorResponse)
} as Response);
const dispatch = jest.fn();
const getState = jest.fn();
const extra = {};
const thunk = deleteTodo(todoId);
const result = await thunk(dispatch, getState, extra);
expect(result.type).toBe('todos/deleteTodo/rejected');
expect(result.payload).toEqual(errorResponse);
});
});
});
π§ Testing Reducers
ποΈ Testing State Transitions
Reducers are pure functions that should be tested thoroughly for all possible state transitions.
// π Comprehensive reducer testing
describe('Auth Reducer', () => {
// β
Testing initial state
it('should return initial state', () => {
const state = authReducer(undefined, { type: '@@INIT' } as any);
expect(state).toEqual(initialAuthState);
});
// β
Testing synchronous actions
describe('Synchronous Actions', () => {
it('should handle clearError', () => {
const previousState: AuthState = {
...initialAuthState,
error: 'Some error'
};
const state = authReducer(previousState, clearError());
expect(state.error).toBeNull();
expect(state).toEqual({
...previousState,
error: null
});
});
it('should handle resetLoginAttempts', () => {
const previousState: AuthState = {
...initialAuthState,
loginAttempts: 3,
lastLoginAttempt: '2023-01-01T00:00:00Z'
};
const state = authReducer(previousState, resetLoginAttempts());
expect(state.loginAttempts).toBe(0);
expect(state.lastLoginAttempt).toBeNull();
});
it('should handle updateUserProfile when user exists', () => {
const user: User = {
id: 'user-123',
name: 'John Doe',
email: '[email protected]',
role: 'user',
isActive: true,
createdAt: '2023-01-01T00:00:00Z'
};
const previousState: AuthState = {
...initialAuthState,
user
};
const updates = { name: 'Updated Name', email: '[email protected]' };
const state = authReducer(previousState, updateUserProfile(updates));
expect(state.user).toEqual({
...user,
...updates
});
});
it('should not update user profile when user is null', () => {
const previousState: AuthState = {
...initialAuthState,
user: null
};
const updates = { name: 'Updated Name' };
const state = authReducer(previousState, updateUserProfile(updates));
expect(state.user).toBeNull();
expect(state).toEqual(previousState);
});
it('should handle setToken', () => {
const token = 'new-token-123';
const state = authReducer(initialAuthState, setToken(token));
expect(state.token).toBe(token);
});
});
// β
Testing async action states
describe('Login User Async Actions', () => {
it('should handle loginUser.pending', () => {
const action = { type: loginUser.pending.type };
const state = authReducer(initialAuthState, action);
expect(state.loading).toBe(true);
expect(state.error).toBeNull();
});
it('should handle loginUser.fulfilled', () => {
const payload = {
user: {
id: 'user-123',
name: 'John Doe',
email: '[email protected]',
role: 'user' as const,
isActive: true,
createdAt: '2023-01-01T00:00:00Z'
},
token: 'auth-token-123'
};
const previousState: AuthState = {
...initialAuthState,
loading: true,
loginAttempts: 2,
lastLoginAttempt: '2023-01-01T10:00:00Z'
};
const action = { type: loginUser.fulfilled.type, payload };
const state = authReducer(previousState, action);
expect(state).toEqual({
...previousState,
loading: false,
user: payload.user,
token: payload.token,
error: null,
loginAttempts: 0,
lastLoginAttempt: null
});
});
it('should handle loginUser.rejected', () => {
const payload = {
message: 'Invalid credentials',
code: 401
};
const previousState: AuthState = {
...initialAuthState,
loading: true,
loginAttempts: 1
};
const action = { type: loginUser.rejected.type, payload };
const state = authReducer(previousState, action);
expect(state.loading).toBe(false);
expect(state.error).toBe('Invalid credentials');
expect(state.loginAttempts).toBe(2);
expect(state.lastLoginAttempt).toBeDefined();
expect(new Date(state.lastLoginAttempt!).getTime()).toBeCloseTo(Date.now(), -3);
});
it('should handle loginUser.rejected without payload', () => {
const previousState: AuthState = {
...initialAuthState,
loading: true
};
const action = { type: loginUser.rejected.type, payload: undefined };
const state = authReducer(previousState, action);
expect(state.loading).toBe(false);
expect(state.error).toBe('Login failed');
expect(state.loginAttempts).toBe(1);
});
});
// β
Testing logout actions
describe('Logout User Async Actions', () => {
const loggedInState: AuthState = {
...initialAuthState,
user: {
id: 'user-123',
name: 'John Doe',
email: '[email protected]',
role: 'user',
isActive: true,
createdAt: '2023-01-01T00:00:00Z'
},
token: 'auth-token-123',
loginAttempts: 1,
lastLoginAttempt: '2023-01-01T10:00:00Z'
};
it('should handle logoutUser.pending', () => {
const action = { type: logoutUser.pending.type };
const state = authReducer(loggedInState, action);
expect(state.loading).toBe(true);
// Other state should remain unchanged
expect(state.user).toBe(loggedInState.user);
expect(state.token).toBe(loggedInState.token);
});
it('should handle logoutUser.fulfilled', () => {
const action = { type: logoutUser.fulfilled.type };
const state = authReducer(loggedInState, action);
expect(state).toEqual({
loading: false,
user: null,
token: null,
error: null,
loginAttempts: 0,
lastLoginAttempt: null
});
});
it('should handle logoutUser.rejected', () => {
const payload = {
message: 'Logout failed',
code: 500
};
const action = { type: logoutUser.rejected.type, payload };
const state = authReducer(loggedInState, action);
expect(state.loading).toBe(false);
expect(state.error).toBe('Logout failed');
// User should remain logged in on logout failure
expect(state.user).toBe(loggedInState.user);
expect(state.token).toBe(loggedInState.token);
});
});
});
describe('Todos Reducer', () => {
// β
Testing initial state
it('should return initial state', () => {
const state = todosReducer(undefined, { type: '@@INIT' } as any);
expect(state).toEqual(initialTodosState);
});
// β
Testing synchronous actions
describe('Synchronous Actions', () => {
it('should handle setFilter', () => {
const filter = 'completed';
const previousState = {
...initialTodosState,
pagination: { ...initialTodosState.pagination, page: 3 }
};
const state = todosReducer(previousState, setFilter(filter));
expect(state.filter).toBe(filter);
expect(state.pagination.page).toBe(1); // Should reset to page 1
});
it('should handle setSortBy', () => {
const sortBy = 'priority';
const state = todosReducer(initialTodosState, setSortBy(sortBy));
expect(state.sortBy).toBe(sortBy);
});
it('should handle setSearchQuery', () => {
const searchQuery = 'important task';
const previousState = {
...initialTodosState,
pagination: { ...initialTodosState.pagination, page: 2 }
};
const state = todosReducer(previousState, setSearchQuery(searchQuery));
expect(state.searchQuery).toBe(searchQuery);
expect(state.pagination.page).toBe(1); // Should reset to page 1
});
it('should handle toggleTodoCompleted for existing todo', () => {
const todos: Todo[] = [
{
id: 'todo-1',
text: 'First todo',
completed: false,
priority: 'medium'
},
{
id: 'todo-2',
text: 'Second todo',
completed: true,
priority: 'high'
}
];
const previousState = {
...initialTodosState,
items: todos
};
const state = todosReducer(previousState, toggleTodoCompleted('todo-1'));
expect(state.items[0].completed).toBe(true);
expect(state.items[1].completed).toBe(true); // Should remain unchanged
});
it('should handle toggleTodoCompleted for non-existing todo', () => {
const todos: Todo[] = [
{
id: 'todo-1',
text: 'First todo',
completed: false,
priority: 'medium'
}
];
const previousState = {
...initialTodosState,
items: todos
};
const state = todosReducer(previousState, toggleTodoCompleted('non-existing'));
expect(state.items).toEqual(todos); // Should remain unchanged
});
it('should handle addTodoOptimistic', () => {
const newTodo: Todo = {
id: 'temp-123',
text: 'Optimistic todo',
completed: false,
priority: 'low'
};
const state = todosReducer(initialTodosState, addTodoOptimistic(newTodo));
expect(state.items).toHaveLength(1);
expect(state.items[0]).toEqual(newTodo);
});
it('should handle removeTodoOptimistic', () => {
const todos: Todo[] = [
{
id: 'temp-123',
text: 'Temp todo',
completed: false,
priority: 'medium'
},
{
id: 'real-456',
text: 'Real todo',
completed: false,
priority: 'high'
}
];
const previousState = {
...initialTodosState,
items: todos
};
const state = todosReducer(previousState, removeTodoOptimistic('temp-123'));
expect(state.items).toHaveLength(1);
expect(state.items[0].id).toBe('real-456');
});
});
// β
Testing async actions
describe('Fetch Todos Async Actions', () => {
it('should handle fetchTodos.pending', () => {
const action = { type: fetchTodos.pending.type };
const state = todosReducer(initialTodosState, action);
expect(state.loading).toBe(true);
expect(state.error).toBeNull();
});
it('should handle fetchTodos.fulfilled', () => {
const payload = {
todos: [
{
id: 'todo-1',
text: 'First todo',
completed: false,
priority: 'high' as const
},
{
id: 'todo-2',
text: 'Second todo',
completed: true,
priority: 'low' as const
}
],
pagination: {
page: 1,
limit: 10,
total: 2,
totalPages: 1
}
};
const action = { type: fetchTodos.fulfilled.type, payload };
const state = todosReducer(initialTodosState, action);
expect(state.loading).toBe(false);
expect(state.items).toEqual(payload.todos);
expect(state.pagination).toEqual(payload.pagination);
expect(state.error).toBeNull();
});
it('should handle fetchTodos.rejected', () => {
const payload = {
message: 'Failed to fetch todos',
code: 500
};
const action = { type: fetchTodos.rejected.type, payload };
const state = todosReducer(initialTodosState, action);
expect(state.loading).toBe(false);
expect(state.error).toBe('Failed to fetch todos');
});
});
// β
Testing create/update/delete actions
describe('CRUD Async Actions', () => {
const existingTodos: Todo[] = [
{
id: 'todo-1',
text: 'Existing todo',
completed: false,
priority: 'medium'
}
];
it('should handle createTodo.fulfilled', () => {
const newTodo: Todo = {
id: 'todo-2',
text: 'New todo',
completed: false,
priority: 'high'
};
const previousState = {
...initialTodosState,
items: [...existingTodos, { id: 'temp-id', text: 'Temp', completed: false, priority: 'low' as const }],
loading: true
};
const action = { type: createTodo.fulfilled.type, payload: newTodo };
const state = todosReducer(previousState, action);
expect(state.loading).toBe(false);
expect(state.error).toBeNull();
expect(state.items).toHaveLength(2);
expect(state.items[0]).toEqual(newTodo); // New todo should be first
expect(state.items[1]).toEqual(existingTodos[0]);
expect(state.items.some(item => item.id === 'temp-id')).toBe(false); // Temp should be removed
});
it('should handle updateTodo.fulfilled', () => {
const updatedTodo: Todo = {
id: 'todo-1',
text: 'Updated todo',
completed: true,
priority: 'high'
};
const previousState = {
...initialTodosState,
items: existingTodos
};
const action = { type: updateTodo.fulfilled.type, payload: updatedTodo };
const state = todosReducer(previousState, action);
expect(state.items).toHaveLength(1);
expect(state.items[0]).toEqual(updatedTodo);
});
it('should handle deleteTodo.fulfilled', () => {
const previousState = {
...initialTodosState,
items: existingTodos
};
const action = { type: deleteTodo.fulfilled.type, payload: 'todo-1' };
const state = todosReducer(previousState, action);
expect(state.items).toHaveLength(0);
});
});
});
πͺ Testing Store Integration
π Testing Complete Store Behavior
Store testing verifies that actions, reducers, and selectors work together correctly.
// π Comprehensive store integration testing
describe('Store Integration', () => {
let store: ReturnType<typeof createTestStore>;
beforeEach(() => {
store = createTestStore();
global.fetch = jest.fn();
});
afterEach(() => {
jest.resetAllMocks();
});
// β
Testing initial store state
it('should have correct initial state', () => {
const state = store.getState();
expect(state.auth).toEqual(initialAuthState);
expect(state.todos).toEqual(initialTodosState);
});
// β
Testing store with preloaded state
it('should initialize with preloaded state', () => {
const preloadedState: Partial<RootState> = {
auth: {
...initialAuthState,
user: {
id: 'user-123',
name: 'John Doe',
email: '[email protected]',
role: 'user',
isActive: true,
createdAt: '2023-01-01T00:00:00Z'
},
token: 'existing-token'
}
};
const storeWithState = createTestStore(preloadedState);
const state = storeWithState.getState();
expect(state.auth.user).toEqual(preloadedState.auth!.user);
expect(state.auth.token).toBe('existing-token');
});
// β
Testing action dispatching
it('should dispatch actions and update state', () => {
// Test auth actions
store.dispatch(setToken('new-token'));
expect(store.getState().auth.token).toBe('new-token');
store.dispatch(clearError());
expect(store.getState().auth.error).toBeNull();
// Test todos actions
store.dispatch(setFilter('completed'));
expect(store.getState().todos.filter).toBe('completed');
store.dispatch(setSearchQuery('test query'));
expect(store.getState().todos.searchQuery).toBe('test query');
});
// β
Testing async thunk integration
it('should handle async thunk workflow', async () => {
const mockUser = {
id: 'user-123',
name: 'John Doe',
email: '[email protected]',
role: 'user' as const,
isActive: true,
createdAt: '2023-01-01T00:00:00Z'
};
const mockResponse = {
user: mockUser,
token: 'auth-token-123'
};
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse)
} as Response);
const credentials = { email: '[email protected]', password: 'password123' };
// Dispatch async action
const resultAction = await store.dispatch(loginUser(credentials));
// Check that action was fulfilled
expect(resultAction.type).toBe('auth/loginUser/fulfilled');
// Check state updates
const state = store.getState();
expect(state.auth.loading).toBe(false);
expect(state.auth.user).toEqual(mockUser);
expect(state.auth.token).toBe('auth-token-123');
expect(state.auth.error).toBeNull();
});
// β
Testing multiple action interactions
it('should handle complex action sequences', async () => {
// Start with login
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({
user: {
id: 'user-123',
name: 'John Doe',
email: '[email protected]',
role: 'admin',
isActive: true,
createdAt: '2023-01-01T00:00:00Z'
},
token: 'auth-token-123'
})
} as Response);
await store.dispatch(loginUser({
email: '[email protected]',
password: 'password123'
}));
// Update user profile
store.dispatch(updateUserProfile({ name: 'Updated Name' }));
// Add some todos
store.dispatch(addTodoOptimistic({
id: 'temp-1',
text: 'First todo',
completed: false,
priority: 'high'
}));
store.dispatch(addTodoOptimistic({
id: 'temp-2',
text: 'Second todo',
completed: true,
priority: 'low'
}));
// Change filter
store.dispatch(setFilter('active'));
// Verify final state
const state = store.getState();
expect(state.auth.user?.name).toBe('Updated Name');
expect(state.todos.items).toHaveLength(2);
expect(state.todos.filter).toBe('active');
});
// β
Testing error scenarios
it('should handle error states correctly', async () => {
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch.mockResolvedValue({
ok: false,
json: () => Promise.resolve({
message: 'Invalid credentials',
code: 401
})
} as Response);
const credentials = { email: '[email protected]', password: 'wrongpass' };
await store.dispatch(loginUser(credentials));
const state = store.getState();
expect(state.auth.loading).toBe(false);
expect(state.auth.error).toBe('Invalid credentials');
expect(state.auth.user).toBeNull();
expect(state.auth.loginAttempts).toBe(1);
});
// β
Testing optimistic updates
it('should handle optimistic updates correctly', async () => {
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
// Add optimistic todo
const optimisticTodo: Todo = {
id: 'temp-id',
text: 'Optimistic todo',
completed: false,
priority: 'medium'
};
store.dispatch(addTodoOptimistic(optimisticTodo));
expect(store.getState().todos.items).toHaveLength(1);
// Mock successful creation
const createdTodo: Todo = {
id: 'real-id',
text: 'Optimistic todo',
completed: false,
priority: 'medium'
};
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(createdTodo)
} as Response);
// Dispatch actual create action
await store.dispatch(createTodo({
text: 'Optimistic todo',
completed: false,
priority: 'medium'
}));
const finalState = store.getState();
expect(finalState.todos.items).toHaveLength(1);
expect(finalState.todos.items[0].id).toBe('real-id'); // Should have real ID
expect(finalState.todos.loading).toBe(false);
expect(finalState.todos.error).toBeNull();
});
// β
Testing state persistence
it('should maintain state consistency across actions', () => {
let stateChanges: RootState[] = [];
// Subscribe to state changes
const unsubscribe = store.subscribe(() => {
stateChanges.push(store.getState());
});
// Perform multiple actions
store.dispatch(setToken('token-1'));
store.dispatch(setFilter('active'));
store.dispatch(setSearchQuery('search'));
store.dispatch(clearError());
unsubscribe();
// Verify all state changes were tracked
expect(stateChanges).toHaveLength(4);
// Verify final state
const finalState = store.getState();
expect(finalState.auth.token).toBe('token-1');
expect(finalState.todos.filter).toBe('active');
expect(finalState.todos.searchQuery).toBe('search');
});
});
// β
Testing selectors
describe('Selectors', () => {
let store: ReturnType<typeof createTestStore>;
beforeEach(() => {
store = createTestStore();
});
it('should select auth state correctly', () => {
const mockUser: User = {
id: 'user-123',
name: 'John Doe',
email: '[email protected]',
role: 'user',
isActive: true,
createdAt: '2023-01-01T00:00:00Z'
};
// Set up state
store.dispatch(setToken('test-token'));
store.dispatch(updateUserProfile(mockUser));
// Test selectors
const state = store.getState();
expect(selectUser(state)).toEqual(mockUser);
expect(selectIsAuthenticated(state)).toBe(true);
expect(selectAuthLoading(state)).toBe(false);
expect(selectAuthError(state)).toBeNull();
});
it('should select filtered todos correctly', () => {
const todos: Todo[] = [
{ id: '1', text: 'Active todo 1', completed: false, priority: 'high' },
{ id: '2', text: 'Completed todo', completed: true, priority: 'low' },
{ id: '3', text: 'Active todo 2', completed: false, priority: 'medium' },
{ id: '4', text: 'Another completed', completed: true, priority: 'high' }
];
// Set up todos
todos.forEach(todo => {
store.dispatch(addTodoOptimistic(todo));
});
const state = store.getState();
// Test 'all' filter
expect(selectFilteredTodos(state)).toHaveLength(4);
// Test 'active' filter
store.dispatch(setFilter('active'));
const activeState = store.getState();
const activeTodos = selectFilteredTodos(activeState);
expect(activeTodos).toHaveLength(2);
expect(activeTodos.every(todo => !todo.completed)).toBe(true);
// Test 'completed' filter
store.dispatch(setFilter('completed'));
const completedState = store.getState();
const completedTodos = selectFilteredTodos(completedState);
expect(completedTodos).toHaveLength(2);
expect(completedTodos.every(todo => todo.completed)).toBe(true);
});
it('should select todos with search query', () => {
const todos: Todo[] = [
{ id: '1', text: 'Important task', completed: false, priority: 'high' },
{ id: '2', text: 'Regular task', completed: false, priority: 'low' },
{ id: '3', text: 'Another important item', completed: true, priority: 'medium' }
];
todos.forEach(todo => {
store.dispatch(addTodoOptimistic(todo));
});
// Set search query
store.dispatch(setSearchQuery('important'));
const state = store.getState();
const filteredTodos = selectFilteredTodos(state);
expect(filteredTodos).toHaveLength(2);
expect(filteredTodos.every(todo =>
todo.text.toLowerCase().includes('important')
)).toBe(true);
});
it('should calculate todo stats correctly', () => {
const todos: Todo[] = [
{ id: '1', text: 'Todo 1', completed: false, priority: 'high' },
{ id: '2', text: 'Todo 2', completed: true, priority: 'low' },
{ id: '3', text: 'Todo 3', completed: false, priority: 'high' },
{ id: '4', text: 'Todo 4', completed: false, priority: 'medium' }
];
todos.forEach(todo => {
store.dispatch(addTodoOptimistic(todo));
});
const state = store.getState();
const stats = selectTodoStats(state);
expect(stats).toEqual({
total: 4,
completed: 1,
active: 3,
highPriority: 2
});
});
});
π§ͺ Testing React-Redux Integration
βοΈ Testing Components with Redux
Testing React components that use Redux requires proper store setup and component integration testing.
// π Comprehensive React-Redux integration testing
describe('React-Redux Integration', () => {
// β
Testing connected components
describe('Auth Components', () => {
const LoginForm: React.FC = () => {
const dispatch = useDispatch();
const { loading, error } = useSelector((state: RootState) => ({
loading: selectAuthLoading(state),
error: selectAuthError(state)
}));
const [email, setEmail] = React.useState('');
const [password, setPassword] = React.useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await dispatch(loginUser({ email, password }));
};
return (
<form onSubmit={handleSubmit} data-testid="login-form">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
data-testid="email-input"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
data-testid="password-input"
/>
<button type="submit" disabled={loading} data-testid="submit-button">
{loading ? 'Logging in...' : 'Login'}
</button>
{error && <div data-testid="error-message">{error}</div>}
</form>
);
};
it('should handle successful login', async () => {
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({
user: {
id: 'user-123',
name: 'John Doe',
email: '[email protected]',
role: 'user',
isActive: true,
createdAt: '2023-01-01T00:00:00Z'
},
token: 'auth-token-123'
})
} as Response);
const { store } = renderWithRedux(<LoginForm />);
const emailInput = screen.getByTestId('email-input');
const passwordInput = screen.getByTestId('password-input');
const submitButton = screen.getByTestId('submit-button');
// Fill form
fireEvent.change(emailInput, { target: { value: '[email protected]' } });
fireEvent.change(passwordInput, { target: { value: 'password123' } });
// Submit form
fireEvent.click(submitButton);
// Should show loading state
expect(submitButton).toHaveTextContent('Logging in...');
expect(submitButton).toBeDisabled();
// Wait for completion
await waitFor(() => {
expect(submitButton).toHaveTextContent('Login');
expect(submitButton).not.toBeDisabled();
});
// Verify store state
const state = store.getState();
expect(state.auth.user?.email).toBe('[email protected]');
expect(state.auth.token).toBe('auth-token-123');
});
it('should handle login error', async () => {
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch.mockResolvedValue({
ok: false,
json: () => Promise.resolve({
message: 'Invalid credentials',
code: 401
})
} as Response);
renderWithRedux(<LoginForm />);
const emailInput = screen.getByTestId('email-input');
const passwordInput = screen.getByTestId('password-input');
const submitButton = screen.getByTestId('submit-button');
// Fill form with invalid credentials
fireEvent.change(emailInput, { target: { value: '[email protected]' } });
fireEvent.change(passwordInput, { target: { value: 'wrongpass' } });
// Submit form
fireEvent.click(submitButton);
// Wait for error
await waitFor(() => {
expect(screen.getByTestId('error-message')).toHaveTextContent('Invalid credentials');
});
expect(submitButton).toHaveTextContent('Login');
expect(submitButton).not.toBeDisabled();
});
});
// β
Testing todos components
describe('Todos Components', () => {
const TodoList: React.FC = () => {
const dispatch = useDispatch();
const { todos, loading, filter } = useSelector((state: RootState) => ({
todos: selectFilteredTodos(state),
loading: selectTodosLoading(state),
filter: selectTodosFilter(state)
}));
const handleToggle = (id: string) => {
dispatch(toggleTodoCompleted(id));
};
const handleFilterChange = (newFilter: TodosState['filter']) => {
dispatch(setFilter(newFilter));
};
if (loading) {
return <div data-testid="loading">Loading todos...</div>;
}
return (
<div data-testid="todo-list">
<div data-testid="filter-buttons">
<button
onClick={() => handleFilterChange('all')}
data-active={filter === 'all'}
data-testid="filter-all"
>
All
</button>
<button
onClick={() => handleFilterChange('active')}
data-active={filter === 'active'}
data-testid="filter-active"
>
Active
</button>
<button
onClick={() => handleFilterChange('completed')}
data-active={filter === 'completed'}
data-testid="filter-completed"
>
Completed
</button>
</div>
<div data-testid="todos">
{todos.map(todo => (
<div key={todo.id} data-testid={`todo-${todo.id}`}>
<span className={todo.completed ? 'completed' : ''}>
{todo.text}
</span>
<button
onClick={() => handleToggle(todo.id)}
data-testid={`toggle-${todo.id}`}
>
{todo.completed ? 'Undo' : 'Complete'}
</button>
</div>
))}
</div>
{todos.length === 0 && (
<div data-testid="no-todos">No todos found</div>
)}
</div>
);
};
it('should render todos and handle filtering', () => {
const initialState: Partial<RootState> = {
todos: {
...initialTodosState,
items: [
{ id: '1', text: 'Active todo', completed: false, priority: 'medium' },
{ id: '2', text: 'Completed todo', completed: true, priority: 'low' },
{ id: '3', text: 'Another active', completed: false, priority: 'high' }
]
}
};
renderWithRedux(<TodoList />, { store: createMockStore(initialState) });
// Should show all todos initially
expect(screen.getByTestId('todo-1')).toBeInTheDocument();
expect(screen.getByTestId('todo-2')).toBeInTheDocument();
expect(screen.getByTestId('todo-3')).toBeInTheDocument();
// Filter to active todos
fireEvent.click(screen.getByTestId('filter-active'));
// Should only show active todos
expect(screen.getByTestId('todo-1')).toBeInTheDocument();
expect(screen.queryByTestId('todo-2')).not.toBeInTheDocument();
expect(screen.getByTestId('todo-3')).toBeInTheDocument();
// Filter to completed todos
fireEvent.click(screen.getByTestId('filter-completed'));
// Should only show completed todos
expect(screen.queryByTestId('todo-1')).not.toBeInTheDocument();
expect(screen.getByTestId('todo-2')).toBeInTheDocument();
expect(screen.queryByTestId('todo-3')).not.toBeInTheDocument();
});
it('should handle todo toggle', () => {
const initialState: Partial<RootState> = {
todos: {
...initialTodosState,
items: [
{ id: '1', text: 'Test todo', completed: false, priority: 'medium' }
]
}
};
const { store } = renderWithRedux(<TodoList />, {
store: createMockStore(initialState)
});
const toggleButton = screen.getByTestId('toggle-1');
expect(toggleButton).toHaveTextContent('Complete');
// Toggle todo
fireEvent.click(toggleButton);
// Verify state change
const state = store.getState();
expect(state.todos.items[0].completed).toBe(true);
expect(toggleButton).toHaveTextContent('Undo');
});
it('should show loading state', () => {
const initialState: Partial<RootState> = {
todos: {
...initialTodosState,
loading: true
}
};
renderWithRedux(<TodoList />, { store: createMockStore(initialState) });
expect(screen.getByTestId('loading')).toHaveTextContent('Loading todos...');
expect(screen.queryByTestId('todo-list')).not.toBeInTheDocument();
});
it('should show empty state', () => {
renderWithRedux(<TodoList />);
expect(screen.getByTestId('no-todos')).toHaveTextContent('No todos found');
});
});
// β
Testing custom hooks with Redux
describe('Custom Hooks with Redux', () => {
const useAuth = () => {
const dispatch = useDispatch();
const authState = useSelector((state: RootState) => state.auth);
const login = useCallback((credentials: { email: string; password: string }) => {
return dispatch(loginUser(credentials));
}, [dispatch]);
const logout = useCallback(() => {
return dispatch(logoutUser());
}, [dispatch]);
const clearAuthError = useCallback(() => {
dispatch(clearError());
}, [dispatch]);
return {
...authState,
login,
logout,
clearAuthError,
isAuthenticated: !!authState.token
};
};
const TestComponent: React.FC = () => {
const auth = useAuth();
return (
<div>
<div data-testid="auth-status">
{auth.isAuthenticated ? 'Authenticated' : 'Not authenticated'}
</div>
<div data-testid="user-name">
{auth.user?.name || 'No user'}
</div>
{auth.error && (
<div data-testid="auth-error">{auth.error}</div>
)}
<button onClick={auth.clearAuthError} data-testid="clear-error">
Clear Error
</button>
</div>
);
};
it('should use auth hook correctly', () => {
const initialState: Partial<RootState> = {
auth: {
...initialAuthState,
user: {
id: 'user-123',
name: 'John Doe',
email: '[email protected]',
role: 'user',
isActive: true,
createdAt: '2023-01-01T00:00:00Z'
},
token: 'auth-token-123'
}
};
renderWithRedux(<TestComponent />, { store: createMockStore(initialState) });
expect(screen.getByTestId('auth-status')).toHaveTextContent('Authenticated');
expect(screen.getByTestId('user-name')).toHaveTextContent('John Doe');
});
it('should handle error clearing', () => {
const initialState: Partial<RootState> = {
auth: {
...initialAuthState,
error: 'Login failed'
}
};
const { store } = renderWithRedux(<TestComponent />, {
store: createMockStore(initialState)
});
expect(screen.getByTestId('auth-error')).toHaveTextContent('Login failed');
// Clear error
fireEvent.click(screen.getByTestId('clear-error'));
// Verify error is cleared
const state = store.getState();
expect(state.auth.error).toBeNull();
expect(screen.queryByTestId('auth-error')).not.toBeInTheDocument();
});
});
});
π Conclusion
Congratulations! Youβve mastered the art of testing Redux in TypeScript! π―
π Key Takeaways
- Action Testing: Verify action creators produce correct types and payloads
- Async Thunk Testing: Mock external dependencies and test all scenarios
- Reducer Testing: Test pure functions for all state transitions
- Store Integration: Test complete workflows with real store instances
- Selector Testing: Verify computed state derivations
- Component Integration: Test React-Redux connections with proper store setup
- Error Scenarios: Test failure paths and error handling
- Performance: Verify optimistic updates and state consistency
π Next Steps
- API Testing: Master API mocking with Mock Service Worker (MSW)
- Integration Testing: Build comprehensive integration test suites
- E2E Testing: Test complete user workflows with Redux state
- Performance Testing: Optimize Redux performance and test scaling
- Advanced Patterns: Test Redux middleware and complex state patterns
You now have the skills to test any Redux application with confidence, ensuring your state management is reliable, predictable, and maintainable! π