Skip to main content

πŸ§ͺ Weekend Project: Implement Comprehensive Testing for a Full-Stack App

All week you learned the pieces β€” unit tests, integration tests, end-to-end tests, coverage, and CI. This weekend you assemble them into one working safety net. You'll take an existing full-stack Task Manager (Express + MongoDB API, React front end) that currently has zero tests and wrap it in a complete testing pyramid: fast Jest unit tests for the pure logic, supertest integration tests that hit real routes against an isolated test database, one Playwright journey that drives the whole app like a user, a coverage gate that fails the build when tests thin out, and a GitHub Actions pipeline that runs the entire pyramid β€” with a real Mongo service container β€” on every single push. When you finish, a red X on a pull request will mean "do not merge," automatically, forever.

Week 12 · Weekend Project · Testing & CI/CD Capstone

🎯 Learning Objectives

By completing this project, you will be able to:

  • Design a testing strategy using the pyramid β€” many unit tests, fewer integration tests, a handful of end-to-end journeys β€” and explain what each layer buys you
  • Configure Jest for a Node project and write fast unit tests for pure functions and services with no I/O
  • Write integration tests for real API endpoints with supertest, run against a throwaway test database that is isolated and reset between tests
  • Automate one critical end-to-end flow with Playwright (register β†’ log in β†’ create a task β†’ complete it) that exercises front end, API, and database together
  • Enforce coverage thresholds so a drop in tested code fails the build, not just a warning
  • Wire a GitHub Actions CI workflow that installs, lints, and runs the full pyramid on every push, using a Mongo service container for the database-backed layers

Estimated Time: 6–9 hours across the weekend

Project: A full-stack app whose repo has a green, enforced test suite at three layers plus a CI pipeline that gates every merge.

In This Project

The Goal

You already have a working full-stack Task Manager: an Express + Mongoose API with user auth and task CRUD, and a React front end that talks to it. It runs. It demos fine. And it has a dangerous secret β€” nothing proves it still works after the next change. Every edit is a leap of faith, every deploy a held breath. This weekend you remove the faith and replace it with evidence.

By Sunday night, three commands tell the whole truth about your app. npm run test:unit checks the pure logic in milliseconds. npm run test:integration spins up a private database, fires real HTTP requests at your routes, and asserts on the responses. npm run test:e2e opens a real browser and walks through signing up and completing a task. And you never have to remember to run them: a GitHub Actions workflow runs all three on every push, so a broken change turns a pull request red before a human ever reviews it.

This is a capstone. You're not re-learning a single Jest assertion β€” you're learning how the layers fit together into a strategy, how to keep a database-backed suite isolated and repeatable, and how to make the machine run it all for you. That trustworthy, automated safety net is what separates a hobby project from software a team can maintain.

πŸ“– Why "it works on my machine" is not enough

Manual testing does not scale: it is slow, it is inconsistent, and it quietly rots as the app grows. Nobody re-clicks every screen before every commit, so regressions slip in. An automated suite runs the same checks the same way every time, in seconds, on every change β€” and a CI pipeline runs them on a clean machine that has none of your local shortcuts, catching the "works here, breaks there" class of bug before it reaches users.

Prerequisites

This project ties together everything Week 12 taught, applied to an app you built earlier in the course. Before you start, make sure you're comfortable with:

  • The app under test β€” a full-stack Task Manager: an Express + Mongoose API (users, auth with JWT, task CRUD) and a React client. Any equivalent app you've built works; the techniques are what matter.
  • Jest fundamentals β€” describe/test, expect matchers, and beforeEach/afterAll hooks (this week's unit-testing lessons)
  • supertest β€” firing HTTP requests at an Express app in memory, without a running server
  • An end-to-end runner β€” Playwright (used here) or Cypress; both drive a real browser
  • Test doubles β€” mocks, stubs, and spies, and the judgment of when not to mock
  • GitHub & YAML basics β€” pushing to a repo and reading a workflow file (this week's CI/CD lessons)

You'll need Node.js 18 or newer (node --version) and a GitHub repository for the app. You do not need a local MongoDB install for the tests: the integration layer uses an in-memory Mongo, and CI uses a service container.

⚠️ Refactor for testability first, if you must

Some code resists testing. If your app.js also calls app.listen(), split them: app.js builds and returns the Express app, and a separate server.js imports it and listens. That one change lets supertest import the app and hit its routes with no live server. Testability is a design property β€” a hard-to-test module is usually a poorly-factored one.

The Testing Pyramid Strategy

Before writing a single test, decide how many of each kind to write. The classic guide is the testing pyramid: a wide base of cheap, fast unit tests; a narrower band of integration tests; and a tiny cap of slow, expensive end-to-end tests. The shape is a rule of thumb about cost versus confidence.

The testing pyramid: many fast unit tests at the base, fewer integration tests in the middle, a few slow end-to-end tests at the top E2E Integration Unit few Β· slow many Β· fast More confidence per test as you climb β€” more cost, too.
Lean on the fast base; use the slow, high-confidence tip sparingly for the flows that truly matter.
LayerWhat it testsSpeedHow many
UnitOne pure function or service in isolation, no I/OMillisecondsLots β€” cover branches and edge cases
IntegrationA route + controller + model against a real (test) DBSecondsSome β€” one per endpoint & key failure
End-to-EndThe whole app through a browser, like a userTens of secondsA few β€” only critical journeys

πŸ’‘ Why not just write E2E tests for everything?

End-to-end tests give the most confidence β€” they prove the real thing works β€” so it is tempting to write only those. Resist it. They are slow (a suite of hundreds takes an hour), flaky (a network blip or animation timing fails a green build), and vague (a failure says "checkout broke," not "which function"). A unit test failing points at one line. Use the pyramid: prove logic cheaply at the bottom, prove wiring in the middle, and reserve the expensive top for the handful of journeys a user would riot over if they broke.

Required Features Checklist

These are the non-negotiables for a passing project. Tick each off as you go.

βœ… Must-have deliverables

  • ☐ Jest configured for the Node/API project with a sensible jest.config.js and npm scripts
  • ☐ Unit tests covering pure logic and services (filtering, sorting, validation, stats) with branch and edge-case coverage
  • ☐ Integration tests for the API endpoints using supertest, hitting real routes
  • ☐ Test-database isolation β€” an in-memory (or dedicated) test DB, connected before the suite and reset between every test
  • ☐ At least one end-to-end flow with Playwright (or Cypress) covering a critical journey start to finish
  • ☐ Coverage thresholds enforced β€” the build fails below the configured percentage, not just warns
  • ☐ A GitHub Actions CI workflow that runs the whole pyramid on every push and pull request
  • ☐ A Mongo service container in CI so the database-backed layers have a real database to talk to
  • ☐ Tests are independent β€” any test can run alone or in any order and still pass

Project & Test Structure

Keep tests discoverable and grouped by layer. A common, clean layout puts each pyramid tier in its own folder, with config and the CI workflow at the root. This is the shape you'll build toward:

task-manager/
β”œβ”€β”€ package.json
β”œβ”€β”€ jest.config.js              <-- Jest config + coverage thresholds
β”œβ”€β”€ playwright.config.js        <-- E2E runner config
β”œβ”€β”€ .github/
β”‚   └── workflows/
β”‚       └── ci.yml              <-- the CI pipeline
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ app.js                  <-- builds the Express app (no listen)
β”‚   β”œβ”€β”€ server.js               <-- connects DB, then app.listen()
β”‚   β”œβ”€β”€ models/Task.js
β”‚   β”œβ”€β”€ controllers/taskController.js
β”‚   β”œβ”€β”€ routes/taskRoutes.js
β”‚   └── services/taskFilters.js <-- pure logic β€” the easiest thing to unit-test
└── tests/
    β”œβ”€β”€ setup/
    β”‚   └── testDb.js           <-- connect / clear / teardown helpers
    β”œβ”€β”€ unit/
    β”‚   └── taskFilters.test.js <-- fast, no I/O
    β”œβ”€β”€ integration/
    β”‚   └── tasks.test.js       <-- supertest against the test DB
    └── e2e/
        └── task-journey.spec.js<-- Playwright browser flow

πŸ’‘ Group by layer, name by convention

Jest finds any file ending in .test.js (or .spec.js) by default, but folders let you run one layer at a time β€” jest tests/unit for the fast loop, the whole thing in CI. Separate folders also let each layer have its own setup: unit tests need none, integration tests need a database, E2E needs a running app. Mixing them makes every test pay for the slowest one's setup.

Stage 1 β€” Set Up Jest & Config

Install the toolchain, then give Jest a config it can grow into. For the API you need Jest itself, supertest for HTTP, and mongodb-memory-server to spin a real MongoDB in RAM for the integration layer.

# From the project root
npm install --save-dev jest supertest mongodb-memory-server

# The E2E runner (installs its own browsers)
npm install --save-dev @playwright/test
npx playwright install --with-deps chromium

Add scripts so each layer runs on its own and all-together in CI. Splitting them keeps your local edit-loop fast (run just the unit tests) while CI runs everything.

{
  "scripts": {
    "test": "jest",
    "test:unit": "jest tests/unit",
    "test:integration": "jest tests/integration --runInBand",
    "test:coverage": "jest --coverage",
    "test:e2e": "playwright test",
    "lint": "eslint ."
  }
}

Now the Jest config. The node test environment is right for an API (no browser DOM needed). collectCoverageFrom tells Jest which source files count toward coverage β€” critically including files with no tests yet, so an untested module drags the number down honestly instead of hiding.

// jest.config.js
module.exports = {
  // Node environment β€” no jsdom; this is a server-side API.
  testEnvironment: 'node',

  // Only Jest layers live under tests/unit and tests/integration.
  // Playwright's *.spec.js under tests/e2e is ignored by Jest.
  testMatch: ['**/tests/unit/**/*.test.js', '**/tests/integration/**/*.test.js'],

  // Measure coverage across ALL source β€” even files with no test yet,
  // so gaps show up instead of silently scoring 100%.
  collectCoverageFrom: [
    'src/**/*.js',
    '!src/server.js',      // the listen wrapper β€” nothing to unit test
  ],

  // Fail fast if a resource (like a DB connection) is left open.
  detectOpenHandles: true,

  // Coverage thresholds are added in Stage 5 β€” start without a gate.
};

⚠️ Why --runInBand for integration tests

Jest runs test files in parallel by default β€” great for fast, isolated unit tests. But database-backed tests that share one test database can collide when they run at once, one test's cleanup wiping another's data mid-run. --runInBand forces them to run one at a time, serially, in a single process. It is slower, but for a shared-DB integration suite it is the reliable choice. (The alternative β€” a separate database per worker β€” is a stretch goal.)

Stage 2 β€” Unit Tests for Pure Logic

Start at the base of the pyramid, because it is the cheapest and highest-value place to begin. A unit test checks one small piece of logic in complete isolation β€” no database, no network, no framework. The ideal target is a pure function: same input, same output, no side effects. If your app doesn't have much pure logic yet, that itself is a smell β€” extract the decision-making out of your controllers into testable functions.

Here is exactly that kind of extraction: the filtering, sorting, and stats logic pulled out of the task controller into a plain module. No req, no res, no Mongoose β€” just data in, data out.

// src/services/taskFilters.js β€” pure logic, trivially testable

// Keep only tasks matching a completion status. 'all' returns everything.
function filterByStatus(tasks, status) {
  if (status === 'all') return [...tasks];
  const wantCompleted = status === 'completed';
  return tasks.filter((t) => t.completed === wantCompleted);
}

// Sort by due date ascending; tasks with no due date sink to the bottom.
function sortByDueDate(tasks) {
  return [...tasks].sort((a, b) => {
    if (!a.dueDate) return 1;
    if (!b.dueDate) return -1;
    return new Date(a.dueDate) - new Date(b.dueDate);
  });
}

// Summarize a task list into counts a dashboard can show.
function computeStats(tasks) {
  const total = tasks.length;
  const completed = tasks.filter((t) => t.completed).length;
  return {
    total,
    completed,
    active: total - completed,
    percentComplete: total === 0 ? 0 : Math.round((completed / total) * 100),
  };
}

module.exports = { filterByStatus, sortByDueDate, computeStats };

Now the tests. Notice the pattern: cover the happy path, the edge cases (empty list, missing fields), and the boundaries (the total === 0 branch that would otherwise divide by zero). Good unit tests chase the branches, not just the obvious case.

// tests/unit/taskFilters.test.js
const { filterByStatus, sortByDueDate, computeStats } = require('../../src/services/taskFilters');

describe('filterByStatus', () => {
  const tasks = [
    { title: 'A', completed: false },
    { title: 'B', completed: true },
    { title: 'C', completed: false },
  ];

  test('returns only active tasks', () => {
    const result = filterByStatus(tasks, 'active');
    expect(result).toHaveLength(2);
    expect(result.every((t) => t.completed === false)).toBe(true);
  });

  test('returns only completed tasks', () => {
    expect(filterByStatus(tasks, 'completed')).toEqual([{ title: 'B', completed: true }]);
  });

  test("returns everything for 'all'", () => {
    expect(filterByStatus(tasks, 'all')).toHaveLength(3);
  });

  test('does not mutate the input array', () => {
    filterByStatus(tasks, 'active');
    expect(tasks).toHaveLength(3);   // original untouched
  });
});

describe('sortByDueDate', () => {
  test('orders by ascending due date and sinks undated tasks last', () => {
    const input = [
      { title: 'later', dueDate: '2025-12-31' },
      { title: 'no date' },
      { title: 'sooner', dueDate: '2025-01-01' },
    ];
    const titles = sortByDueDate(input).map((t) => t.title);
    expect(titles).toEqual(['sooner', 'later', 'no date']);
  });
});

describe('computeStats', () => {
  test('summarizes a mixed list', () => {
    const tasks = [
      { completed: true }, { completed: false }, { completed: true }, { completed: false },
    ];
    expect(computeStats(tasks)).toEqual({
      total: 4, completed: 2, active: 2, percentComplete: 50,
    });
  });

  test('handles the empty list without dividing by zero', () => {
    expect(computeStats([])).toEqual({
      total: 0, completed: 0, active: 0, percentComplete: 0,
    });
  });
});

Output

PASS  tests/unit/taskFilters.test.js
  filterByStatus
    βœ“ returns only active tasks (2 ms)
    βœ“ returns only completed tasks
    βœ“ returns everything for 'all'
    βœ“ does not mutate the input array
  sortByDueDate
    βœ“ orders by ascending due date and sinks undated tasks last
  computeStats
    βœ“ summarizes a mixed list
    βœ“ handles the empty list without dividing by zero

Tests: 7 passed, 7 total   Time: 0.4 s

πŸ“– The "does not mutate" test is the professional touch

Two of the functions above copy their input ([...tasks]) before working on it. The test that asserts the original array is unchanged looks trivial, but it locks in a real contract: callers can trust these helpers not to corrupt their data. That is the mindset unit tests train β€” you don't just check the output, you pin down the behavior, including the promises about what a function must not do.

Stage 3 β€” Integration Tests & Test-DB Isolation

Unit tests prove your logic is correct in a vacuum. Integration tests prove the pieces are wired together: that a real HTTP request travels through your router, controller, and model, touches a database, and comes back with the right status code and body. The tool is supertest, which drives your Express app in memory β€” no port, no running server.

The hard part isn't the requests; it's the database. Tests must be isolated (one test's data never leaks into another's) and repeatable (the same result every run, on any machine). The recipe: spin up a fresh in-memory MongoDB before the suite, clear every collection before each test, and tear it all down at the end. This sequence is the beating heart of reliable database testing:

sequenceDiagram participant J as Jest participant M as Mongo Memory Server participant DB as Test Database J->>M: Start an in-memory Mongo before all tests M-->>J: Return a fresh connection string J->>DB: Connect Mongoose to the test database J->>DB: Clear every collection before each test J->>DB: Run one test against a clean slate J->>DB: Disconnect and stop the server after all tests

Put that lifecycle in one reusable helper so every integration file gets isolation for free:

// tests/setup/testDb.js
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');

let mongoServer;

// Start a throwaway in-memory Mongo and connect Mongoose to it.
async function connect() {
  mongoServer = await MongoMemoryServer.create();
  await mongoose.connect(mongoServer.getUri());
}

// Wipe every collection β€” call before each test for a clean slate.
async function clear() {
  const { collections } = mongoose.connection;
  for (const key of Object.keys(collections)) {
    await collections[key].deleteMany({});
  }
}

// Disconnect and shut the server down so Jest can exit cleanly.
async function disconnect() {
  await mongoose.connection.dropDatabase();
  await mongoose.disconnect();
  await mongoServer.stop();
}

module.exports = { connect, clear, disconnect };

Now the integration test itself. It imports the app (not the server), wraps it in supertest, and asserts on real responses. Because auth guards the routes, we mint a JWT the same way the app does and send it in the Authorization header.

// tests/integration/tasks.test.js
const request = require('supertest');
const jwt = require('jsonwebtoken');
const app = require('../../src/app');           // the app, NOT server.js
const Task = require('../../src/models/Task');
const { connect, clear, disconnect } = require('../setup/testDb');

const userId = '507f1f77bcf86cd799439011';
const token = jwt.sign({ userId }, process.env.JWT_SECRET || 'test-secret', { expiresIn: '1h' });
const auth = (req) => req.set('Authorization', `Bearer ${token}`);

beforeAll(connect);       // one in-memory DB for the whole file
afterEach(clear);         // reset between every test β€” the isolation guarantee
afterAll(disconnect);     // clean shutdown so Jest exits

describe('POST /api/tasks', () => {
  test('creates a task and returns 201 with the saved document', async () => {
    const res = await auth(request(app).post('/api/tasks'))
      .send({ title: 'Write tests', description: 'Cover the API' });

    expect(res.status).toBe(201);
    expect(res.body.title).toBe('Write tests');
    expect(res.body._id).toBeDefined();

    // Prove it really persisted β€” read it straight from the DB.
    const inDb = await Task.findById(res.body._id);
    expect(inDb).not.toBeNull();
  });

  test('rejects a task with no title and returns 400', async () => {
    const res = await auth(request(app).post('/api/tasks')).send({ description: 'no title' });
    expect(res.status).toBe(400);
  });
});

describe('GET /api/tasks', () => {
  test('returns only the current user’s tasks', async () => {
    // Seed two of ours and one belonging to someone else.
    await Task.create([
      { title: 'Mine 1', user: userId },
      { title: 'Mine 2', user: userId },
      { title: 'Theirs', user: '507f1f77bcf86cd799439099' },
    ]);

    const res = await auth(request(app).get('/api/tasks'));
    expect(res.status).toBe(200);
    expect(res.body).toHaveLength(2);           // the other user's task is excluded
  });

  test('starts empty on a clean database', async () => {
    // Thanks to afterEach(clear), this test sees NONE of the data above.
    const res = await auth(request(app).get('/api/tasks'));
    expect(res.body).toEqual([]);
  });
});

describe('auth guard', () => {
  test('returns 401 without a token', async () => {
    const res = await request(app).get('/api/tasks');   // no auth() wrapper
    expect(res.status).toBe(401);
  });
});

βœ… The two GET tests prove isolation works

The first GET test seeds three tasks; the second asserts the database is empty. Both pass β€” because afterEach(clear) wipes the collections between them. That is isolation in action: tests share no state, so their order never matters and one failure can't cascade. If you ever see a test that passes alone but fails in the suite, a leaked-state bug like this is almost always the culprit.

πŸ’‘ Mock the far edges, keep the middle real

Integration tests deliberately use a real database because the point is to test the wiring, and a mocked model would prove nothing about your queries. But you should still mock the truly external and non-deterministic β€” a payment gateway, an email sender, a third-party API β€” so tests stay fast, free, and repeatable. The rule: keep real the thing you're testing; stub the things you're not.

Stage 4 β€” One End-to-End Journey

At the tip of the pyramid sits the end-to-end test: a real browser, driven by code, walking through your live app exactly as a user would. It is the only test that proves the front end, the API, and the database all work together. It is also the slowest and most brittle, so you write few of them β€” just the critical journeys. For a Task Manager, the crown jewel is: a new user signs up, logs in, creates a task, and marks it done. If that works, the app's reason to exist works.

We'll use Playwright. First, a small config pointing at the running app and starting it automatically:

// playwright.config.js
const { defineConfig } = require('@playwright/test');

module.exports = defineConfig({
  testDir: './tests/e2e',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',        // capture a trace when a test retries
  },
  // Playwright boots the app for you, then tears it down when tests finish.
  webServer: {
    command: 'npm run start:test',  // starts API + client against a test DB
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120000,
  },
});

Now the journey. Note the reliance on data-testid attributes: selecting by a stable test id rather than by CSS class or visible text keeps the test from shattering every time a designer renames a button or restyles the page.

// tests/e2e/task-journey.spec.js
const { test, expect } = require('@playwright/test');

test('a new user can register, log in, create and complete a task', async ({ page }) => {
  // Unique email per run so the test can repeat without collisions.
  const email = `e2e_${Date.now()}@example.com`;
  const password = 'testPassword123';

  // 1. Register
  await page.goto('/register');
  await page.getByTestId('name-input').fill('E2E User');
  await page.getByTestId('email-input').fill(email);
  await page.getByTestId('password-input').fill(password);
  await page.getByTestId('register-button').click();

  // 2. Log in
  await expect(page).toHaveURL(/\/login/);
  await page.getByTestId('email-input').fill(email);
  await page.getByTestId('password-input').fill(password);
  await page.getByTestId('login-button').click();

  // 3. Land on the dashboard
  await expect(page).toHaveURL(/\/dashboard/);

  // 4. Create a task
  await page.getByTestId('add-task-button').click();
  await page.getByTestId('task-title-input').fill('Ship the tests');
  await page.getByTestId('save-task-button').click();

  // The task shows up in the list.
  const taskItem = page.getByTestId('task-item').filter({ hasText: 'Ship the tests' });
  await expect(taskItem).toBeVisible();

  // 5. Complete it
  await taskItem.getByTestId('complete-button').click();
  await expect(taskItem).toHaveClass(/completed/);
});

⚠️ Select by test id, not by looks

The single biggest cause of flaky, high-maintenance E2E tests is selecting elements by their appearance β€” .btn-primary, "Save", the third row. Those change constantly. Add explicit data-testid attributes to the elements your tests touch and select by those. The markup gains a stable contract for testing, and a purely visual redesign no longer breaks a single test. It is the small discipline that keeps the top of the pyramid maintainable.

πŸ“– Playwright or Cypress?

Both drive real browsers and are excellent; either satisfies this project. Playwright (used here) runs multiple browser engines, auto-waits for elements, and shines in CI. Cypress has a famously friendly interactive runner and time-travel debugging. The concepts transfer directly: visit a page, act on stable selectors, assert on the result. Pick one, write the one journey, and move on β€” the point of this layer is coverage of the flow, not mastery of a tool.

Stage 5 β€” The Coverage Gate

Coverage measures how much of your code the tests actually execute β€” by line, by branch, by function. It is reported as a percentage, and it is a useful flashlight: it shows you the dark corners no test ever visits. The trick is to turn that flashlight into a gate β€” a threshold the build must clear, so coverage can only hold or climb, never quietly erode.

Add coverageThreshold to the Jest config from Stage 1. Below any number here, jest --coverage exits non-zero β€” which, in CI, fails the build:

// jest.config.js β€” add this block
module.exports = {
  // ...everything from Stage 1...

  coverageThreshold: {
    global: {
      statements: 80,
      branches: 75,
      functions: 80,
      lines: 80,
    },
  },
};

Run it locally and read the table Jest prints. The "% Branch" column and the "Uncovered Line #s" column are the ones that teach you something β€” they point at the exact conditionals your tests never took:

npm run test:coverage

Output

--------------------|---------|----------|---------|---------|-------------------
File                | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
--------------------|---------|----------|---------|---------|-------------------
All files           |   86.2  |   78.9   |   88.0  |   86.2  |
 services           |   100   |   100    |   100   |   100   |
  taskFilters.js    |   100   |   100    |   100   |   100   |
 controllers        |   79.1  |   70.0   |   81.2  |   79.1  |
  taskController.js |   79.1  |   70.0   |   81.2  |   79.1  | 44-47, 88
--------------------|---------|----------|---------|---------|-------------------
Test Suites: 3 passed, 3 total
Tests:       14 passed, 14 total

⚠️ Coverage is a floor, not a trophy

100% coverage does not mean bug-free. A test can execute a line without asserting anything meaningful about it β€” coverage counts that the code ran, not that it was checked. Chasing a perfect number invites hollow tests written to color in the report. Set a pragmatic threshold (80% is a common, honest target), aim it at your logic rather than boilerplate, and treat the uncovered-lines list as a to-do list of real gaps β€” not the score as a goal in itself.

Stage 6 β€” Wire GitHub Actions CI

Everything so far runs on your machine when you remember to run it. The last stage removes both caveats: a CI pipeline that runs the entire pyramid automatically, on a clean machine, on every push and pull request. If any layer goes red, the pull request is marked failing before a human reviews it.

graph LR Push["git push or PR"] --> Install["npm ci"] Install --> Lint["Lint"] Lint --> Unit["Unit tests"] Unit --> Int["Integration tests
Mongo service container"] Int --> E2E["E2E tests
Playwright"] E2E --> Cov["Coverage gate"] Cov --> Pass["Green check
merge allowed"] Unit -.->|fail| Stop["Red X
merge blocked"] Int -.->|fail| Stop E2E -.->|fail| Stop Cov -.->|below threshold| Stop

The key CI concept for a database-backed app is the service container. GitHub Actions can start a Docker container β€” here, MongoDB β€” alongside your job and expose it on localhost. Your integration and E2E steps talk to that real database, and it vanishes when the job ends. Here is the whole workflow:

# .github/workflows/ci.yml
name: CI

# Run on every push and every pull request to main.
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    # A real MongoDB, started by GitHub Actions and reachable on localhost.
    services:
      mongo:
        image: mongo:7
        ports:
          - 27017:27017
        # Wait until Mongo answers before the steps run.
        options: >-
          --health-cmd "mongosh --eval 'db.runCommand({ ping: 1 })'"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    env:
      MONGODB_URI: mongodb://localhost:27017/task_manager_test
      JWT_SECRET: ci-test-secret
      NODE_ENV: test

    steps:
      - name: Check out the code
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm          # cache ~/.npm between runs for speed

      - name: Install dependencies
        run: npm ci           # clean, lockfile-exact install

      - name: Lint
        run: npm run lint

      - name: Unit tests
        run: npm run test:unit

      - name: Integration tests (against the Mongo service)
        run: npm run test:integration

      - name: Coverage gate
        run: npm run test:coverage

      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium

      - name: End-to-end tests
        run: npm run test:e2e

      - name: Upload coverage report
        if: always()          # keep the report even when a step failed
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage/

πŸ“– Read the pipeline top to bottom

The steps run in order and the job stops at the first failure β€” which is deliberate. Lint runs before tests because a syntax slip should fail in one second, not after a five-minute E2E run. The layers then run cheapest first: unit, integration, coverage gate, and finally the slow E2E. Fast feedback is the whole point of CI β€” the sooner a broken change goes red, the cheaper it is to fix. Only upload-artifact uses if: always(), so you still get the coverage report to inspect even on a failed run.

βœ… See it work: open a pull request

Commit the workflow, push a branch, and open a pull request. GitHub runs the pipeline and stamps the PR with a status β€” a green check or a red X β€” right where reviewers look. Now break something on purpose (delete an assertion, return the wrong status code) and push again: the matching layer goes red, and the PR is flagged un-mergeable. That red X, appearing without anyone remembering to run anything, is the entire weekend's payoff.

Stretch Goals

Required build done with time to spare? Push the pipeline toward production quality. None of these are needed to pass the rubric β€” pick what excites you.

  • 🧱 A test matrix β€” run the whole job across several Node versions at once with strategy.matrix.node: [18, 20, 22], catching version-specific breakage before your users do
  • ⚑ Test sharding β€” split a large suite across parallel jobs (jest --shard or Playwright's --shard) so wall-clock time drops as the suite grows
  • πŸ… A coverage badge β€” push the report to Codecov or Coveralls and drop the badge in your README so anyone can see the number at a glance
  • πŸ”’ Branch protection β€” in GitHub repo settings, require the CI check to pass before main can be merged, turning the red X from advice into an enforced rule
  • πŸ—„οΈ Per-worker databases β€” give each Jest worker its own database name so integration tests can drop --runInBand and run in parallel
  • πŸ–ΌοΈ Playwright traces & videos as artifacts β€” upload the trace/video on failure so you can replay a broken E2E run frame by frame
  • 🌐 Frontend component tests β€” add React Testing Library tests for a couple of key components, filling in the client side of the pyramid

Matrix starter

A matrix is one small block that fans the job out across versions β€” every combination runs as its own parallel job:

# Inside the job, above "steps:"
strategy:
  fail-fast: false          # let every version finish, even if one fails
  matrix:
    node: [18, 20, 22]

steps:
  - uses: actions/checkout@v4
  - uses: actions/setup-node@v4
    with:
      node-version: ${{ matrix.node }}
      cache: npm
  # ...the rest of the steps run once per Node version...

Self-Check Rubric

Before you call this done, grade yourself against the rubric. Aim to answer "yes" to everything in the first two columns β€” the stretch column is bonus.

AreaMeets expectations (required)Exceeds (stretch)
Jest setup Jest configured with node environment, layered test scripts, and collectCoverageFrom across all source Shared setup files; module aliases; parallel-safe config
Unit tests Pure logic and services tested with happy-path, edge, and branch cases; no I/O Frontend component tests with React Testing Library
Integration tests supertest hits real endpoints; success and failure status codes asserted Auth flows, pagination, and error middleware all covered
Test-DB isolation Test DB connected before the suite, collections cleared before each test, torn down after; tests order-independent A separate database per Jest worker; drops --runInBand
End-to-end At least one critical journey passes in a real browser, selecting by stable data-testid Traces/videos uploaded on failure; multiple journeys
Coverage gate coverageThreshold set so the build fails below target, not just warns Coverage badge published; per-file thresholds on core logic
CI pipeline GitHub Actions runs lint + the full pyramid on every push/PR, with a Mongo service container Node version matrix, sharding, and enforced branch protection

πŸ§ͺ Final verification checklist

  • ☐ npm run test:unit passes in well under a second
  • ☐ npm run test:integration passes and each test starts from an empty database
  • ☐ Any single integration test passes when run alone and in the full suite (order independence)
  • ☐ npm run test:e2e completes the register β†’ login β†’ create β†’ complete journey
  • ☐ Lowering a threshold, or deleting a test, makes npm run test:coverage fail
  • ☐ Pushing a branch runs the CI pipeline and shows a status on the pull request
  • ☐ Deliberately breaking a route turns the matching CI layer red and blocks the merge

Summary

πŸŽ‰ What You Built

  • A complete testing pyramid over a real full-stack app β€” a wide base of fast Jest unit tests, a middle band of supertest integration tests, and one Playwright end-to-end journey at the tip
  • Isolated, repeatable database testing: an in-memory Mongo connected before the suite and reset before every test, so tests never leak state and run in any order
  • An enforced coverage gate that fails the build when tested code thins out, turning a metric into a guardrail
  • A GitHub Actions CI pipeline that lints and runs the whole pyramid on every push and pull request, using a Mongo service container for the database-backed layers
  • Fast-feedback ordering β€” lint and cheap tests first, slow E2E last, stop at the first failure β€” so broken changes go red in seconds

This capstone is the difference between code that happens to work and code you can prove works β€” and keep proving, automatically, forever. The strategy you practiced is the everyday grammar of professional teams: test the logic cheaply at the bottom, test the wiring in the middle, guard the crown-jewel journeys at the top, gate coverage so it can't slide, and let CI run all of it on a clean machine so no regression reaches main unnoticed. You didn't just write tests this weekend β€” you built a safety net that lets you change the app tomorrow without fear.

πŸ“š Additional Resources

πŸš€ What's Next?

Your app is tested and every merge is gated β€” it is finally ready to leave your laptop. Week 13 opens the Cloud & Deployment module, and the first lesson, AWS, Azure, and GCP compared, surveys the big three cloud platforms so you can choose where this well-tested app will actually live. The green CI pipeline you just built becomes the front half of a full deployment pipeline: test, then ship β€” with confidence.

πŸŽ‰ You finished Week 12!

You wrapped a full-stack app in a real safety net and automated it end to end. Push it to GitHub, watch the checks go green, and put "CI-gated test suite" on your rΓ©sumΓ© β€” you earned it.