Comprehensive Testing for a Full-Stack Application

Weekend Project for JavaScript Full Stack Developer Course - Week 12

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:

  1. Understand the problem: What needs to be tested and why?
  2. Devise a plan: How will we organize our testing approach?
  3. Execute the plan: Implement the tests step by step
  4. 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:

flowchart TD A[Full-Stack Application Testing] A --> B[Frontend Testing] A --> C[Backend Testing] A --> D[Integration Testing] A --> E[E2E Testing] B --> B1[UI Components] B --> B2[State Management] B --> B3[API Interactions] C --> C1[API Endpoints] C --> C2[Business Logic] C --> C3[Database Operations] D --> D1[Frontend-Backend Integration] D --> D2[Service Integrations] E --> E1[User Flows] E --> E2[Performance] E --> E3[Security]

What We Need to Test

Project Requirements

The Sample Application

For this project, we'll work with a simple "Task Manager" application with the following features:

The application uses the following technology stack:

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

  1. Set up the testing environment and tools
  2. Implement frontend unit tests
  3. Implement backend unit tests
  4. Create integration tests for API endpoints
  5. Develop end-to-end tests
  6. Configure test coverage reporting
  7. Set up a CI pipeline for automatic testing
  8. Document the testing approach
gantt title Testing Implementation Timeline dateFormat YYYY-MM-DD axisFormat %d section Setup Environment Setup :a1, 2025-05-05, 1d section Frontend Component Tests :a2, after a1, 1d Redux Tests :a3, after a2, 1d section Backend Model Tests :a4, after a1, 1d Controller Tests :a5, after a4, 1d section Integration API Tests :a6, after a5, 1d section E2E User Flow Tests :a7, after a6, 1d section Finalization CI Pipeline :a8, after a7, 1d Documentation :a9, after a8, 1d

Testing Tools We'll Use

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.

pie title "Test Coverage by Component Type" "Models" : 95 "Controllers" : 90 "Services" : 85 "Components" : 88 "Reducers" : 92 "Utilities" : 75

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:

Areas for Improvement

Based on our review, we've identified several areas where we can improve our testing approach:

Additional Unit Tests

Integration Tests

End-to-End Tests

Performance Tests

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:

Accessibility Testing

Ensure the application is accessible to all users:

Visual Regression Testing

Catch unintended visual changes:

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:

  1. Understood the problem: Identified what parts of our application need testing and why.
  2. Devised a plan: Created a structured approach to testing different layers of the application.
  3. Executed the plan: Implemented unit, integration, and end-to-end tests, as well as set up CI/CD pipelines.
  4. 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

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:

Exercise 2: Test Task Prioritization

Add a priority feature to tasks and thoroughly test it:

Exercise 3: Implement and Test Task Labels/Tags

Add a label/tag system to tasks and test it:

Exercise 4: Set Up Visual Regression Testing

Implement visual regression testing to catch UI changes: