Skip to main content

Testing — Part 1: Core Concepts

Ticket: SCRUM-124 | Status: Approved ✅

What This Task Was About

Before writing any tests, this session was about building a foundation — understanding why we test, how to think about tests, and learning the core vocabulary (mocks, spies, AAA pattern, getByRole vs getByTestId). This is the theory session before the setup and coding tickets.


Why Write Tests?

  • Prove that code works before refactoring it — so you can change the implementation without worrying it'll break something silently.
  • Tests lock in behaviour. If the behaviour is still correct after a refactor, the tests pass. If something broke, they catch it immediately.

Why Vitest?

  • Faster than Jest — runs on top of Vite's config, so it reuses the same transform pipeline already set up for the project.
  • Has a built-in UI (vitest --ui) for an interactive test runner.
  • Works with both chai and jest assertion syntax — no need to relearn matchers.
  • globals: true in the config means you don't need to import describe, it, expect at the top of every test file.
// vitest.config.ts
export default defineConfig({
test: {
globals: true, // ← no need to import describe/it/expect
},
});

How to Test a Function That Throws

Wrap the call inside a function (fn), then assert that fn throws:

const fn = () => withdraw(account, 9999);
expect(fn).toThrow();

// Or assert the message:
expect(fn).toThrow('Insufficient funds');

Mocks

"Don't use the real thing — use this fake I control instead."

Mocks keep tests fast, predictable, and focused on only the code you wrote. There are three types:

1. Mock module — replace an entire library with a fake

// src/__mocks__/axios.ts
// Vitest/Jest auto-uses this file whenever any code imports axios
export default {
get: vi.fn().mockResolvedValue({ data: [{ login: 'ThePhonyGOAT' }] })
}

axios.get now always returns fake data instantly — no network needed.

2. Mock function — track whether a function was called

const mockSetTodos = vi.fn();
// Pass it as a prop, then assert it was called
expect(mockSetTodos).toBeCalled();

You don't care what setTodos does internally — you just want to confirm "did my component call it?"

3. Mock data — fake input instead of real data

<TodoFooter numberOfIncompleteTasks={5} />

Instead of running the whole app to get a real task count, you pass a number directly.


vi.mock() — how it works

vi.mock('../firebase/config', () => ({
db: {},
auth: {},
}));

Parameters:

  • Path to the module you want to mock
  • Optional factory function — lets you control what the mock returns

Return value:

  • Either void (just blocks the real module)
  • Or an object with mock implementations — you can later assert expect(mockFn).toHaveReturnedWith(value)

Spies vs Mocks

MockSpy
What it doesReplaces the real function entirelyWraps the real function and observes it
Real code runs?NoYes
Use whenYou want full control over what's returnedYou want to verify a real function was called correctly
// Mock — real implementation replaced
const mockFn = vi.fn().mockReturnValue(42);

// Spy — real implementation still runs, but you can assert on it
const spy = vi.spyOn(wordService, 'createWord');

The AAA Pattern

Testing is behavioural — treat the function/component like a black box:

  • What arguments does it accept?
  • What does it return?
  • What visible side effects does it have? (state change, thrown error, call to another service)

You don't care how it achieves that — just that given X, you get Y. Internal implementation can change freely; behaviour is what you're locking in.

AAA = Arrange → Act → Assert

describe('CheckingAccount', () => {
describe('withdraw', () => {
test('decreases balance when amount is valid', () => {
// Arrange
const account = new CheckingAccount('John', 100);

// Act
account.withdraw(30);

// Assert
expect(account.balance).toBe(70);
});

test('throws when amount exceeds balance', () => {
const account = new CheckingAccount('John', 100);
const fn = () => account.withdraw(999);
expect(fn).toThrow('Insufficient funds');
});
});
});

How to organise describe blocks

StrategyExample
By unit/methoddescribe('CheckingAccount.withdraw', ...)
By input typetest('throws when amount > balance', ...)
By statedescribe('when user is admin', ...)

beforeEach — shared setup

Use beforeEach when multiple tests need exactly the same starting state:

describe('CheckingAccount', () => {
let account: CheckingAccount;

beforeEach(() => {
account = new CheckingAccount('John', 100); // reset before every test
});

test('withdraw subtracts amount', () => {
account.withdraw(30);
expect(account.balance).toBe(70);
});

test('withdraw all leaves zero', () => {
account.withdraw(100);
expect(account.balance).toBe(0);
});
});

Approach: Start with plain Arrange inside each test. When setup is duplicated, extract it into beforeEach — but keep it simple and readable.


React Testing Library: getByRole vs getByTestId

RTL prefers getByRole because it mimics how a real user (or screen reader) perceives the UI. Users see buttons, headings, and text — not internal test IDs.

// Preferred — tests what a user actually sees
const button = screen.getByRole('button', { name: /submit/i });

// Avoid unless necessary — couples test to implementation detail
const button = screen.getByTestId('submit-btn');

Why this matters: It forces you to give elements proper roles, labels, and alt text — the same hooks that screen readers and keyboard users rely on. Tests written this way give more confidence that the UI actually works for all users.


Interacting with Elements (fireEvent / userEvent)

Use fireEvent to trigger DOM events in tests:

import { fireEvent, render, screen } from '@testing-library/react';

test('clears input after clicking Add', () => {
render(<TodoInput />);
const input = screen.getByRole('textbox');

fireEvent.change(input, { target: { value: 'Buy milk' } });
fireEvent.click(screen.getByRole('button', { name: /add/i }));

expect(input).toHaveValue('');
});

Note: In modern RTL, prefer userEvent over fireEvent — it simulates real browser events more accurately. For example, fireEvent.click doesn't trigger focus/blur, but userEvent.click does.

import userEvent from '@testing-library/user-event';

test('types and submits', async () => {
const user = userEvent.setup();
render(<TodoInput />);

await user.type(screen.getByRole('textbox'), 'Buy milk');
await user.click(screen.getByRole('button', { name: /add/i }));

expect(screen.getByRole('textbox')).toHaveValue('');
});

Code Review Feedback

Katie Nguyen (2026-02-26):

[Code Review Feedback] SCRUM-124 — Learn: Testing in JS/TS

What went well:

  • Why Vitest: Spot on. Especially the insight about running on Vite's config — that's exactly why it's faster than Jest. Good note on global: true too.
  • vi.mock(): Your strongest comment. You understood WHY you'd use each type (mock module vs mock function vs mock data), wrote real examples, and nailed the one-line summary: "Don't use the real thing — use this fake I control instead." That's exactly the right mental model.
  • Spies vs Mocks: This distinction trips people up all the time. You got it right: mocks replace the function, spies observe the real one. Clean and concise.
  • AAA Pattern: Very thorough. You covered all three organize-by strategies (unit, input type, state), and the beforeEach example shows you understand when to extract shared setup vs keeping it inline — a nuanced point.
  • getByRole vs getByTestId: Great answer. You added the accessibility angle (screen readers, keyboard users) which is exactly the WHY behind RTL's philosophy.

One thing to tighten up:

  • FireEvent: Worth knowing for when you start writing real tests — prefer userEvent over fireEvent in modern RTL. It simulates real browser events more accurately (e.g. fireEvent.click doesn't trigger focus/blur, but userEvent.click does).

Overall: You clearly watched the material and didn't just copy notes — you synthesized it into your own words. The mock/spy distinction, the AAA breakdown, and the RTL philosophy answer are all at the level I'd expect from someone ready to start writing real tests.

Verdict: APPROVED ✅ — ready to move on to the coding subtasks in SCRUM-117.