Skip to content

Expert · Fast, accessible, findable and safe: quality for real sites · topic 50 of 64 · 35 min

Testing basics: unit, integration and end-to-end

Explanation

A test is a small program that checks your code does what you expect, every time you change something. Most tests should be fast unit tests of small functions, fewer should check parts working together, and a few end-to-end tests drive a real browser like a user. Vitest runs unit tests, Playwright runs browser tests, and GitHub Actions can run them all on every push.

Why test?

Without tests, you check your site by hand: open it, click around, and hope nothing broke. That is slow, and you forget steps. A test suite checks hundreds of things in seconds. It catches bugs before users do, lets you change code without fear (refactoring), and documents what the code is supposed to do.

Think of it like this: Think of a sweet shop owner who tastes one laddoo from every new batch before the tray goes out. It takes a minute and saves a day of unhappy customers. Tests are that taste check for your code: small, quick checks every time you make a new batch, which means every change.

The test pyramid

LevelWhat it checksSpeedHow many
UnitOne function or component on its ownMillisecondsMany
IntegrationSeveral parts together, for example a form with its validation and a faked APIFastSome
End-to-end (E2E)The whole app in a real browser, like a user: open the page, type, click, see the resultSeconds eachA few, for the most important journeys such as login and checkout

A unit test: Arrange, Act, Assert

javascript
// cart.js
export function cartTotal(items) {
  return items.reduce((sum, item) => sum + item.price * item.qty, 0);
}

// cart.test.js
import { describe, it, expect } from 'vitest';
import { cartTotal } from './cart.js';

describe('cartTotal', () => {
  it('adds price times quantity for every item', () => {
    // Arrange: set up the data
    const items = [{ price: 40, qty: 2 }, { price: 25, qty: 1 }];
    // Act: call the code
    const total = cartTotal(items);
    // Assert: check the result
    expect(total).toBe(105);
  });

  it('is 0 for an empty cart', () => {
    expect(cartTotal([])).toBe(0);
  });
});
describe groups tests, it is one test, expect checks a result. Run them with npx vitest.
  • toBe checks exact equality (like ===), good for numbers, strings and booleans.
  • toEqual compares the contents of objects and arrays.
  • toThrow checks that a function throws an error.
  • Jest uses almost the same describe, it and expect syntax, so the skill carries over.
Tip: Start with pure functions: they take inputs and return an output, with no DOM, network or clock. They are the easiest to test. Keep your rules (prices, validation, formatting) in pure functions and keep the DOM code thin.

Mocking: faking the network

javascript
import { it, expect, vi } from 'vitest';
import { loadUser } from './api.js';

it('returns the user name from the API', async () => {
  const fakeFetch = vi.fn().mockResolvedValue({
    ok: true,
    json: async () => ({ name: 'Asha' }),
  });
  vi.stubGlobal('fetch', fakeFetch);

  const user = await loadUser(7);

  expect(fakeFetch).toHaveBeenCalledWith('/api/users/7');
  expect(user.name).toBe('Asha');
});
A mock replaces the real fetch, so the test is fast, works offline and always gets the same answer

Testing what the user sees

Testing Library (for React, Vue, plain DOM and more) finds elements the way a user does: by role, label and visible text, not by CSS classes. A query like screen.getByRole("button", { name: "Pay" }) works only if the button really has that accessible name, so these tests also push you toward accessible HTML. Playwright goes one step further and drives real browsers.

javascript
// tests/order.spec.js
import { test, expect } from '@playwright/test';

test('user can add a dosa and see the total', async ({ page }) => {
  await page.goto('http://localhost:5173/');
  await page.getByRole('button', { name: 'Add Masala Dosa' }).click();
  await expect(page.getByText('Total: ₹80')).toBeVisible();
});
Playwright clicks through your app in Chromium, Firefox and WebKit. Run it with npx playwright test.

Run tests on every push (CI)

output
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
      - run: npm ci
      - run: npm test
GitHub Actions runs your tests on a fresh computer for every push and pull request; a red cross appears if anything fails
  • A good test checks one behaviour and has a name that says it: "gives free delivery at 199 or more".
  • It covers edge cases: empty lists, 0, the exact limit, very big numbers and wrong input.
  • It is independent: it does not depend on other tests or on the order they run in.
  • It is reliable: no real network, no real clock, no random values unless you control them.
  • It fails when the code is wrong. A test that can never fail is worse than no test.
Careful: 100% code coverage does not mean bug-free. Coverage only shows which lines ran during the tests, not whether the checks were good. Aim for meaningful tests on the important logic.

Watch the lesson

Testing basics: unit, integration and end-to-end · 1/7

Tests = automatic checks

A test is code that checks your code. It runs in seconds, every time you change something.

Prefer a video by a teacher? Find free videos on “vitest tutorial unit testing javascript playwright for beginners” on YouTube ↗

In real life: A billing app that stopped giving wrong discounts

A shop billing app has a "buy 2, get 1 free" rule. A developer changes the code and, by mistake, gives the free item only when the quantity is more than 3. Customers buying exactly 3 now pay for all of them. The team adds unit tests for quantities 0, 2, 3, 4 and 6. The test for 3 fails at once. Because GitHub Actions runs the tests on every pull request and the main branch only accepts green pull requests, the next mistake like this never reaches the shop.

Practice (0/3 done)

Task 1 · Code · Javascript

Build the heart of a test library yourself. Write expectEqual(actual, expected). If JSON.stringify(actual) and JSON.stringify(expected) are the same, do nothing. Otherwise throw a new Error with the message "Expected <expected> but got <actual>", using the JSON text of each. Examples: expectEqual(2 + 2, 5) throws an Error with the message "Expected 5 but got 4"; expectEqual([1, 2], [1, 2]) does nothing.

Task 2 · Code · Javascript

A tester's job is to find the case that breaks the code. The leap year rule: a year is a leap year if it is divisible by 4, except years divisible by 100, unless they are also divisible by 400. So 2024 and 2000 are leap years, but 1900 and 2023 are not. A teammate wrote isLeapYear(year) as "return year % 4 === 0", which is wrong. Write an array called cases with at least 4 test cases, each { year, expected } where expected is true or false. Every case must be right for the real rule, and at least one case must make the buggy version fail. The checker runs your cases against both versions.

Task 3 · Practice

Add real tests to one of your projects. Install Vitest (npm install -D vitest), add "test": "vitest run" to the scripts in package.json, write tests for two pure functions, then add a GitHub Actions workflow that runs npm test on every push.

Quick quiz

Question 1

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

Question 2

2.What does Arrange-Act-Assert mean?

Question 3

3.Why mock fetch in a unit test?

Question 4

4.Which tool is made for end-to-end tests that click through your site in real browsers?

Question 5

5.A function has 100% line coverage. What can you be sure of?

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: Front-end security: HTTPS, XSS, CSP and secrets