Your First Real Tests
Stage 3 · Testing, Quality & Craft · B.U.I.L.D. letter: D
You vibe-coded something that works — right now, on your machine, in this browser tab. That feeling is real. Tests exist so that feeling is still true tomorrow, after the third refactor, after the teammate touches it, after you forget what you built. This lesson gives you the exact mechanics to lock that working behavior down permanently.
⚠️ The vibe trap
Most first-time testers write one test: the happy path, with perfect input, and it passes. Feels great. Means almost nothing. The bugs that ship to users are never the perfect-input case — they're the empty string, the null, the number that's one too large, the user who clicks twice. There's a second trap too: tests that depend on each other, or on a specific run order, so the suite passes locally but fails randomly in CI. Real tests are isolated, deterministic, and relentlessly suspicious of edge cases.
🔬 Anatomy of a test — describe / it / arrange-act-assert
Every test in Jest or Vitest follows the same three-beat rhythm: Arrange (set up your inputs), Act (call the thing under test), Assert (check the result). The describe block names the subject; each it (or test) names one behavior.
// utils/clamp.js
export function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
// utils/clamp.test.js
import { clamp } from './clamp.js';
describe('clamp', () => {
it('returns the value when it is within range', () => {
// Arrange
const value = 5, min = 0, max = 10;
// Act
const result = clamp(value, min, max);
// Assert
expect(result).toBe(5);
});
});
Mental model: A test is a micro-spec. describe is the noun (what thing?), it is the verb phrase (what should it do?). Read them out loud together: "clamp — returns the value when it is within range." If that sentence would belong in a README, the test name is good.
Why it matters: Three-beat structure forces you to think about inputs and outputs explicitly before you write the assertion. That pause catches bugs before you even run the suite.
Common mistake: Cramming multiple behaviors into one it block. If your test has three expect calls that check unrelated things, split it. When it fails, you want the failure message to tell you exactly what broke.
🎯 Common matchers — toBe, toEqual, toThrow
Jest and Vitest ship with a rich set of matchers. Three you'll use constantly:
toBe— strict equality (===). Use for primitives (strings, numbers, booleans).toEqual— deep equality. Use for objects and arrays (checks shape, not reference).toThrow— asserts the function throws, optionally matching the error message.
import { formatCurrency, parseConfig } from './helpers.js';
describe('formatCurrency', () => {
it('formats a number as USD with two decimal places', () => {
expect(formatCurrency(9.5)).toBe('$9.50');
});
it('returns an object with the same shape when given valid config', () => {
const input = { theme: 'dark', lang: 'en' };
expect(parseConfig(input)).toEqual({ theme: 'dark', lang: 'en', version: 1 });
});
});
Mental model: toBe is ===. toEqual is "looks the same." Never use toBe on objects — two objects with identical keys are not === unless they're the same reference in memory.
Why it matters: Wrong matcher, wrong signal. expect({a:1}).toBe({a:1}) fails even though the values match. Default to toBe for primitives; reach for toEqual the moment you're comparing objects or arrays.
Common mistake: Using toEqual everywhere out of habit. It passes for primitives too — but toBe is more precise and catches type mismatches that toEqual forgives.
🧱 Test pure functions first — highest signal, zero setup
A pure function takes inputs, returns an output, touches nothing else. No network, no database, no this. These are the easiest tests to write and the most valuable per line of code, because they document exactly what the function does under every condition.
// math/discount.js
export function applyDiscount(price, percentOff) {
if (percentOff < 0 || percentOff > 100) {
throw new RangeError('percentOff must be between 0 and 100');
}
return parseFloat((price * (1 - percentOff / 100)).toFixed(2));
}
// math/discount.test.js
import { applyDiscount } from './discount.js';
describe('applyDiscount', () => {
it('applies a 20% discount to a price', () => {
expect(applyDiscount(100, 20)).toBe(80);
});
it('returns the original price when discount is 0', () => {
expect(applyDiscount(49.99, 0)).toBe(49.99);
});
it('returns 0 when discount is 100%', () => {
expect(applyDiscount(250, 100)).toBe(0);
});
it('throws RangeError when percentOff is negative', () => {
expect(() => applyDiscount(100, -5)).toThrow(RangeError);
expect(() => applyDiscount(100, -5)).toThrow('percentOff must be between 0 and 100');
});
it('throws RangeError when percentOff exceeds 100', () => {
expect(() => applyDiscount(100, 110)).toThrow(RangeError);
});
});
Mental model: Pure functions are a contract. Tests are proof the contract holds. Every branch deserves a test; every throw deserves a test that confirms it fires.
Why it matters: Pure functions are deterministic — same inputs, same output, always. Your test can never flake. It either works or it doesn't.
Common mistake: Testing the return value but ignoring the thrown path. If your function throws on bad input, that's part of its public API. Skipping it means a refactor can silently swallow the error and every caller breaks.
🕳️ Edge cases and error paths — where bugs actually live
Once the happy path passes, immediately ask: what are the boundary conditions? Empty input, zero, negative, null, undefined, the exact minimum, the exact maximum, one above the maximum. These are the cases vibe-coders skip — and exactly where production bugs nest.
// utils/truncate.js
export function truncate(str, maxLen) {
if (typeof str !== 'string') throw new TypeError('str must be a string');
if (str.length <= maxLen) return str;
return str.slice(0, maxLen) + '…';
}
// utils/truncate.test.js
import { truncate } from './truncate.js';
describe('truncate', () => {
it('returns the string unchanged when it is shorter than maxLen', () => {
expect(truncate('hello', 10)).toBe('hello');
});
it('returns the string unchanged when it is exactly maxLen', () => {
// Boundary: length === maxLen should NOT truncate
expect(truncate('hello', 5)).toBe('hello');
});
it('truncates and appends ellipsis when string exceeds maxLen', () => {
expect(truncate('hello world', 5)).toBe('hello…');
});
it('handles an empty string without crashing', () => {
expect(truncate('', 10)).toBe('');
});
it('throws TypeError when str is not a string', () => {
expect(() => truncate(null, 5)).toThrow(TypeError);
expect(() => truncate(42, 5)).toThrow('str must be a string');
});
});
Mental model: For every parameter — smallest legal value, largest, exactly the boundary, wrong type, nothing at all. Each answer is a test.
Why it matters: Off-by-one errors (< vs <=) are the single most common logic bug category. Boundary tests catch them mechanically. Error-path tests catch silent-swallow refactors that turn a helpful TypeError into a mysterious undefined three levels deep.
Common mistake: Assuming the happy-path test "probably covers" the boundary. It doesn't. truncate('hello', 10) passing tells you nothing about length 5.
🏷️ Naming tests so failures read like a spec
When a test fails in CI at 2 AM, the failure message is all you have. Make it count.
Bad names:
it('works')it('test 1')it('formatCurrency edge case')
Good names follow the pattern: [given context] — [input/condition] — [expected output]
it('returns $0.00 when price is 0')it('throws RangeError when percentOff exceeds 100')it('handles an empty string without crashing')
The full failure line in your terminal becomes: FAIL · applyDiscount · throws RangeError when percentOff exceeds 100. That's a spec. Anyone on the team can read it without opening the test file.
⚡ Keeping tests independent and deterministic
Two rules that prevent an entire class of mysterious flaky tests:
- Each test sets up its own state. Never let test B depend on test A having run first. If you need shared setup, use
beforeEachto reset it. - No randomness, no real clocks, no real network. If your function calls
Date.now()orMath.random(), mock those in the test (covered in Lesson 5 — Test Doubles). For now, keep your tested functions pure so this isn't a concern.
// BAD — tests share mutable state
let counter = 0;
it('increments counter', () => {
counter++;
expect(counter).toBe(1); // passes only if this runs first
});
it('counter is 2 after two increments', () => {
counter++;
expect(counter).toBe(2); // breaks if order changes
});
// GOOD — each test is self-contained
it('increments a counter from 0 to 1', () => {
let counter = 0;
counter++;
expect(counter).toBe(1);
});
it('increments a counter from 1 to 2', () => {
let counter = 1;
counter++;
expect(counter).toBe(2);
});
🚦 Running tests and reading a failure
# Vitest
npx vitest run
# Jest
npx jest
# Watch mode (re-runs on file save — use this while writing)
npx vitest
A passing suite prints green checkmarks. A failure looks like this:
FAIL utils/truncate.test.js
truncate
✓ returns the string unchanged when it is shorter than maxLen
✗ handles an empty string without crashing
● truncate › handles an empty string without crashing
expect(received).toBe(expected)
Expected: ""
Received: "…"
Read it top to bottom: file → describe block → test name → what was expected vs. what arrived. The test name is your compass. If it said test 3, you'd have to open the file. If it says "handles an empty string without crashing", you already know the bug.
🛠️ Your mission
Pick one utility function from your own app — something that takes inputs and returns a value. Write exactly three tests for it:
- Happy path — valid, typical input produces the correct output.
- Edge case — boundary value or empty input that is technically valid but unusual.
- Error case — invalid input that should throw (add the guard if it doesn't exist yet).
Run the suite. Read the output. If a test fails, read the failure message before looking at the code — practice trusting the test output as your first diagnostic tool.
✅ You're done when…
- Three tests pass locally for a real function from your app
- Each test name reads like a sentence in the Pre-Ship Checklist — if it failed in CI, anyone on your team would know exactly what broke without reading the code
- You have at least one
toThrowassertion covering an error path - Each test is self-contained: removing any one test does not break the others
- You ran the suite, introduced a deliberate bug, watched the right test fail, and fixed it
➡️ Next: Test-Driven Development. Build It Right, Or Don't Build It At All. 🏛️