Skip to main content

๐Ÿงช Automated Testing Pipelines

You can write a workflow. You can write tests. This lesson fuses them into a real quality gate: a pipeline that runs the right tests in the right order, refuses to ship code that drops coverage, and blocks any pull request that turns a check red. Think of it as a quality-control conveyor belt for your code.

Week 12 · Day 5 (Friday: Continuous Integration) · Lecture 3

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Layer unit, integration, and end-to-end tests according to the test pyramid
  • Order pipeline stages to fail fast โ€” cheapest and most likely failures first
  • Enforce a coverage gate that blocks changes which under-test new code
  • Speed up long suites with parallel sharding across runners
  • Wire branch protection so a PR cannot merge until every required check passes
  • Diagnose and defuse flaky tests before they erode trust in the pipeline

Estimated Time: 70 minutes

Practice: Assemble a multi-stage test pipeline with a coverage threshold and a database service.

In This Lesson

The Conveyor Belt

Picture a factory's quality-control line. A product rolls down the belt and passes inspection stations: a quick visual check, then a weight test, then a full functional test, and finally a random deep audit before it ships. Cheap checks come first because they catch the most defects fastest; the expensive audit only happens on units that already look good.

An automated testing pipeline is that belt for code. A commit enters, moves through linting, unit tests, integration tests, and end-to-end tests, and only reaches "deploy" if every station approves. The point isn't just to run tests โ€” it's to run them in a deliberate order that gives developers the fastest possible honest answer to one question: is this change safe?

graph TD A["Commit or pull request"] --> B["Lint and type-check"] B --> C["Unit tests"] C --> D["Integration tests"] D --> E["End to end tests"] E --> F{"All stations pass?"} F -->|Yes| G["Deploy to staging"] F -->|No| H["Notify author and stop"] G --> I["Smoke test staging"] I --> J{"Healthy?"} J -->|Yes| K["Promote to production"] J -->|No| H

The Test Pyramid

Not all tests are equal. The test pyramid โ€” a concept from Mike Cohn โ€” guides how many of each kind you should have. Many fast, cheap unit tests at the base; fewer integration tests in the middle; a small number of slow, expensive end-to-end tests at the top.

The test pyramid: many unit tests, fewer integration tests, few end-to-end tests E2E ~10% ยท slow, high-value Integration ~20% ยท modules working together Unit ~70% ยท fast, isolated, run constantly
Push the bulk of your coverage down to fast unit tests; reserve slow end-to-end tests for the critical user journeys.
LayerVerifiesSpeedTools
UnitOne function/component in isolationMillisecondsJest, Vitest
IntegrationModules working together (e.g. API + DB)SecondsSupertest, Vitest
End-to-endA real user flow through the whole appMinutesPlaywright, Cypress

โš ๏ธ Avoid the "ice cream cone"

The inverted pyramid โ€” mostly slow end-to-end tests, few unit tests โ€” is a classic anti-pattern. It produces pipelines that take 40 minutes, fail flakily, and are miserable to debug. When a test fails, a unit test points at one function; an E2E test just says "the checkout page broke somewhere." Keep the base wide.

Stages & Fail Fast

Order is a design decision. A "fail fast" pipeline runs stages roughly in order of speed and likelihood of failure, so the most common mistakes surface in seconds rather than after a ten-minute wait.

  1. Lint & type-check โ€” seconds. Catches syntax slips and type errors instantly.
  2. Unit tests โ€” seconds. The bulk of your correctness coverage, and quick.
  3. Integration tests โ€” seconds to a minute. Needs a database or services.
  4. End-to-end tests โ€” minutes. Boots the real app; run only after cheaper stages pass.
  5. Deploy + smoke test โ€” verify staging is alive before promoting.

๐Ÿ”‘ Fail fast in Jest and matrices

"Fail fast" appears at two levels. In a matrix, strategy.fail-fast: true cancels sibling jobs when one fails. Within a run, tools stop early too โ€” for example jest --bail stops the whole run at the first failing test. Both save minutes when you just need a quick pass/fail signal.

A Full-Stack Pipeline

Here's a complete, realistic workflow for a full-stack JavaScript app โ€” a React frontend and a Node API backed by Postgres. It layers the pyramid, uses a service container for the database, and only deploys after every test job passes.

# .github/workflows/test-pipeline.yml
name: Test Pipeline

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

concurrency:                          # cancel stale runs on new pushes
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  # ---- Stage 1: cheap static checks ----
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npm run lint
      - run: npm run type-check

  # ---- Stage 2: fast unit tests with coverage ----
  unit:
    needs: lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - name: Unit tests with coverage
        run: npm test -- --coverage
      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage/

  # ---- Stage 3: integration tests against a real database ----
  integration:
    needs: lint
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: test_db
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - name: Integration tests
        run: npm run test:integration
        env:
          DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_db

  # ---- Stage 4: end-to-end tests on the built app ----
  e2e:
    needs: [ unit, integration ]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npm run build
      - name: Run Playwright end-to-end tests
        run: npx playwright test

  # ---- Stage 5: deploy to staging, only on main ----
  deploy-staging:
    needs: e2e
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npm run build
      - name: Deploy
        run: npm run deploy:staging
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
      - name: Smoke test staging
        run: npm run test:smoke
        env:
          STAGING_URL: ${{ secrets.STAGING_URL }}

Read the needs chain and the dependency graph appears: lint gates everything; unit and integration run in parallel after lint; e2e waits for both; and deploy-staging runs only on a push to main after E2E is green.

graph LR A["lint"] --> B["unit"] A --> C["integration"] B --> D["e2e"] C --> D D --> E["deploy-staging"]

๐Ÿ’ก concurrency saves your minutes

The concurrency block at the top cancels an in-progress run when you push again to the same branch. Without it, pushing three quick fixes queues three full pipelines; with it, only the newest survives. On a busy PR this alone can halve your CI usage.

Coverage Gates

Running tests with --coverage tells you what percentage of your code the tests actually exercise. A coverage gate turns that number into a rule: if coverage drops below a threshold, the build fails. This stops the slow rot where new code ships with no tests and the percentage quietly slides.

With Jest, you declare thresholds in config and the run fails automatically if they aren't met:

// jest.config.js
module.exports = {
  collectCoverage: true,
  coverageReporters: ['text', 'lcov'],   // text = console, lcov = for tools
  coverageThreshold: {
    global: {
      statements: 80,
      branches: 75,
      functions: 80,
      lines: 80,
    },
  },
};

Now npm test -- --coverage exits non-zero โ€” and fails the CI job โ€” the moment coverage falls under those numbers. No extra pipeline logic needed; the test command itself is the gate.

Coverage output

-----------------|---------|----------|---------|---------|
File             | % Stmts | % Branch | % Funcs | % Lines |
-----------------|---------|----------|---------|---------|
All files        |   83.4  |   77.1   |  85.0   |  83.1   |
 cart.js         |   91.2  |   88.0   |  100    |  90.9   |
 checkout.js     |   68.0  |   55.5   |  62.5   |  67.4   |  โ† thin
-----------------|---------|----------|---------|---------|
Jest: coverage threshold met โœ“

โš ๏ธ Coverage is a floor, not a trophy

100% coverage does not mean bug-free โ€” you can execute a line without asserting anything meaningful about it. Treat coverage as a safety net that catches untested code, not a goal to game. A thoughtful 80% beats a box-ticking 100%.

Parallelism & Sharding

As a suite grows, run time balloons. Two techniques keep it in check.

Parallel jobs

You've already seen it: split independent test types into separate jobs so they run at once. Unit and integration don't depend on each other, so they should never run one-after-the-other.

Sharding

When a single suite is the bottleneck, split it across several runners with a matrix. Each shard runs a slice of the tests; together they finish in a fraction of the time.

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [ 1, 2, 3, 4 ]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - name: Run test shard ${{ matrix.shard }} of 4
        run: npx jest --shard=${{ matrix.shard }}/4

Four shards turn a 12-minute suite into roughly three minutes, because each runner handles only a quarter of the tests. Combine sharding with caching and you rarely wait long for feedback.

Branch Protection

A pipeline that only reports failures is a smoke alarm with the wires cut. Branch protection rules make it a real gate: mark your CI checks as required, and GitHub will physically prevent merging a pull request until they pass.

In the repo, go to Settings โ†’ Branches โ†’ Add branch ruleset (or "Add rule") for main, and enable:

  • Require status checks to pass before merging โ€” then select your lint, unit, integration, and e2e jobs
  • Require branches to be up to date before merging โ€” so checks run against the latest main
  • Require a pull request before merging โ€” no direct pushes to main
  • Optionally, require review approvals for a human sign-off too
graph TD A["Open pull request"] --> B["Required checks run"] B --> C{"All checks green?"} C -->|No| D["Merge button disabled"] C -->|Yes| E["Merge allowed"] D --> F["Author pushes a fix"] F --> B

โœ… This is where CI becomes non-negotiable

Before branch protection, passing CI is a polite suggestion. After it, broken code cannot reach main โ€” the pipeline is now load-bearing. This single setting is what turns "we have tests" into "untested code doesn't ship."

Practice & Quiz

๐Ÿ‹๏ธ Exercise 1: Rebalance the pyramid

Goal: A team's suite is 15 unit tests, 30 integration tests, and 120 end-to-end tests. Runs take 45 minutes and fail flakily. Describe what's wrong and how you'd rebalance it.

๐Ÿ’ก Hint

Which layer should be the widest? Which layer is slow and flaky? Compare their counts to the ideal 70 / 20 / 10 split.

โœ… Solution

This is an inverted pyramid (an "ice cream cone"): far too many slow, flaky end-to-end tests and far too few fast unit tests. The fix:

  • Push logic down โ€” most of those 120 E2E cases are really testing units or single API calls; rewrite them as unit or integration tests.
  • Keep only ~10-15 E2E tests covering the critical journeys (sign up, checkout, core flow).
  • Grow the unit layer to be the widest so failures point at specific functions and run in milliseconds.

Result: faster runs, sharper failure messages, and far less flakiness.

๐Ÿ‹๏ธ Exercise 2: Add a coverage gate

Goal: A project runs npm test -- --coverage in CI but never fails on low coverage. Add config so the build fails if line coverage drops below 80%.

โœ… Solution
// jest.config.js
module.exports = {
  collectCoverage: true,
  coverageThreshold: {
    global: {
      lines: 80,
      statements: 80,
    },
  },
};

With a coverageThreshold set, Jest exits with a non-zero code when coverage falls short โ€” which automatically fails the CI job running the command. No extra workflow steps required.

๐ŸŽฏ Quick Quiz

Question 1: According to the test pyramid, which layer should you have the most of?

Question 2: What does a coverage gate do?

Question 3: What makes passing CI actually required to merge a pull request?

Best Practices & Pitfalls

โœ… Do

  • Shape the suite like a pyramid: many unit, some integration, few end-to-end
  • Order stages to fail fast โ€” lint and unit before slow E2E
  • Set a realistic coverage threshold and let the test command enforce it
  • Use service containers for integration tests instead of mocking the whole database
  • Make CI checks required via branch protection so nothing merges untested
  • Add a concurrency group to cancel superseded runs

โŒ Don't

  • Don't build an ice-cream cone โ€” a mountain of slow E2E tests is fragile and slow
  • Don't chase 100% coverage for its own sake; assert meaningfully instead
  • Don't tolerate flaky tests โ€” quarantine and fix them, don't normalize "just re-run"
  • Don't let integration tests share mutable state; reset the database per run so tests stay isolated

โš ๏ธ Taming flaky tests

Flakiness usually comes from timing (a test that assumes something loaded before it did) or shared state (test B depends on data test A left behind). Fixes: await real conditions instead of fixed sleeps, give each test its own fresh data, and mock external network calls. A limited auto-retry can hide a flake temporarily, but the real cure is removing the nondeterminism.

Summary

๐ŸŽ‰ Key Takeaways

  • A testing pipeline is a conveyor belt: cheap checks first, expensive ones last, deploy only if all pass
  • Shape tests by the pyramid โ€” ~70% unit, ~20% integration, ~10% end-to-end
  • Fail fast by ordering stages by speed and running matrices with fail-fast
  • A coverage gate (e.g. Jest coverageThreshold) fails the build when tests thin out
  • Parallel jobs and sharding keep even large suites fast
  • Branch protection with required checks is what makes CI truly block bad merges

๐Ÿ“š Additional Resources

๐Ÿš€ What's Next?

You've now designed a pipeline that tests thoroughly and gates ruthlessly. The next lesson is the payoff: Implement comprehensive testing for a full-stack application โ€” a hands-on project where you build the complete unit, integration, and end-to-end test suite for a real app and wire it all into the CI pipeline you now understand.

๐Ÿงช The belt is running

Every change now earns its way to production through a real quality gate. Time to build the full suite for a complete app.