🧪 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 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.
~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"]
| Milestone | You produce | Done when… |
|---|---|---|
| 1 · Plan | A one-page test plan | Each critical component has a chosen test level and priority |
| 2 · Unit | 8–12 unit tests | Core logic + one component pass in isolation |
| 3 · Integration | 3–5 API/DB tests | Routes run against a real test database |
| 4 · E2E | 1–2 user-flow tests | Your happy path passes in a real browser |
| 5 · Coverage & CI | Coverage gate + workflow file | Every 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.
| Component | Critical behavior | Test level(s) | Priority |
|---|---|---|---|
| Auth service | Login, token issue, expiry | Unit + Integration + E2E | High |
| Cart total | Sum, discounts, tax, rounding | Unit | High |
| Products API | List, fetch one, create | Integration | High |
| Checkout flow | Cart → pay → confirmation | E2E | Medium |
| Product card | Renders info, add-to-cart click | Unit | Medium |
⚠️ 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.
💡 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
- ☐
--coverageruns 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
awaitor 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 |
|---|---|---|
| Speed | Unit + integration finish in seconds | The suite takes minutes; nobody runs it locally |
| Isolation | Any test can run alone and pass | Tests must run in order; one leaks state into the next |
| Reliability | Same code → same result, every time | Flaky tests people re-run until green |
| Intent | A failure name tells you what broke | test 1, test 2; failures are a mystery |
| Shape | Many unit, some integration, few E2E | Mostly slow E2E; the pyramid is upside down |
| Focus | Tests assert behavior users care about | Tests 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
- Martin Fowler — The Practical Test Pyramid
- Testing Library — Guiding Principles & Docs
- Playwright — Getting Started
- Vitest — Guide
- JavaScript Testing Best Practices
🚀 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.