๐งช 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?
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.
| Layer | Verifies | Speed | Tools |
|---|---|---|---|
| Unit | One function/component in isolation | Milliseconds | Jest, Vitest |
| Integration | Modules working together (e.g. API + DB) | Seconds | Supertest, Vitest |
| End-to-end | A real user flow through the whole app | Minutes | Playwright, 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.
- Lint & type-check โ seconds. Catches syntax slips and type errors instantly.
- Unit tests โ seconds. The bulk of your correctness coverage, and quick.
- Integration tests โ seconds to a minute. Needs a database or services.
- End-to-end tests โ minutes. Boots the real app; run only after cheaper stages pass.
- 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.
๐ก 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, ande2ejobs - 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
โ 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
concurrencygroup 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
- GitHub Docs โ Building and testing Node.js
- GitHub Docs โ About protected branches
- GitHub Docs โ About service containers
- Martin Fowler โ The Practical Test Pyramid
๐ 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.