Project Overview
Welcome to your weekend project! In this exercise, you'll implement comprehensive testing for a full-stack JavaScript application. By the end of this project, you'll have created a robust testing suite that covers your application from end to end, ensuring that it works correctly and reliably.
We'll approach this project using George Polya's 4-step problem-solving method:
- Understand the problem: What needs to be tested and why?
- Devise a plan: How will we organize our testing approach?
- Execute the plan: Implement the tests step by step
- Review and extend: Evaluate our testing, identify gaps, and improve
Let's begin by understanding the challenge at hand.
Understanding the Problem
Testing a full-stack application is like performing a thorough inspection of a building. Just as an inspector checks the foundation, structure, plumbing, electrical systems, and overall functionality of a building, we need to test all layers of our application:
What We Need to Test
- Frontend: React components, state management, API calls
- Backend: API endpoints, business logic, database operations
- Integration: How frontend and backend work together
- End-to-End: Complete user flows across the entire application
Project Requirements
- Implement unit tests for frontend components
- Implement unit tests for backend services and controllers
- Create integration tests for API endpoints
- Develop end-to-end tests for critical user flows
- Set up a CI pipeline for automated testing
- Generate test coverage reports
- Document the testing approach and procedures
The Sample Application
For this project, we'll work with a simple "Task Manager" application with the following features:
- User authentication (register, login, logout)
- Task operations (create, read, update, delete tasks)
- Task filtering and sorting
- User profile management
The application uses the following technology stack:
- Frontend: React, React Router, Redux
- Backend: Node.js, Express
- Database: MongoDB with Mongoose
- Authentication: JWT
Now that we understand the problem, let's create a plan for our testing approach.
Devising a Plan
Just as a doctor follows a systematic approach to diagnose illness—starting with simple checks like temperature and blood pressure before moving to more complex tests—we'll build our testing suite from simple unit tests to complex end-to-end tests.
Testing Plan Whiteboard
- Set up the testing environment and tools
- Implement frontend unit tests
- Implement backend unit tests
- Create integration tests for API endpoints
- Develop end-to-end tests
- Configure test coverage reporting
- Set up a CI pipeline for automatic testing
- Document the testing approach
Testing Tools We'll Use
- Frontend Unit Tests: Jest, React Testing Library
- Backend Unit Tests: Jest, Supertest
- End-to-End Tests: Cypress
- Code Coverage: Jest's built-in coverage, nyc (istanbul)
- CI Pipeline: GitHub Actions
With our plan in place, let's move on to implementation.
Executing the Plan
Step 1: Setting Up the Testing Environment
Before we write any tests, we need to set up our testing environment. Let's start by setting up Jest for both frontend and backend testing.
Frontend Testing Setup (React)
Create a Jest configuration file for the frontend project:
File: frontend/jest.config.js
// This configuration sets up Jest for testing React components
module.exports = {
// The root directory for Jest to look for test files
rootDir: '.',
// The test environment for React components
testEnvironment: 'jsdom',
// File extensions Jest will look for
moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'],
// Transform files with babel-jest
transform: {
'^.+\\.(js|jsx|ts|tsx)$': 'babel-jest',
},
// Modules to mock when importing
moduleNameMapper: {
// Mock CSS imports
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
// Mock image imports
'\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.js',
// Path aliases if you use them
'^@/(.*)$': '<rootDir>/src/$1',
},
// Setup files to run before each test
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
// Directories to search for test files
testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'],
// Coverage configuration
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/index.js',
'!src/serviceWorker.js',
],
coverageThreshold: {
global: {
statements: 70,
branches: 70,
functions: 70,
lines: 70,
},
},
};
Create a setup file for React Testing Library:
File: frontend/src/setupTests.js
// Import Jest DOM extensions
import '@testing-library/jest-dom';
// Mock global objects if necessary
global.fetch = jest.fn();
// Clean up after each test
afterEach(() => {
jest.clearAllMocks();
});
Backend Testing Setup (Node.js/Express)
Create a Jest configuration file for the backend project:
File: backend/jest.config.js
module.exports = {
// The root directory
rootDir: '.',
// Test environment for Node.js
testEnvironment: 'node',
// File extensions for Jest to look for
moduleFileExtensions: ['js', 'json'],
// Transform files with babel-jest if needed
transform: {
'^.+\\.js$': 'babel-jest',
},
// Mocks for environment variables
setupFiles: ['<rootDir>/tests/setup.js'],
// Directories to search for test files
testMatch: ['**/__tests__/**/*.js', '**/?(*.)+(spec|test).js'],
// Coverage configuration
collectCoverageFrom: [
'src/**/*.js',
'!src/index.js',
'!**/node_modules/**',
'!**/vendor/**',
],
coverageThreshold: {
global: {
statements: 70,
branches: 70,
functions: 70,
lines: 70,
},
},
};
Create a setup file for backend tests:
File: backend/tests/setup.js
// Set environment variables for testing
process.env.NODE_ENV = 'test';
process.env.PORT = 3001;
process.env.MONGODB_URI = 'mongodb://localhost:27017/task_manager_test';
process.env.JWT_SECRET = 'test-secret-key';
// Mock any modules if needed
jest.mock('../src/utils/logger', () => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
}));
// Global setup before tests
global.beforeAll(async () => {
// Database connection or any other setup
});
// Global teardown after tests
global.afterAll(async () => {
// Close database connection or any other cleanup
});
Step 2: Implementing Frontend Unit Tests
Now that our testing environment is set up, let's start implementing tests for our frontend components. We'll begin with the Task component, which displays a single task in our application.
Testing a React Component
File: frontend/src/components/Task/Task.js
import React from 'react';
import { formatDate } from '../../utils/dateUtils';
import './Task.css';
const Task = ({ task, onComplete, onDelete, onEdit }) => {
const { _id, title, description, dueDate, completed } = task;
return (
<div className={`task-item ${completed ? 'completed' : ''}`} data-testid="task-item">
<div className="task-header">
<h3>{title}</h3>
<div className="task-actions">
<button
className="btn-complete"
onClick={() => onComplete(_id, !completed)}
data-testid="complete-button"
>
{completed ? 'Mark Incomplete' : 'Complete'}
</button>
<button
className="btn-edit"
onClick={() => onEdit(task)}
data-testid="edit-button"
>
Edit
</button>
<button
className="btn-delete"
onClick={() => onDelete(_id)}
data-testid="delete-button"
>
Delete
</button>
</div>
</div>
<p className="task-description">{description}</p>
{dueDate && (
<p className="task-due-date">
Due: {formatDate(dueDate)}
</p>
)}
</div>
);
};
export default Task;
Now, let's write tests for this component:
File: frontend/src/components/Task/Task.test.js
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Task from './Task';
import { formatDate } from '../../utils/dateUtils';
// Mock the dateUtils module
jest.mock('../../utils/dateUtils', () => ({
formatDate: jest.fn(date => 'formatted-date')
}));
describe('Task Component', () => {
// Sample task data for testing
const mockTask = {
_id: '123',
title: 'Test Task',
description: 'This is a test task',
dueDate: '2025-05-15T00:00:00.000Z',
completed: false
};
// Mock functions for event handlers
const mockOnComplete = jest.fn();
const mockOnDelete = jest.fn();
const mockOnEdit = jest.fn();
// Reset mocks before each test
beforeEach(() => {
jest.clearAllMocks();
});
// Test 1: Component renders correctly
test('renders task with correct content', () => {
// Render the component with mock props
render(
<Task
task={mockTask}
onComplete={mockOnComplete}
onDelete={mockOnDelete}
onEdit={mockOnEdit}
/>
);
// Check if all expected elements are in the document
expect(screen.getByText(mockTask.title)).toBeInTheDocument();
expect(screen.getByText(mockTask.description)).toBeInTheDocument();
expect(screen.getByText(/due:/i)).toBeInTheDocument();
expect(formatDate).toHaveBeenCalledWith(mockTask.dueDate);
// Check if buttons are rendered
expect(screen.getByTestId('complete-button')).toBeInTheDocument();
expect(screen.getByTestId('edit-button')).toBeInTheDocument();
expect(screen.getByTestId('delete-button')).toBeInTheDocument();
});
// Test 2: Complete button works correctly
test('calls onComplete with correct arguments when complete button is clicked', () => {
// Render the component
render(
<Task
task={mockTask}
onComplete={mockOnComplete}
onDelete={mockOnDelete}
onEdit={mockOnEdit}
/>
);
// Find and click the complete button
const completeButton = screen.getByTestId('complete-button');
fireEvent.click(completeButton);
// Check if the onComplete function was called with correct arguments
expect(mockOnComplete).toHaveBeenCalledTimes(1);
expect(mockOnComplete).toHaveBeenCalledWith(mockTask._id, true);
});
// Test 3: Delete button works correctly
test('calls onDelete with correct id when delete button is clicked', () => {
// Render the component
render(
<Task
task={mockTask}
onComplete={mockOnComplete}
onDelete={mockOnDelete}
onEdit={mockOnEdit}
/>
);
// Find and click the delete button
const deleteButton = screen.getByTestId('delete-button');
fireEvent.click(deleteButton);
// Check if the onDelete function was called with correct arguments
expect(mockOnDelete).toHaveBeenCalledTimes(1);
expect(mockOnDelete).toHaveBeenCalledWith(mockTask._id);
});
// Test 4: Edit button works correctly
test('calls onEdit with task object when edit button is clicked', () => {
// Render the component
render(
<Task
task={mockTask}
onComplete={mockOnComplete}
onDelete={mockOnDelete}
onEdit={mockOnEdit}
/>
);
// Find and click the edit button
const editButton = screen.getByTestId('edit-button');
fireEvent.click(editButton);
// Check if the onEdit function was called with correct arguments
expect(mockOnEdit).toHaveBeenCalledTimes(1);
expect(mockOnEdit).toHaveBeenCalledWith(mockTask);
});
// Test 5: Component applies completed class when task is completed
test('applies completed class when task is marked as completed', () => {
// Create a completed task by modifying our mock
const completedTask = { ...mockTask, completed: true };
// Render the component with completed task
render(
<Task
task={completedTask}
onComplete={mockOnComplete}
onDelete={mockOnDelete}
onEdit={mockOnEdit}
/>
);
// Check if the task item has the completed class
const taskItem = screen.getByTestId('task-item');
expect(taskItem).toHaveClass('completed');
// Check if the complete button text changes
expect(screen.getByTestId('complete-button')).toHaveTextContent('Mark Incomplete');
});
});
Testing Redux Store and Actions
Next, let's test our Redux store and actions. First, let's create a simple task slice:
File: frontend/src/store/slices/taskSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import taskService from '../../services/taskService';
// Async thunk to fetch all tasks
export const fetchTasks = createAsyncThunk(
'tasks/fetchTasks',
async (_, { rejectWithValue }) => {
try {
const response = await taskService.getAllTasks();
return response.data;
} catch (error) {
return rejectWithValue(error.response?.data || error.message);
}
}
);
// Async thunk to add a new task
export const addTask = createAsyncThunk(
'tasks/addTask',
async (taskData, { rejectWithValue }) => {
try {
const response = await taskService.createTask(taskData);
return response.data;
} catch (error) {
return rejectWithValue(error.response?.data || error.message);
}
}
);
// Async thunk to update a task
export const updateTask = createAsyncThunk(
'tasks/updateTask',
async ({ id, taskData }, { rejectWithValue }) => {
try {
const response = await taskService.updateTask(id, taskData);
return response.data;
} catch (error) {
return rejectWithValue(error.response?.data || error.message);
}
}
);
// Async thunk to delete a task
export const deleteTask = createAsyncThunk(
'tasks/deleteTask',
async (id, { rejectWithValue }) => {
try {
await taskService.deleteTask(id);
return id;
} catch (error) {
return rejectWithValue(error.response?.data || error.message);
}
}
);
// Initial state
const initialState = {
tasks: [],
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null,
};
// Create the slice
const taskSlice = createSlice({
name: 'tasks',
initialState,
reducers: {
clearTaskError: (state) => {
state.error = null;
}
},
extraReducers: (builder) => {
builder
// Handle fetchTasks
.addCase(fetchTasks.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchTasks.fulfilled, (state, action) => {
state.status = 'succeeded';
state.tasks = action.payload;
})
.addCase(fetchTasks.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload;
})
// Handle addTask
.addCase(addTask.pending, (state) => {
state.status = 'loading';
})
.addCase(addTask.fulfilled, (state, action) => {
state.status = 'succeeded';
state.tasks.push(action.payload);
})
.addCase(addTask.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload;
})
// Handle updateTask
.addCase(updateTask.pending, (state) => {
state.status = 'loading';
})
.addCase(updateTask.fulfilled, (state, action) => {
state.status = 'succeeded';
const index = state.tasks.findIndex(task => task._id === action.payload._id);
if (index !== -1) {
state.tasks[index] = action.payload;
}
})
.addCase(updateTask.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload;
})
// Handle deleteTask
.addCase(deleteTask.pending, (state) => {
state.status = 'loading';
})
.addCase(deleteTask.fulfilled, (state, action) => {
state.status = 'succeeded';
state.tasks = state.tasks.filter(task => task._id !== action.payload);
})
.addCase(deleteTask.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload;
});
},
});
export const { clearTaskError } = taskSlice.actions;
export default taskSlice.reducer;
Now, let's write tests for our Redux slice:
File: frontend/src/store/slices/taskSlice.test.js
import taskReducer, {
fetchTasks,
addTask,
updateTask,
deleteTask,
clearTaskError
} from './taskSlice';
import taskService from '../../services/taskService';
// Mock the taskService
jest.mock('../../services/taskService');
describe('Task Slice', () => {
// Initial state for tests
const initialState = {
tasks: [],
status: 'idle',
error: null,
};
// Sample task data
const mockTasks = [
{ _id: '1', title: 'Task 1', description: 'Description 1', completed: false },
{ _id: '2', title: 'Task 2', description: 'Description 2', completed: true },
];
const newTask = { title: 'New Task', description: 'New Description', completed: false };
const createdTask = { _id: '3', ...newTask };
const updatedTask = { _id: '1', title: 'Updated Task', description: 'Updated Description', completed: true };
// Test 1: Initial state
test('should return the initial state', () => {
expect(taskReducer(undefined, { type: undefined })).toEqual(initialState);
});
// Test 2: clearTaskError action
test('should clear error when clearTaskError action is dispatched', () => {
const stateWithError = {
...initialState,
error: 'Some error message',
};
expect(taskReducer(stateWithError, clearTaskError())).toEqual({
...stateWithError,
error: null,
});
});
// Test 3: fetchTasks async thunk - pending
test('should set status to loading when fetchTasks is pending', () => {
const action = { type: fetchTasks.pending.type };
const state = taskReducer(initialState, action);
expect(state).toEqual({
...initialState,
status: 'loading',
});
});
// Test 4: fetchTasks async thunk - fulfilled
test('should update tasks and set status to succeeded when fetchTasks is fulfilled', () => {
const action = {
type: fetchTasks.fulfilled.type,
payload: mockTasks
};
const state = taskReducer(initialState, action);
expect(state).toEqual({
tasks: mockTasks,
status: 'succeeded',
error: null,
});
});
// Test 5: fetchTasks async thunk - rejected
test('should set error and status to failed when fetchTasks is rejected', () => {
const errorMessage = 'Failed to fetch tasks';
const action = {
type: fetchTasks.rejected.type,
payload: errorMessage
};
const state = taskReducer(initialState, action);
expect(state).toEqual({
...initialState,
status: 'failed',
error: errorMessage,
});
});
// Test 6: addTask async thunk - fulfilled
test('should add a new task when addTask is fulfilled', () => {
const stateBefore = {
tasks: [...mockTasks],
status: 'idle',
error: null,
};
const action = {
type: addTask.fulfilled.type,
payload: createdTask
};
const state = taskReducer(stateBefore, action);
expect(state.tasks).toHaveLength(3);
expect(state.tasks).toContainEqual(createdTask);
expect(state.status).toBe('succeeded');
});
// Test 7: updateTask async thunk - fulfilled
test('should update existing task when updateTask is fulfilled', () => {
const stateBefore = {
tasks: [...mockTasks],
status: 'idle',
error: null,
};
const action = {
type: updateTask.fulfilled.type,
payload: updatedTask
};
const state = taskReducer(stateBefore, action);
expect(state.tasks).toHaveLength(2);
expect(state.tasks[0]).toEqual(updatedTask);
expect(state.status).toBe('succeeded');
});
// Test 8: deleteTask async thunk - fulfilled
test('should remove task when deleteTask is fulfilled', () => {
const stateBefore = {
tasks: [...mockTasks],
status: 'idle',
error: null,
};
const action = {
type: deleteTask.fulfilled.type,
payload: '1' // ID of the task to delete
};
const state = taskReducer(stateBefore, action);
expect(state.tasks).toHaveLength(1);
expect(state.tasks[0]._id).toBe('2');
expect(state.status).toBe('succeeded');
});
// Test 9: Integration test with mocked API calls
test('fetchTasks thunk should call API and dispatch correct actions', async () => {
// Mock the API response
taskService.getAllTasks.mockResolvedValue({
data: mockTasks
});
// Create mock dispatch function
const dispatch = jest.fn();
// Call the thunk with dispatch
await fetchTasks()(dispatch, () => {}, undefined);
// Check if correct actions were dispatched
expect(dispatch).toHaveBeenCalledTimes(2);
// Check first dispatch (pending action)
expect(dispatch.mock.calls[0][0].type).toBe(fetchTasks.pending.type);
// Check second dispatch (fulfilled action)
expect(dispatch.mock.calls[1][0].type).toBe(fetchTasks.fulfilled.type);
expect(dispatch.mock.calls[1][0].payload).toEqual(mockTasks);
// Verify that the service was called
expect(taskService.getAllTasks).toHaveBeenCalledTimes(1);
});
});
Step 3: Implementing Backend Unit Tests
Now let's move on to testing our backend. We'll start with testing our Task model.
Testing Mongoose Models
File: backend/src/models/Task.js
const mongoose = require('mongoose');
const taskSchema = new mongoose.Schema({
title: {
type: String,
required: true,
trim: true,
minlength: 1,
maxlength: 100
},
description: {
type: String,
required: false,
trim: true,
maxlength: 500
},
completed: {
type: Boolean,
default: false
},
dueDate: {
type: Date,
required: false
},
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
}
}, {
timestamps: true
});
// Create indexes for efficient querying
taskSchema.index({ user: 1, completed: 1 });
taskSchema.index({ user: 1, dueDate: 1 });
const Task = mongoose.model('Task', taskSchema);
module.exports = Task;
Now, let's write tests for our Task model:
File: backend/src/models/__tests__/Task.test.js
const mongoose = require('mongoose');
const Task = require('../Task');
// Use in-memory MongoDB server for tests
const { MongoMemoryServer } = require('mongodb-memory-server');
describe('Task Model', () => {
let mongoServer;
// Set up MongoDB memory server before tests
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const uri = mongoServer.getUri();
await mongoose.connect(uri);
});
// Clean up after tests
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
// Clear database before each test
beforeEach(async () => {
await Task.deleteMany({});
});
// Test 1: Task creation with valid data
test('should create a new task successfully', async () => {
// Create a mock user ID
const userId = new mongoose.Types.ObjectId();
// Create task data
const taskData = {
title: 'Test Task',
description: 'This is a test task',
completed: false,
dueDate: new Date('2025-12-31'),
user: userId
};
// Create and save a task
const task = new Task(taskData);
const savedTask = await task.save();
// Assertions
expect(savedTask._id).toBeDefined();
expect(savedTask.title).toBe(taskData.title);
expect(savedTask.description).toBe(taskData.description);
expect(savedTask.completed).toBe(taskData.completed);
expect(savedTask.dueDate).toEqual(taskData.dueDate);
expect(savedTask.user.toString()).toBe(userId.toString());
expect(savedTask.createdAt).toBeDefined();
expect(savedTask.updatedAt).toBeDefined();
});
// Test 2: Task validation - missing required field (title)
test('should fail to create a task without a title', async () => {
// Create a mock user ID
const userId = new mongoose.Types.ObjectId();
// Create task data with missing title
const taskData = {
description: 'This is a test task',
completed: false,
user: userId
};
// Try to create and save a task
const task = new Task(taskData);
// Expect validation error
await expect(task.save()).rejects.toThrow();
});
// Test 3: Task validation - title length constraint
test('should fail to create a task with a title exceeding max length', async () => {
// Create a mock user ID
const userId = new mongoose.Types.ObjectId();
// Create task data with too long title
const taskData = {
title: 'a'.repeat(101), // 101 characters (exceeding 100 character limit)
description: 'This is a test task',
completed: false,
user: userId
};
// Try to create and save a task
const task = new Task(taskData);
// Expect validation error
await expect(task.save()).rejects.toThrow();
});
// Test 4: Task validation - missing required field (user)
test('should fail to create a task without a user reference', async () => {
// Create task data with missing user
const taskData = {
title: 'Test Task',
description: 'This is a test task',
completed: false
};
// Try to create and save a task
const task = new Task(taskData);
// Expect validation error
await expect(task.save()).rejects.toThrow();
});
// Test 5: Default values are set
test('should set default values correctly', async () => {
// Create a mock user ID
const userId = new mongoose.Types.ObjectId();
// Create minimal task data
const taskData = {
title: 'Test Task',
user: userId
};
// Create and save a task
const task = new Task(taskData);
const savedTask = await task.save();
// Assertions for default values
expect(savedTask.completed).toBe(false);
expect(savedTask.description).toBe(undefined);
expect(savedTask.dueDate).toBe(undefined);
});
});
Testing API Controllers
Next, let's test our task controller:
File: backend/src/controllers/taskController.js
const Task = require('../models/Task');
// Get all tasks for the current user
const getAllTasks = async (req, res) => {
try {
const tasks = await Task.find({ user: req.user._id })
.sort({ createdAt: -1 });
res.status(200).json(tasks);
} catch (error) {
res.status(500).json({ message: 'Error fetching tasks', error: error.message });
}
};
// Get a single task by ID
const getTaskById = async (req, res) => {
try {
const task = await Task.findOne({
_id: req.params.id,
user: req.user._id
});
if (!task) {
return res.status(404).json({ message: 'Task not found' });
}
res.status(200).json(task);
} catch (error) {
res.status(500).json({ message: 'Error fetching task', error: error.message });
}
};
// Create a new task
const createTask = async (req, res) => {
try {
const { title, description, dueDate, completed } = req.body;
const task = new Task({
title,
description,
dueDate,
completed,
user: req.user._id
});
const savedTask = await task.save();
res.status(201).json(savedTask);
} catch (error) {
res.status(400).json({ message: 'Error creating task', error: error.message });
}
};
// Update a task
const updateTask = async (req, res) => {
try {
const { title, description, dueDate, completed } = req.body;
const task = await Task.findOne({
_id: req.params.id,
user: req.user._id
});
if (!task) {
return res.status(404).json({ message: 'Task not found' });
}
// Update task fields
if (title !== undefined) task.title = title;
if (description !== undefined) task.description = description;
if (dueDate !== undefined) task.dueDate = dueDate;
if (completed !== undefined) task.completed = completed;
const updatedTask = await task.save();
res.status(200).json(updatedTask);
} catch (error) {
res.status(400).json({ message: 'Error updating task', error: error.message });
}
};
// Delete a task
const deleteTask = async (req, res) => {
try {
const task = await Task.findOneAndDelete({
_id: req.params.id,
user: req.user._id
});
if (!task) {
return res.status(404).json({ message: 'Task not found' });
}
res.status(200).json({ message: 'Task deleted successfully' });
} catch (error) {
res.status(500).json({ message: 'Error deleting task', error: error.message });
}
};
module.exports = {
getAllTasks,
getTaskById,
createTask,
updateTask,
deleteTask
};
Now, let's write tests for our task controller:
File: backend/src/controllers/__tests__/taskController.test.js
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const taskController = require('../taskController');
const Task = require('../../models/Task');
const httpMocks = require('node-mocks-http');
// Mock User model
jest.mock('../../models/User', () => {
return jest.fn().mockImplementation(() => {
return {
save: jest.fn().mockResolvedValue(this)
};
});
});
describe('Task Controller', () => {
let mongoServer;
let mockUser;
// Set up MongoDB memory server before tests
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const uri = mongoServer.getUri();
await mongoose.connect(uri);
// Create a mock user ID for testing
mockUser = {
_id: new mongoose.Types.ObjectId(),
name: 'Test User',
email: 'test@example.com'
};
});
// Clean up after tests
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
// Clear database before each test
beforeEach(async () => {
await Task.deleteMany({});
});
// Test 1: Get all tasks
test('getAllTasks should return all tasks for the current user', async () => {
// Create test tasks in the database
const testTasks = [
{
title: 'Task 1',
description: 'Description 1',
user: mockUser._id
},
{
title: 'Task 2',
description: 'Description 2',
user: mockUser._id
},
{
title: 'Task 3',
description: 'Description 3',
user: new mongoose.Types.ObjectId() // Different user
}
];
await Task.insertMany(testTasks);
// Create mock request and response
const req = httpMocks.createRequest({
user: mockUser
});
const res = httpMocks.createResponse();
// Call controller method
await taskController.getAllTasks(req, res);
// Parse response
const data = JSON.parse(res._getData());
// Assertions
expect(res._getStatusCode()).toBe(200);
expect(data).toHaveLength(2); // Only the tasks for mockUser
expect(data[0].title).toBe('Task 1');
expect(data[1].title).toBe('Task 2');
});
// Test 2: Get task by ID
test('getTaskById should return a single task if it belongs to the user', async () => {
// Create a test task
const testTask = new Task({
title: 'Test Task',
description: 'Test Description',
user: mockUser._id
});
const savedTask = await testTask.save();
// Create mock request and response
const req = httpMocks.createRequest({
user: mockUser,
params: {
id: savedTask._id.toString()
}
});
const res = httpMocks.createResponse();
// Call controller method
await taskController.getTaskById(req, res);
// Parse response
const data = JSON.parse(res._getData());
// Assertions
expect(res._getStatusCode()).toBe(200);
expect(data.title).toBe('Test Task');
expect(data.description).toBe('Test Description');
expect(data.user.toString()).toBe(mockUser._id.toString());
});
// Test 3: Get task by ID - not found
test('getTaskById should return 404 if task does not exist', async () => {
// Create mock request with non-existent ID
const req = httpMocks.createRequest({
user: mockUser,
params: {
id: new mongoose.Types.ObjectId().toString()
}
});
const res = httpMocks.createResponse();
// Call controller method
await taskController.getTaskById(req, res);
// Parse response
const data = JSON.parse(res._getData());
// Assertions
expect(res._getStatusCode()).toBe(404);
expect(data.message).toBe('Task not found');
});
// Test 4: Create task
test('createTask should create a new task', async () => {
// Task data
const taskData = {
title: 'New Task',
description: 'New Description',
dueDate: new Date('2025-12-31')
};
// Create mock request and response
const req = httpMocks.createRequest({
user: mockUser,
body: taskData
});
const res = httpMocks.createResponse();
// Call controller method
await taskController.createTask(req, res);
// Parse response
const data = JSON.parse(res._getData());
// Assertions
expect(res._getStatusCode()).toBe(201);
expect(data.title).toBe(taskData.title);
expect(data.description).toBe(taskData.description);
expect(new Date(data.dueDate)).toEqual(taskData.dueDate);
expect(data.user.toString()).toBe(mockUser._id.toString());
// Check database
const savedTask = await Task.findById(data._id);
expect(savedTask).not.toBeNull();
expect(savedTask.title).toBe(taskData.title);
});
// Test 5: Update task
test('updateTask should update an existing task', async () => {
// Create a test task
const testTask = new Task({
title: 'Original Title',
description: 'Original Description',
user: mockUser._id
});
const savedTask = await testTask.save();
// Update data
const updateData = {
title: 'Updated Title',
completed: true
};
// Create mock request and response
const req = httpMocks.createRequest({
user: mockUser,
params: {
id: savedTask._id.toString()
},
body: updateData
});
const res = httpMocks.createResponse();
// Call controller method
await taskController.updateTask(req, res);
// Parse response
const data = JSON.parse(res._getData());
// Assertions
expect(res._getStatusCode()).toBe(200);
expect(data.title).toBe(updateData.title);
expect(data.description).toBe('Original Description'); // Not updated
expect(data.completed).toBe(true);
// Check database
const updatedTask = await Task.findById(savedTask._id);
expect(updatedTask.title).toBe(updateData.title);
expect(updatedTask.completed).toBe(true);
});
// Test 6: Delete task
test('deleteTask should remove a task', async () => {
// Create a test task
const testTask = new Task({
title: 'Task to Delete',
description: 'Will be deleted',
user: mockUser._id
});
const savedTask = await testTask.save();
// Create mock request and response
const req = httpMocks.createRequest({
user: mockUser,
params: {
id: savedTask._id.toString()
}
});
const res = httpMocks.createResponse();
// Call controller method
await taskController.deleteTask(req, res);
// Parse response
const data = JSON.parse(res._getData());
// Assertions
expect(res._getStatusCode()).toBe(200);
expect(data.message).toBe('Task deleted successfully');
// Check database
const deletedTask = await Task.findById(savedTask._id);
expect(deletedTask).toBeNull();
});
});
Step 4: Implementing API Integration Tests
Now that we've tested our components individually, let's test how they work together by creating integration tests for our API endpoints.
File: backend/tests/integration/tasks.test.js
const request = require('supertest');
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const app = require('../../src/app');
const User = require('../../src/models/User');
const Task = require('../../src/models/Task');
const jwt = require('jsonwebtoken');
describe('Task API Endpoints', () => {
let mongoServer;
let testUser;
let authToken;
// Set up MongoDB memory server and create a test user
beforeAll(async () => {
// Start MongoDB Memory Server
mongoServer = await MongoMemoryServer.create();
const uri = mongoServer.getUri();
await mongoose.connect(uri);
// Create a test user
testUser = new User({
name: 'Test User',
email: 'test@example.com',
password: 'password123'
});
await testUser.save();
// Generate JWT for authentication
authToken = jwt.sign(
{ userId: testUser._id },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
});
// Clean up after all tests
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
// Clear tasks before each test
beforeEach(async () => {
await Task.deleteMany({});
});
// Test Suite: Task CRUD Operations
describe('Task CRUD Operations', () => {
// Test 1: GET /api/tasks - Get all tasks (empty)
test('GET /api/tasks should return empty array when no tasks exist', async () => {
const response = await request(app)
.get('/api/tasks')
.set('Authorization', `Bearer ${authToken}`);
expect(response.status).toBe(200);
expect(response.body).toEqual([]);
});
// Test 2: POST /api/tasks - Create task
test('POST /api/tasks should create a new task', async () => {
const taskData = {
title: 'Integration Test Task',
description: 'Created during integration test',
dueDate: new Date('2025-12-31')
};
const response = await request(app)
.post('/api/tasks')
.set('Authorization', `Bearer ${authToken}`)
.send(taskData);
expect(response.status).toBe(201);
expect(response.body.title).toBe(taskData.title);
expect(response.body.description).toBe(taskData.description);
expect(new Date(response.body.dueDate)).toEqual(taskData.dueDate);
expect(response.body.user.toString()).toBe(testUser._id.toString());
// Verify in database
const task = await Task.findById(response.body._id);
expect(task).not.toBeNull();
});
// Test 3: GET /api/tasks - Get all tasks (with data)
test('GET /api/tasks should return all tasks for the user', async () => {
// Create some test tasks
const testTasks = [
{
title: 'Task 1',
description: 'Description 1',
user: testUser._id
},
{
title: 'Task 2',
description: 'Description 2',
user: testUser._id
}
];
await Task.insertMany(testTasks);
const response = await request(app)
.get('/api/tasks')
.set('Authorization', `Bearer ${authToken}`);
expect(response.status).toBe(200);
expect(response.body).toHaveLength(2);
expect(response.body[0].title).toBe('Task 1');
expect(response.body[1].title).toBe('Task 2');
});
// Test 4: GET /api/tasks/:id - Get task by ID
test('GET /api/tasks/:id should return a single task', async () => {
// Create a test task
const task = new Task({
title: 'Single Task',
description: 'Get by ID test',
user: testUser._id
});
const savedTask = await task.save();
const response = await request(app)
.get(`/api/tasks/${savedTask._id}`)
.set('Authorization', `Bearer ${authToken}`);
expect(response.status).toBe(200);
expect(response.body.title).toBe('Single Task');
expect(response.body.description).toBe('Get by ID test');
});
// Test 5: PUT /api/tasks/:id - Update task
test('PUT /api/tasks/:id should update a task', async () => {
// Create a test task
const task = new Task({
title: 'Original Title',
description: 'Original Description',
user: testUser._id
});
const savedTask = await task.save();
const updateData = {
title: 'Updated Title',
completed: true
};
const response = await request(app)
.put(`/api/tasks/${savedTask._id}`)
.set('Authorization', `Bearer ${authToken}`)
.send(updateData);
expect(response.status).toBe(200);
expect(response.body.title).toBe('Updated Title');
expect(response.body.description).toBe('Original Description');
expect(response.body.completed).toBe(true);
// Verify in database
const updatedTask = await Task.findById(savedTask._id);
expect(updatedTask.title).toBe('Updated Title');
expect(updatedTask.completed).toBe(true);
});
// Test 6: DELETE /api/tasks/:id - Delete task
test('DELETE /api/tasks/:id should delete a task', async () => {
// Create a test task
const task = new Task({
title: 'Task to Delete',
description: 'Will be deleted',
user: testUser._id
});
const savedTask = await task.save();
const response = await request(app)
.delete(`/api/tasks/${savedTask._id}`)
.set('Authorization', `Bearer ${authToken}`);
expect(response.status).toBe(200);
expect(response.body.message).toBe('Task deleted successfully');
// Verify in database
const deletedTask = await Task.findById(savedTask._id);
expect(deletedTask).toBeNull();
});
});
// Test Suite: Authentication and Authorization
describe('Authentication and Authorization', () => {
// Test 1: Unauthenticated request
test('should return 401 if no auth token is provided', async () => {
const response = await request(app)
.get('/api/tasks');
expect(response.status).toBe(401);
});
// Test 2: Invalid auth token
test('should return 401 if invalid auth token is provided', async () => {
const response = await request(app)
.get('/api/tasks')
.set('Authorization', 'Bearer invalid-token');
expect(response.status).toBe(401);
});
// Test 3: Task access control
test('should not allow access to tasks owned by other users', async () => {
// Create another user
const anotherUser = new User({
name: 'Another User',
email: 'another@example.com',
password: 'password456'
});
await anotherUser.save();
// Create a task owned by the other user
const task = new Task({
title: 'Other User Task',
description: 'Belongs to another user',
user: anotherUser._id
});
const savedTask = await task.save();
// Try to access with testUser's token
const response = await request(app)
.get(`/api/tasks/${savedTask._id}`)
.set('Authorization', `Bearer ${authToken}`);
expect(response.status).toBe(404); // Should return not found, not forbidden
});
});
});
Step 5: Implementing End-to-End Tests
Finally, let's implement end-to-end tests using Cypress to test critical user flows across the entire application.
File: frontend/cypress/e2e/task_management.cy.js
// Cypress E2E test for task management functionality
describe('Task Management', () => {
// Test data
const testUser = {
name: 'E2E Test User',
email: 'e2e-test@example.com',
password: 'testPassword123'
};
const testTask = {
title: 'E2E Test Task',
description: 'This task was created during E2E testing'
};
const updatedTask = {
title: 'Updated E2E Test Task',
description: 'This task was updated during E2E testing'
};
// Setup: Register and login before tests
beforeEach(() => {
// Clear database and seed test data via API
cy.request('POST', 'http://localhost:3001/api/test/reset');
cy.visit('/register');
// Register new user
cy.get('[data-testid=name-input]').type(testUser.name);
cy.get('[data-testid=email-input]').type(testUser.email);
cy.get('[data-testid=password-input]').type(testUser.password);
cy.get('[data-testid=confirm-password-input]').type(testUser.password);
cy.get('[data-testid=register-button]').click();
// We should be redirected to login
cy.url().should('include', '/login');
// Login
cy.get('[data-testid=email-input]').type(testUser.email);
cy.get('[data-testid=password-input]').type(testUser.password);
cy.get('[data-testid=login-button]').click();
// We should be redirected to dashboard
cy.url().should('include', '/dashboard');
});
// Test 1: Create a new task
it('should create a new task', () => {
// Click on the "Add Task" button
cy.get('[data-testid=add-task-button]').click();
// A modal should appear
cy.get('[data-testid=task-modal]').should('be.visible');
// Fill in task details
cy.get('[data-testid=task-title-input]').type(testTask.title);
cy.get('[data-testid=task-description-input]').type(testTask.description);
// Submit the form
cy.get('[data-testid=save-task-button]').click();
// Modal should close
cy.get('[data-testid=task-modal]').should('not.exist');
// Task should appear in the list
cy.get('[data-testid=task-list]').should('contain', testTask.title);
cy.get('[data-testid=task-list]').should('contain', testTask.description);
});
// Test 2: Edit an existing task
it('should edit an existing task', () => {
// First create a task
cy.get('[data-testid=add-task-button]').click();
cy.get('[data-testid=task-title-input]').type(testTask.title);
cy.get('[data-testid=task-description-input]').type(testTask.description);
cy.get('[data-testid=save-task-button]').click();
// Find and click the edit button
cy.get('[data-testid=edit-button]').first().click();
// Modal should appear with pre-filled values
cy.get('[data-testid=task-modal]').should('be.visible');
cy.get('[data-testid=task-title-input]').should('have.value', testTask.title);
cy.get('[data-testid=task-description-input]').should('have.value', testTask.description);
// Update the values
cy.get('[data-testid=task-title-input]').clear().type(updatedTask.title);
cy.get('[data-testid=task-description-input]').clear().type(updatedTask.description);
// Save changes
cy.get('[data-testid=save-task-button]').click();
// Updated task should appear in the list
cy.get('[data-testid=task-list]').should('contain', updatedTask.title);
cy.get('[data-testid=task-list]').should('contain', updatedTask.description);
// Original task should no longer appear
cy.get('[data-testid=task-list]').should('not.contain', testTask.title);
});
// Test 3: Mark a task as completed
it('should mark a task as completed', () => {
// First create a task
cy.get('[data-testid=add-task-button]').click();
cy.get('[data-testid=task-title-input]').type(testTask.title);
cy.get('[data-testid=task-description-input]').type(testTask.description);
cy.get('[data-testid=save-task-button]').click();
// Initially, task should not have completed class
cy.get('[data-testid=task-item]').first().should('not.have.class', 'completed');
// Click the complete button
cy.get('[data-testid=complete-button]').first().click();
// Task should now have completed class
cy.get('[data-testid=task-item]').first().should('have.class', 'completed');
// Button text should change
cy.get('[data-testid=complete-button]').first().should('contain', 'Mark Incomplete');
});
// Test 4: Delete a task
it('should delete a task', () => {
// First create a task
cy.get('[data-testid=add-task-button]').click();
cy.get('[data-testid=task-title-input]').type(testTask.title);
cy.get('[data-testid=task-description-input]').type(testTask.description);
cy.get('[data-testid=save-task-button]').click();
// Verify task exists
cy.get('[data-testid=task-list]').should('contain', testTask.title);
// Click the delete button
cy.get('[data-testid=delete-button]').first().click();
// Confirm deletion in the modal
cy.get('[data-testid=confirm-delete-button]').click();
// Task should no longer appear in the list
cy.get('[data-testid=task-list]').should('not.contain', testTask.title);
cy.get('[data-testid=task-list]').should('not.contain', testTask.description);
});
// Test 5: Task filtering
it('should filter tasks by completion status', () => {
// Create a completed task
cy.get('[data-testid=add-task-button]').click();
cy.get('[data-testid=task-title-input]').type('Completed Task');
cy.get('[data-testid=task-description-input]').type('This task is completed');
cy.get('[data-testid=save-task-button]').click();
// Mark it as completed
cy.get('[data-testid=complete-button]').first().click();
// Create an incomplete task
cy.get('[data-testid=add-task-button]').click();
cy.get('[data-testid=task-title-input]').type('Incomplete Task');
cy.get('[data-testid=task-description-input]').type('This task is not completed');
cy.get('[data-testid=save-task-button]').click();
// Initially both tasks should be visible
cy.get('[data-testid=task-item]').should('have.length', 2);
// Filter by completed tasks
cy.get('[data-testid=filter-completed]').click();
// Only completed task should be visible
cy.get('[data-testid=task-item]').should('have.length', 1);
cy.get('[data-testid=task-list]').should('contain', 'Completed Task');
cy.get('[data-testid=task-list]').should('not.contain', 'Incomplete Task');
// Filter by incomplete tasks
cy.get('[data-testid=filter-incomplete]').click();
// Only incomplete task should be visible
cy.get('[data-testid=task-item]').should('have.length', 1);
cy.get('[data-testid=task-list]').should('not.contain', 'Completed Task');
cy.get('[data-testid=task-list]').should('contain', 'Incomplete Task');
// Show all tasks
cy.get('[data-testid=filter-all]').click();
// Both tasks should be visible again
cy.get('[data-testid=task-item]').should('have.length', 2);
});
});
Step 6: Setting Up a CI Pipeline
Now that we have all our tests in place, let's set up a CI pipeline to run them automatically whenever we push changes to our repository. We'll use GitHub Actions for this.
File: .github/workflows/ci.yml
name: Full-Stack Testing CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test-backend:
runs-on: ubuntu-latest
services:
mongodb:
image: mongo:4.4
ports:
- 27017:27017
steps:
- uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '16.x'
cache: 'npm'
cache-dependency-path: 'backend/package-lock.json'
- name: Install backend dependencies
run: npm ci
working-directory: ./backend
- name: Run backend linting
run: npm run lint
working-directory: ./backend
- name: Run backend unit tests
run: npm test
working-directory: ./backend
- name: Run backend integration tests
run: npm run test:integration
working-directory: ./backend
- name: Generate backend coverage report
run: npm run test:coverage
working-directory: ./backend
- name: Upload backend coverage
uses: actions/upload-artifact@v3
with:
name: backend-coverage
path: backend/coverage/
test-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '16.x'
cache: 'npm'
cache-dependency-path: 'frontend/package-lock.json'
- name: Install frontend dependencies
run: npm ci
working-directory: ./frontend
- name: Run frontend linting
run: npm run lint
working-directory: ./frontend
- name: Run frontend unit tests
run: npm test
working-directory: ./frontend
- name: Generate frontend coverage report
run: npm run test:coverage
working-directory: ./frontend
- name: Upload frontend coverage
uses: actions/upload-artifact@v3
with:
name: frontend-coverage
path: frontend/coverage/
e2e-tests:
needs: [test-backend, test-frontend]
runs-on: ubuntu-latest
services:
mongodb:
image: mongo:4.4
ports:
- 27017:27017
steps:
- uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '16.x'
cache: 'npm'
- name: Install backend dependencies
run: npm ci
working-directory: ./backend
- name: Install frontend dependencies
run: npm ci
working-directory: ./frontend
- name: Start backend server
run: npm run start:ci
working-directory: ./backend
env:
PORT: 3001
MONGODB_URI: mongodb://localhost:27017/task_manager_test
JWT_SECRET: test-secret-key
NODE_ENV: test
- name: Build frontend
run: npm run build
working-directory: ./frontend
- name: Serve frontend
run: npm run serve
working-directory: ./frontend
env:
PORT: 3000
REACT_APP_API_URL: http://localhost:3001
- name: Run Cypress tests
uses: cypress-io/github-action@v5
with:
working-directory: ./frontend
wait-on: 'http://localhost:3000,http://localhost:3001/api/health'
browser: chrome
record: true
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
- name: Upload Cypress screenshots
uses: actions/upload-artifact@v3
if: failure()
with:
name: cypress-screenshots
path: frontend/cypress/screenshots/
- name: Upload Cypress videos
uses: actions/upload-artifact@v3
if: always()
with:
name: cypress-videos
path: frontend/cypress/videos/
deploy-preview:
needs: e2e-tests
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '16.x'
- name: Install dependencies and build
run: |
cd frontend && npm ci && npm run build
cd ../backend && npm ci
- name: Deploy to preview environment
run: npm run deploy:preview
working-directory: ./backend
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
Step 7: Configure Test Coverage Reporting
To track our test coverage over time, let's set up a configuration for coverage reporting.
Backend Coverage Setup
File: backend/package.json
{
"name": "task-manager-backend",
"version": "1.0.0",
"description": "Task Manager API",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"test": "jest",
"test:watch": "jest --watch",
"test:integration": "jest --testMatch='**/tests/integration/**/*.test.js'",
"test:coverage": "jest --coverage",
"lint": "eslint .",
"start:ci": "node src/index.js",
"deploy:preview": "node scripts/deploy-preview.js"
},
"jest": {
"collectCoverageFrom": [
"src/**/*.js",
"!src/index.js",
"!**/node_modules/**"
],
"coverageReporters": [
"text",
"lcov",
"clover",
"json"
],
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
}
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.0.0",
"express": "^4.17.3",
"jsonwebtoken": "^8.5.1",
"mongoose": "^6.2.9",
"morgan": "^1.10.0"
},
"devDependencies": {
"eslint": "^8.13.0",
"jest": "^29.0.0",
"mongodb-memory-server": "^8.4.0",
"node-mocks-http": "^1.11.0",
"nodemon": "^2.0.15",
"supertest": "^6.2.2"
}
}
Frontend Coverage Setup
File: frontend/package.json
{
"name": "task-manager-frontend",
"version": "1.0.0",
"private": true,
"dependencies": {
"@reduxjs/toolkit": "^1.8.1",
"axios": "^0.26.1",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-redux": "^7.2.8",
"react-router-dom": "^6.3.0",
"react-scripts": "5.0.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --transformIgnorePatterns \"node_modules/(?!@axios)/\"",
"test:coverage": "npm test -- --coverage --watchAll=false",
"eject": "react-scripts eject",
"lint": "eslint .",
"serve": "serve -s build -l 3000",
"cypress:open": "cypress open",
"cypress:run": "cypress run"
},
"jest": {
"collectCoverageFrom": [
"src/**/*.{js,jsx}",
"!src/index.js",
"!src/reportWebVitals.js",
"!src/serviceWorker.js"
],
"coverageReporters": [
"text",
"lcov",
"clover",
"json"
],
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
}
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.0.1",
"@testing-library/user-event": "^14.1.0",
"cypress": "^10.0.0",
"eslint": "^8.13.0",
"eslint-plugin-react": "^7.29.4",
"identity-obj-proxy": "^3.0.0",
"jest": "^27.5.1",
"serve": "^13.0.2"
}
}
Reviewing and Extending Our Testing Approach
Now that we've implemented a comprehensive testing suite for our application, let's review our approach and identify areas for improvement and extension.
Test Coverage Analysis
One key aspect of reviewing our testing strategy is analyzing the coverage of our tests. Coverage reports help us identify parts of our code that are not adequately tested.
From our coverage analysis, we can see that our utilities have the lowest coverage at 75%. This suggests that we should focus on adding more tests for utility functions.
Test Quality Assessment
Beyond mere coverage, we should assess the quality of our tests:
- Test Independence: Are our tests independent of each other? Tests should not rely on the state created by other tests.
- Test Readability: Are our tests easy to understand? Well-named tests with clear assertions improve maintainability.
- Test Reliability: Are our tests consistent, or do they sometimes fail for non-deterministic reasons?
- Edge Cases: Have we covered important edge cases, or only the happy path?
- Negative Testing: Have we tested failure scenarios and error handling?
Areas for Improvement
Based on our review, we've identified several areas where we can improve our testing approach:
Additional Unit Tests
- Add tests for utility functions to improve coverage
- Test edge cases and error handling in controllers
- Add tests for custom hooks in the frontend
Integration Tests
- Add tests for authentication flow
- Test pagination and filtering in API endpoints
- Add tests for error handling middleware
End-to-End Tests
- Add tests for user profile management
- Test task sorting functionality
- Add tests for offline mode and sync
Performance Tests
- Add load testing for API endpoints
- Measure and test frontend rendering performance
- Test database query performance
Extended Testing Strategies
For a production-grade application, we might want to consider additional testing strategies:
Security Testing
Add security tests to check for common vulnerabilities:
- SQL/NoSQL injection
- Cross-Site Scripting (XSS)
- Cross-Site Request Forgery (CSRF)
- Authentication and authorization bypasses
Accessibility Testing
Ensure the application is accessible to all users:
- Test keyboard navigation
- Verify screen reader compatibility
- Check color contrast and text size
- Validate ARIA attributes
Visual Regression Testing
Catch unintended visual changes:
- Take screenshot baselines of key UI components
- Compare screenshots after changes to detect regressions
- Test across different screen sizes
Conclusion
In this weekend project, we've implemented a comprehensive testing strategy for a full-stack JavaScript application. By following George Polya's problem-solving approach, we've:
- Understood the problem: Identified what parts of our application need testing and why.
- Devised a plan: Created a structured approach to testing different layers of the application.
- Executed the plan: Implemented unit, integration, and end-to-end tests, as well as set up CI/CD pipelines.
- Reviewed and extended: Analyzed our testing coverage and quality, and identified areas for improvement.
This approach has given us a solid foundation for ensuring the quality and reliability of our application. By continuing to improve and extend our testing strategy, we can build confidence in our code and deliver a better user experience.
Key Takeaways
- A comprehensive testing strategy covers all layers of the application
- Different testing types (unit, integration, E2E) serve different purposes
- Automated testing in CI pipelines ensures tests run consistently
- Test coverage metrics help identify areas needing more tests
- Testing is an ongoing process that evolves with the application
Further Resources
Additional Practice
To further solidify your understanding of testing, try these additional practice exercises:
Exercise 1: Add Tests for Advanced Filtering
Extend the task filtering functionality to include filtering by due date, and write appropriate tests:
- Implement filtering tasks by date ranges (e.g., today, this week, this month)
- Write unit tests for the filter functions
- Add E2E tests for the filtering UI
Exercise 2: Test Task Prioritization
Add a priority feature to tasks and thoroughly test it:
- Update the Task model to include a priority field (high, medium, low)
- Write tests for the updated model
- Implement priority filtering and sorting
- Test the priority functionality through the API
- Add E2E tests for priority management
Exercise 3: Implement and Test Task Labels/Tags
Add a label/tag system to tasks and test it:
- Create a Label model and establish a relationship with Task
- Write unit tests for the Label model
- Implement API endpoints for managing labels
- Test the label-related API endpoints
- Create UI components for label management
- Add E2E tests for the label functionality
Exercise 4: Set Up Visual Regression Testing
Implement visual regression testing to catch UI changes:
- Set up a visual testing tool like Percy or Storybook's visual testing
- Create baseline screenshots for key UI components
- Add visual testing to the CI pipeline
- Make a deliberate UI change and observe the test results