Skip to main content

🧪 Weekend Project: Testing & QA

This weekend you'll take a working full-stack app and give it a spine: a layered test suite that runs on every push. Not a hundred assertions for their own sake — a small, deliberate set that catches the bugs that actually matter, plus the CI wiring that keeps the whole thing honest. By Sunday evening you'll have a green pipeline, a coverage report, and the confidence to refactor without fear.

🎯 Learning Objectives

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

  • Plan a test strategy from the testing pyramid, choosing what to test at each level and what to skip
  • Write unit, integration, and end-to-end tests for a real frontend and backend
  • Measure coverage and set a realistic gate that fails the build when quality slips
  • Automate the suite in a CI pipeline that runs on every push and pull request
  • Evaluate your own tests against the qualities of good tests: fast, isolated, reliable, and maintainable

Estimated Time: 6–10 hours over a weekend  •  Difficulty: Intermediate

Hands-on: This whole lesson is the build — you ship a tested app with a green CI badge and a coverage report.

In This Lesson

The Project & The Pyramid

Your mission this weekend: take a small full-stack application — a task tracker, a notes app, a tiny store, whatever you've built earlier in this course — and wrap it in a test suite you'd trust in a real job. If you don't have an app handy, a two-endpoint API plus one page is plenty. The point isn't the app; it's the discipline you build around it.

The organizing idea is the testing pyramid. It says: write many cheap, fast tests at the bottom, fewer broader tests in the middle, and only a handful of slow, full-system tests at the top. Invert that shape — lots of slow browser tests, few unit tests — and you get the dreaded "ice-cream cone," a suite that's slow, flaky, and expensive to maintain.

The testing pyramid A three-tier pyramid: a wide base of many fast unit tests, a middle band of integration tests, and a narrow top of a few slow end-to-end tests. E2E Integration Unit few · slow some many · fast cost & runtime rise
Figure 1 — Aim for a wide base of fast unit tests, a thinner band of integration tests, and just a few end-to-end tests over your most critical user journeys.

📖 The three levels

Unit test: exercises one function or component in isolation, with its collaborators faked. Milliseconds to run.

Integration test: checks that several real pieces work together — an API route hitting a real (test) database, say. Seconds.

End-to-end (E2E) test: drives the whole system like a user would, through a real browser. Slow, brittle, but priceless for your top user flows.

Milestones at a Glance

Five milestones take you from a bare repo to a green pipeline. Do them in order — each one leans on the last. Times are rough; spend more where your app is more complex.

flowchart LR M1["M1 · Plan
~1h"] --> M2["M2 · Unit
~2h"] M2 --> M3["M3 · Integration
~2h"] M3 --> M4["M4 · E2E
~2h"] M4 --> M5["M5 · Coverage & CI
~1.5h"] M5 --> D["✅ Green pipeline
+ coverage report"]
MilestoneYou produceDone when…
1 · PlanA one-page test planEach critical component has a chosen test level and priority
2 · Unit8–12 unit testsCore logic + one component pass in isolation
3 · Integration3–5 API/DB testsRoutes run against a real test database
4 · E2E1–2 user-flow testsYour happy path passes in a real browser
5 · Coverage & CICoverage gate + workflow fileEvery push runs the suite and reports coverage

Milestone 1 — Plan What to Test

Resist the urge to open your editor. A doctor doesn't run every test on every patient; you shouldn't either. Spend the first hour deciding what deserves a test and at which level. The best tests protect the code that is both important (a bug here hurts) and likely to break (it changes often or has tricky logic).

Where to aim

  • Business logic — pricing, scoring, permission checks. The heart of the app.
  • Data transformations — parsing, formatting, mapping between shapes.
  • Edge cases — empty inputs, zero, negatives, huge values, the boundary at the limit.
  • Critical user journeys — sign in, add to cart, check out. The paths that make you money.
  • Security-sensitive code — authentication, authorization, input validation.

What to skip: framework internals, trivial getters, and third-party libraries — they have their own tests. Don't test that React renders a <div>; test your logic that decides what goes in it.

Your deliverable: a test plan table

Fill one row per critical component. This becomes your checklist for the rest of the weekend.

ComponentCritical behaviorTest level(s)Priority
Auth serviceLogin, token issue, expiryUnit + Integration + E2EHigh
Cart totalSum, discounts, tax, roundingUnitHigh
Products APIList, fetch one, createIntegrationHigh
Checkout flowCart → pay → confirmationE2EMedium
Product cardRenders info, add-to-cart clickUnitMedium

⚠️ A plan is a filter, not a wish list

If everything is "High," nothing is. Force yourself to mark some rows Medium or Low. You will run out of weekend before you run out of things you could test — the plan decides what actually gets done.

Milestone 2 — Unit Tests

Start at the base of the pyramid, because unit tests give you the most safety per minute spent. Install your tools, then write tests for your highest-priority pure logic and one component.

# Frontend (React) — Vitest is the modern default; Jest works the same way
npm install --save-dev vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

# Node backend
npm install --save-dev vitest supertest

Pure logic first

Pure functions — same input, same output, no side effects — are the easiest and most valuable things to test. Here's a cart-total calculator with the edge cases that bite in production:

// cartTotal.js
export function cartTotal(items, { taxRate = 0, discount = 0 } = {}) {
  const subtotal = items.reduce((sum, i) => sum + i.price * i.qty, 0);
  const discounted = subtotal * (1 - discount);
  const withTax = discounted * (1 + taxRate);
  // Money in cents avoids floating-point surprises
  return Math.round(withTax * 100) / 100;
}
// cartTotal.test.js
import { describe, it, expect } from 'vitest';
import { cartTotal } from './cartTotal.js';

describe('cartTotal', () => {
  it('sums price times quantity', () => {
    const items = [{ price: 10, qty: 2 }, { price: 5, qty: 1 }];
    expect(cartTotal(items)).toBe(25);
  });

  it('returns 0 for an empty cart', () => {
    expect(cartTotal([])).toBe(0);
  });

  it('applies a percentage discount', () => {
    const items = [{ price: 100, qty: 1 }];
    expect(cartTotal(items, { discount: 0.2 })).toBe(80);
  });

  it('rounds tax to two decimals', () => {
    const items = [{ price: 9.99, qty: 3 }];
    expect(cartTotal(items, { taxRate: 0.0825 })).toBe(32.44);
  });
});

💡 The AAA shape

Every good test reads in three beats: Arrange the inputs, Act by calling the thing, Assert on the result. If a test needs a fourth beat or a mystery of setup, it's a hint the code under test is doing too much.

Then one component

Test behavior a user can observe — text on screen, what happens on click — not internal state. Use accessible queries (getByRole, getByText) so your test breaks only when the experience breaks.

// ProductCard.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import ProductCard from './ProductCard';

describe('ProductCard', () => {
  const product = { id: '1', name: 'Test Mug', price: 19.99 };

  it('shows the product name and price', () => {
    render(<ProductCard product={product} onAdd={() => {}} />);
    expect(screen.getByText('Test Mug')).toBeInTheDocument();
    expect(screen.getByText('$19.99')).toBeInTheDocument();
  });

  it('calls onAdd with the product id when clicked', async () => {
    const onAdd = vi.fn();
    render(<ProductCard product={product} onAdd={onAdd} />);
    await userEvent.click(screen.getByRole('button', { name: /add to cart/i }));
    expect(onAdd).toHaveBeenCalledWith('1');
  });
});

Target for this milestone: 8–12 passing unit tests covering your highest-priority logic and one component.

Milestone 3 — Integration Tests

Unit tests fake the database and the network. Integration tests remove those fakes for a slice of the system so you catch the bugs that only appear when real pieces meet — a wrong column name, a broken query, a route that forgets to validate input.

Use Supertest to drive your Express app in memory against a real test database (never your dev or prod data). Seed known rows before, wipe them after, so tests stay isolated.

// products.api.test.js
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import app from '../app.js';
import { db } from '../db.js';

describe('Products API', () => {
  beforeAll(async () => {
    await db.connect(process.env.TEST_DATABASE_URL);
    await db.seed();          // insert a couple of known products
  });

  afterAll(async () => {
    await db.clear();
    await db.disconnect();
  });

  it('GET /api/products returns the seeded products', async () => {
    const res = await request(app).get('/api/products').expect(200);
    expect(Array.isArray(res.body.products)).toBe(true);
    expect(res.body.products.length).toBeGreaterThan(0);
  });

  it('GET /api/products/:id returns one product', async () => {
    const res = await request(app).get('/api/products/1').expect(200);
    expect(res.body).toMatchObject({ id: '1', name: expect.any(String) });
  });

  it('POST /api/products rejects a missing name with 400', async () => {
    await request(app)
      .post('/api/products')
      .send({ price: 9.99 })   // no name
      .expect(400);
  });

  it('POST /api/products creates a valid product', async () => {
    const res = await request(app)
      .post('/api/products')
      .send({ name: 'New Item', price: 29.99 })
      .expect(201);
    expect(res.body).toHaveProperty('id');
  });
});

✅ Test the sad path, not just the happy one

Notice the 400 test. Anyone can verify a valid request works — the bugs that reach users live in the rejected inputs, the missing fields, the "what if the id doesn't exist?" branch. Give every endpoint at least one failing-input test.

Target for this milestone: 3–5 integration tests hitting real routes and a real test database, including at least one error case.

Milestone 4 — End-to-End Tests

At the top of the pyramid you drive the whole app through a real browser, exactly as a user would. These are slow and the most fragile, so write few — cover only your make-or-break journeys. Playwright is the modern choice (Cypress is a fine alternative with near-identical concepts).

npm install --save-dev @playwright/test
npx playwright install   # downloads the browser binaries
// e2e/checkout.spec.js
import { test, expect } from '@playwright/test';

test('a shopper can add an item and check out', async ({ page }) => {
  await page.goto('/');

  // Add the first product to the cart
  await page.getByRole('button', { name: /add to cart/i }).first().click();

  // Open the cart and go to checkout
  await page.getByRole('link', { name: /cart/i }).click();
  await page.getByRole('button', { name: /checkout/i }).click();

  // Fill shipping details
  await page.getByLabel('Full name').fill('Ada Lovelace');
  await page.getByLabel('Address').fill('123 Analytical Ave');
  await page.getByRole('button', { name: /continue to payment/i }).click();

  // Use the test card and place the order
  await page.getByLabel('Card number').fill('4242 4242 4242 4242');
  await page.getByLabel('Expiry').fill('12/30');
  await page.getByLabel('CVC').fill('123');
  await page.getByRole('button', { name: /place order/i }).click();

  // Assert on what the user actually sees
  await expect(page.getByText(/order confirmed/i)).toBeVisible();
});

⚠️ Query by role and label, not by CSS class

Selectors like .btn-primary-3 break the moment a designer renames a class. Roles and labels (getByRole, getByLabel) track the user-facing meaning of the page, so your E2E tests survive restyles and double as an accessibility check.

Target for this milestone: 1–2 E2E tests over your single most important flow, passing in a real browser.

Milestone 5 — Coverage & CI

A suite that only runs when you remember to run it will rot. The final milestone makes the tests automatic and gives you a number to watch.

Measure coverage — and read it critically

# Vitest / Jest
npx vitest run --coverage

# Python
pytest --cov=app

# PHP
./vendor/bin/phpunit --coverage-text

Coverage reports three things: which statements ran, whether both sides of each branch were exercised, and which functions were called. Branch coverage is the honest one — 90% statements with 50% branches means half your if/else paths are untested.

pie showData title Example coverage report "Covered statements" : 87 "Covered branches" : 72 "Uncovered" : 13

💡 Coverage is a floor, not a trophy

Chasing 100% wastes hours on trivial lines and tempts you to write assertion-free tests that only inflate the number. A sensible gate for this project is 70–80%, higher on business logic. High coverage of shallow tests is worse than moderate coverage of sharp ones.

Wire it into CI

Add a GitHub Actions workflow so the whole suite runs on every push and pull request. If tests fail, the pull request is blocked — bad code never reaches main.

# .github/workflows/test.yml
name: Test Suite

on:
  push:
    branches: [ main ]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Unit & integration tests (with coverage)
        run: npx vitest run --coverage

      - name: Install browsers for E2E
        run: npx playwright install --with-deps

      - name: End-to-end tests
        run: npx playwright test

      - name: Upload coverage report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage/

Push this, open a pull request, and watch the checks run. A green check next to your commit is the whole weekend paying off: proof, on every change, that the app still works.

Completion Checklist

Tick these off as you go. When every box is checked, the project is done.

📋 Weekend checklist

  • ☐ A one-page test plan table exists, with a level and priority per component
  • 8–12 unit tests pass, covering core logic and one component
  • ☐ At least one unit test asserts an edge case (empty, zero, boundary)
  • 3–5 integration tests hit real routes against a test database
  • ☐ At least one integration test checks a rejected/invalid input
  • 1–2 E2E tests pass over your most important user flow
  • ☐ E2E tests query by role/label, not by CSS class
  • --coverage runs and reports a number you understand
  • ☐ A coverage gate (70–80%) is set and the suite respects it
  • ☐ A CI workflow runs the whole suite on every push/PR
  • ☐ The pipeline shows a green check on your latest commit

Stretch goals (if Sunday's going well)

  • Add contract tests (Pact) so the frontend and backend can't silently drift apart.
  • Add a smoke E2E test that runs against your deployed staging URL after each deploy.
  • Introduce a deliberately flaky test, watch it fail intermittently, then fix the root cause (usually a missing await or shared state).

What Good Looks Like

Two suites can both be "green" and be worlds apart in value. Here's how to tell a suite you'd trust from one that just decorates the repo.

Quality✅ Good🚫 Warning sign
SpeedUnit + integration finish in secondsThe suite takes minutes; nobody runs it locally
IsolationAny test can run alone and passTests must run in order; one leaks state into the next
ReliabilitySame code → same result, every timeFlaky tests people re-run until green
IntentA failure name tells you what broketest 1, test 2; failures are a mystery
ShapeMany unit, some integration, few E2EMostly slow E2E; the pyramid is upside down
FocusTests assert behavior users care aboutTests pin internal details and break on every refactor

✅ The real test of your tests

Delete a line of real logic — remove the discount, break a route's validation — and rerun the suite. If a test goes red and its name tells you exactly what you broke, your suite is doing its job. If everything stays green, you have coverage without protection.

🎯 Quick Quiz

Question 1: Following the testing pyramid, which type of test should you have the most of?

Question 2: Why is a high statement coverage number not enough on its own?

Question 3: In an end-to-end test, why prefer getByRole/getByLabel over selecting by CSS class?

Summary & Quiz

🎉 Key Takeaways

  • Plan before you test. A one-page plan of what to test, at which level, keeps the weekend focused on code that matters.
  • Follow the pyramid: many fast unit tests, fewer integration tests, a few E2E tests over critical journeys.
  • Test the sad path. Rejected inputs and edge cases are where the real bugs hide.
  • Coverage is a floor, not a trophy — 70–80% of sharp tests beats 100% of shallow ones.
  • Automate in CI so the suite runs on every push and bad code never reaches main.
  • Good tests are fast, isolated, reliable, and clear — and they actually go red when you break something.

📚 Further Reading

🚀 What's Next?

You've got a tested app and a pipeline that runs the suite on every push. Next we widen the lens from testing to the whole delivery pipeline: how continuous integration and continuous delivery turn a green test run into a safe, automatic release.

🎉 Nice work!

Your app now has a spine. Refactor away — the tests have your back.