Up to now you have written code and checked it by eye — run it once, see the right output, and move on. That works for scripts you never touch again, but real programs change. You fix a bug in one place and accidentally break another. You add a feature and three old ones stop working. Without a safety net, every edit is a gamble, and the fear of breaking something slowly freezes your codebase solid.
A unit test is a small, automatic check that one piece of your program — one unit — behaves exactly as you expect. You write the test once, and from then on you can rerun it in milliseconds whenever you change anything. A good test suite is the difference between confidently refactoring your code and praying nothing broke. It is also the fastest way to know whether a function you wrote five minutes ago actually works for every case you care about.
flowchart LR A["Arrange<br/>set up inputs"] --> B["Act<br/>call the unit"] B --> C["Assert<br/>check the output"] C -->|pass| D["✓ green"] C -->|fail| E["✗ red"] style A fill:#3776ab,color:#fff style B fill:#b45309,color:#fff style C fill:#e0900b,color:#fff style D fill:#1e7f3d,color:#fff style E fill:#b91c1c,color:#fff
The anatomy of a test
Every test follows the same three-part shape, whether you are testing JavaScript, Python, or SQL.
- Arrange — set up the data you need. Create variables, build objects, or configure state so the function has something to work with.
- Act — call the function you are testing with the arranged inputs.
- Assert — check that the actual result matches what you expected. If it does not, the test fails and tells you exactly where the mismatch is.
The assertion is the heart of the test. It is a single yes-or-no question: did the result equal 3? Was the array empty? When the answer is no, the assertion raises an error so you cannot miss the failure. A test without a strong assertion is just a program that runs and says nothing.
function assertEqual(actual, expected) {
if (actual !== expected) {
throw new Error(`Expected ${expected}, got ${actual}`);
}
}
function double(n) {
return n * 2;
}
const input = 5;
const result = double(input);
assertEqual(result, 10);
console.log("test passed");
Why tests isolate bugs
A failing test gives you three precious pieces of information: which behavior is wrong, what input triggered it, and what output you got instead of the expected one. That turns the vague dread of something broke into a specific, fixable problem. Instead of hunting through hundreds of lines, you know the exact function and the exact case that misbehaves.
Tests also act as a regression shield. Once a bug is fixed, you add a test for it. If that same bug ever reappears — because of a bad merge, a rushed edit, or a teammate's change — the test catches it before a human does. The test is now part of your program's documentation: it says, in runnable code, exactly what this function promises to do and what it does not.
flowchart TD A["Developer changes code"] --> B["Test suite runs"] B -->|all pass| C["✓ safe to ship"] B -->|one fails| D["✗ bug caught"] D --> E["Fix before merge"] style C fill:#1e7f3d,color:#fff style D fill:#b91c1c,color:#fff
function assertEqual(actual, expected) {
if (actual !== expected) {
throw new Error(`Expected ${expected}, got ${actual}`);
}
}
function greet(name) {
if (name === "") {
return "Hello, stranger";
}
return `Hello, ${name}`;
}
assertEqual(greet("Ada"), "Hello, Ada");
assertEqual(greet(""), "Hello, stranger");
assertEqual(greet("Z"), "Hello, Z");
console.log("all tests passed");
What to test
Beginners often test only the obvious case — double(2) equals 4 — and call it done. Professional tests hunt for edge cases: empty strings, zero, negative numbers, the largest value you expect, and the boundary between one category and another. A function that works for 1 through 10 may still break on 0 or -1, and a string handler that works for 'hello' may crash on ''.
Every test should also be independent. A test that relies on another test having run first is fragile: if you reorder them or run one in isolation, it mysteriously fails. Give each test its own fresh data, and clean up anything that persists between calls. Independence means you can run one test, ten tests, or a thousand, and each still tells the truth about the unit it checks without interference from its neighbors.
flowchart TD
Q{"What should I test?"} --> H["Happy path<br/>normal input"]
Q --> E["Edge cases<br/>zero, empty, negative"]
Q --> B["Boundaries<br/>first, last, limit"]
style H fill:#3776ab,color:#fff
style E fill:#b45309,color:#fff
style B fill:#e0900b,color:#fff
function assertEqual(actual, expected) {
if (actual !== expected) {
throw new Error(`Expected ${expected}, got ${actual}`);
}
}
function sign(n) {
if (n > 0) return 1;
if (n < 0) return -1;
return 0;
}
assertEqual(sign(10), 1);
assertEqual(sign(-5), -1);
assertEqual(sign(0), 0);
console.log("sign tests passed");
Tests as documentation
Comments rot. They describe what the author hoped the code did six months ago, and they are rarely updated when the code changes. A passing test, by contrast, cannot lie about current behavior — if the test passes, the code definitely does what the test asserts.
This makes tests the most reliable form of documentation you can write. When you join a new project, reading the tests is often faster than reading the source, because a test says given this input, expect that output in plain, executable terms. Name your tests clearly and future maintainers will thank you.
What does this print? Track the order of the logs around the passing assertion.
function assertEqual(a, e) {
if (a !== e) throw new Error("fail");
}
console.log("start");
assertEqual(2 + 2, 4);
console.log("end");
The assertion passes because 2 + 2 strictly equals 4.
A passing assert does not print anything on its own.
Write assertEqual(actual, expected) that throws an Error when the two values are not strictly equal. Then write multiply(a, b) that returns the product. Include three assertions that test multiply: one normal case, one with zero, and one with a negative number.
function assertEqual(actual, expected) {
// throw if they differ
}
function multiply(a, b) {
// return the product
}
// write your assertions below
Use !== for strict equality inside assertEqual.
Return a * b inside multiply.
Call assertEqual once for each required case.
function assertEqual(actual, expected) {
if (actual !== expected) {
throw new Error(`Expected ${expected}, got ${actual}`);
}
}
function multiply(a, b) {
return a * b;
}
assertEqual(multiply(3, 4), 12);
assertEqual(multiply(0, 5), 0);
assertEqual(multiply(-2, 3), -6);
This test helper and suite are meant to verify triple(n), but the helper has two bugs: it returns 'PASS' on mismatch and throws on match, and it uses loose == instead of strict ===. Fix assertEqual so it throws on mismatch and passes silently on match with strict equality.
function assertEqual(actual, expected) {
if (actual == expected) {
return "PASS";
}
throw new Error("FAIL");
}
function triple(n) {
return n * 3;
}
assertEqual(triple(3), 9);
assertEqual(triple(0), 0);
assertEqual(triple(-2), -6);
Flip the condition: throw when actual !== expected.
Remove the return statement; a passing assert should do nothing.
function assertEqual(actual, expected) {
if (actual !== expected) {
throw new Error(`Expected ${expected}, got ${actual}`);
}
}
function triple(n) {
return n * 3;
}
assertEqual(triple(3), 9);
assertEqual(triple(0), 0);
assertEqual(triple(-2), -6);
Write firstChar(s) that returns the first character of s, or an empty string if s is empty. Also write assertEqual(actual, expected) with strict equality, and three assertions that cover: a normal string, an empty string, and a single-character string.
function firstChar(s) {
// return first character or empty string
}
function assertEqual(actual, expected) {
// strict equality assertion
}
// write your assertions below
Check s.length before indexing.
Use strict equality inside assertEqual.
function firstChar(s) {
if (s.length === 0) {
return "";
}
return s[0];
}
function assertEqual(actual, expected) {
if (actual !== expected) {
throw new Error(`Expected ${expected}, got ${actual}`);
}
}
assertEqual(firstChar("hello"), "h");
assertEqual(firstChar(""), "");
assertEqual(firstChar("A"), "A");
You have tested removeVowels('hello') and it returns 'hllo'. Which additional test is MOST important to add next?
The empty string is a fundamental edge case. Many string-handling functions fail or behave unexpectedly when given empty input, and it is the fastest way to find off-by-one errors and missing guard clauses.
Recap
- A unit test is an automatic check that one function — one unit — behaves correctly for a specific input.
- Tests follow Arrange-Act-Assert: set up data, call the unit, check the result with a strict comparison.
- An assertion throws when the check fails, turning a hidden bug into a visible error you cannot ignore.
- Test edge cases, not just happy paths: zero, empty inputs, negatives, boundaries, and maximum expected values.
- Keep tests independent so they stay reliable when run alone, in groups, or in any order.
- Tests are living documentation: they describe what the code promises in runnable, verifiable code.
- A test that never fails is a test you cannot trust — verify it catches real mistakes before you rely on it.
Next you will meet Test-Driven Development, where tests are written before the code they verify — turning the red of a failing test into the green of a passing one.