Skip to content

Expert · Performance, security and testing · topic 30 of 34 · 30 min

Testing a full-stack app

Explanation

Tests are small programs that check your app still works after every change. A full-stack app needs many fast unit tests, some integration tests that call the real API with a test database, and a few end-to-end tests that click through the app in a real browser. Test money, login and data-loss paths first, and run every test in CI on each push.

Why write tests?

Every time you change the Notes app, something old can break: a fix in search can break pinning. Clicking through every screen by hand takes 20 minutes and you will skip it when you are tired. A test is code that checks your code, and it runs in seconds, every time.

Think of it like this: A car factory tests each part on its own (unit tests), then tests the engine fitted to the gearbox (integration tests), then a driver takes the finished car on a test track (end-to-end tests). There are thousands of part checks, fewer engine checks and only a few test drives, because test drives are slow and costly.
LevelWhat it checksSpeedNotes app exampleCommon tool
UnitOne function aloneMillisecondsvalidateNote rejects an empty titleVitest, Jest
ComponentOne UI part with fake dataFastNoteForm shows "Title is required"Testing Library
IntegrationAPI handler + real test databaseSecondsPOST /api/notes saves a rowVitest + Supertest
End-to-end (E2E)The whole app in a real browserSlowSign up, add a note, see it in the listPlaywright

Unit tests: the base of the pyramid

javascript
import { test, expect } from 'vitest';
import { validateNote } from './validate-note.js';

test('rejects a title that is only spaces', () => {
  expect(validateNote({ title: '   ' }).ok).toBe(false);
});

test('accepts a 100-character title but not 101', () => {
  expect(validateNote({ title: 'a'.repeat(100) }).ok).toBe(true);
  expect(validateNote({ title: 'a'.repeat(101) }).ok).toBe(false);
});
Runs with Vitest in Node: pure functions such as validation and reducers are the easiest to test.

Integration tests: API plus a test database

javascript
import request from 'supertest';
import { createApp } from '../src/app.js';
import { createTestDb } from './helpers.js';

test('a user cannot delete another user\'s note', async () => {
  const db = createTestDb();            // fresh SQLite file or schema for each test
  const app = createApp(db);
  const asha = await signupAndLogin(app, 'asha@example.com');
  const ravi = await signupAndLogin(app, 'ravi@example.com');
  const created = await asha.post('/api/notes').send({ title: 'Rent' });
  const res = await ravi.delete('/api/notes/' + created.body.id);
  expect(res.status).toBe(404);
});
Runs on Node.js with Vitest and Supertest: real routes, real SQL, but a throwaway test database (never production).

Component and end-to-end tests

typescript
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { test, expect, vi } from 'vitest';
import { NoteForm } from './NoteForm';

test('shows an error and does not save an empty note', async () => {
  const onSave = vi.fn();
  render(<NoteForm onSave={onSave} />);
  await userEvent.click(screen.getByRole('button', { name: 'Save' }));
  expect(screen.getByText('Title is required')).toBeTruthy();
  expect(onSave).not.toHaveBeenCalled();
});
A component test with Testing Library: find things the way a user does (by role and label), not by CSS class.
typescript
import { test, expect } from '@playwright/test';

test('user can log in and create a note', async ({ page }) => {
  await page.goto('http://localhost:3000/login');
  await page.getByLabel('Email').fill('test-user@example.com');
  await page.getByLabel('Password').fill(process.env.E2E_PASSWORD ?? '');
  await page.getByRole('button', { name: 'Log in' }).click();
  await page.getByRole('button', { name: 'New note' }).click();
  await page.getByLabel('Title').fill('Buy milk');
  await page.getByRole('button', { name: 'Save' }).click();
  await expect(page.getByText('Buy milk')).toBeVisible();
});
An end-to-end test with Playwright, shown as an example: a real browser clicks through the running app.

What to test first

  • Money: prices, totals, discounts, refunds. A wrong number here costs real rupees.
  • Auth and permissions: signup, login, logout, and "user A cannot read, edit or delete user B's notes".
  • Data-loss paths: delete, edit, import and migrations. Losing a user's notes is the worst bug a notes app can have.
  • Every bug you fix: write a test that fails because of the bug, then fix it. This is a regression test, and the bug can never quietly come back.

Tests in CI, and flaky tests

output
# .github/workflows/test.yml
name: test
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm test
GitHub Actions runs every test on each push; a red cross blocks the merge.

A flaky test passes sometimes and fails sometimes with no code change. Common causes are real timers, tests that depend on each other's data, the current date, and waiting for a fixed time instead of waiting for something to appear. Fix the cause: give each test its own data, pass a fake clock, and use waits like Playwright's expect(...).toBeVisible() that retry until the element shows.

Tip: Write code that is easy to test: pass things like the database, the clock and fetch in as parameters. Then a test can pass a fake, and you do not need tricks to replace them.
Careful: Never point tests at the production database. Use a separate test database that is created and wiped by the tests. A test that deletes notes must never be able to reach real users' notes.

Watch the lesson

Testing a full-stack app · 1/6

Tests = code that checks code

Every change can break something old. Tests check your app for you, in seconds, every time.

Prefer a video by a teacher? Find free videos on “testing pyramid unit integration end to end tests javascript vitest playwright” on YouTube ↗

In real life: The fees bug that tests would have caught

A coaching institute's fee app gave a 10% sibling discount. A developer changed the discount code to add a festival offer, and both discounts started stacking, so some families were charged 19% less. Nobody noticed for a month. After that, the team added unit tests for every discount rule ("sibling only: 10%", "sibling plus festival: the bigger one, not both"), an integration test for the invoice API and one end-to-end test that pays a fee in the payment gateway's test mode. The tests run in CI on every push, so a change that breaks a rule cannot be merged.

Practice (0/3 done)

Task 1 · Code · Javascript

Make the tests pass. Write searchNotes(notes, query). Each note is { id, title, body, pinned }. Return the notes whose title or body contains the query, ignoring upper/lower case and spaces at the ends of the query. An empty query returns all notes. Pinned notes come first; otherwise keep the original order. Do not change the input array.

Task 2 · Code · Javascript

An integration-style test runs create -> list -> delete against an in-memory API. Write createNotesApi() returning { handle(req) }. req is { method, path, userId, body }. Rules: no userId -> 401. POST /api/notes: trimmed title must be 1-100 characters, else 400; otherwise save { id, userId, title, body, pinned: false } (ids start at 1, body defaults to "") and return { status: 201, body: note }. GET /api/notes -> 200 with only this user's notes. DELETE /api/notes/:id -> 204 if it is this user's note, else 404. Anything else -> 404.

Task 3 · Practice

Write a test plan for your Notes app: which tests you will write at each level, and in which order. Then write and run at least the first three.

Quick quiz

Question 1

1.In the testing pyramid, which kind of test should you have the most of?

Question 2

2.What does an integration test of POST /api/notes usually use?

Question 3

3.Which of these should you test first in a new app?

Question 4

4.A test passes on your laptop but fails about one time in five in CI with no code change. What is it?

Question 5

5.You fixed a bug where pinned notes lost their pin after editing. What should you add?

Read more (free)

Pass the quiz (60% or more) or mark this topic as done when you are ready.

Your progress is saved on this device only.

← Previous: A full-stack security review