The bug you can't see

You write a function. You try it twice, it gives the right answers, and you move on. Three months later someone reports that it gives a wrong answer for one particular input, and you have no idea when it broke. The usual cure for this is a unit test: a small piece of code whose only job is to check that another piece of code still does what you meant.

A unit is just a small part of a program, usually one function. A test calls that function with an input you chose and compares the result with the answer you expect. If they match, the test passes. If not, it fails, and you find out right now instead of from a user.

A function that looks right

Here is a function that says whether a year is a leap year (a year with 366 days instead of 365). Plenty of people write it this way on a first try:

function isLeapYear(year) {
  return year % 4 === 0;
}
console.log(isLeapYear(2024)); // true
console.log(isLeapYear(2023)); // false
console.log(isLeapYear(1900)); // should be false

The % sign gives the remainder after dividing, so year % 4 === 0 means "divisible by 4 with nothing left over". Running it in Node.js prints:

true
false
true

The first two lines are right. The third is wrong: 1900 was not a leap year. The real rule has two exceptions: a year divisible by 100 is not a leap year, unless it is also divisible by 400. That is why 1900 had 365 days but 2000 had 366. Your two quick manual checks never touched that case, so they could not have caught it.

Write the checks down

The fix for "I forgot to check that" is to write every check as code, so the computer repeats all of them every time. The smallest version of this needs no tools at all:

function check(name, actual, expected) {
  if (actual === expected) {
    console.log("PASS", name);
  } else {
    console.log("FAIL", name, "- expected", expected, "but got", actual);
  }
}

check("2024 is a leap year", isLeapYear(2024), true);
check("2023 is not", isLeapYear(2023), false);
check("1900 is not", isLeapYear(1900), false);
check("2000 is", isLeapYear(2000), true);

With the one-line version of isLeapYear from above, this prints:

PASS 2024 is a leap year
PASS 2023 is not
FAIL 1900 is not - expected false but got true
PASS 2000 is

Each test has three parts: the input you chose, the expected answer you worked out yourself (from the rules, not from running the code), and the actual answer the function returned. Everything else in testing is a more convenient way of doing this.

Try it: break it, then fix it

Below is the function from the article, and four tests that run against it. The code box is editable. Press Run tests, watch one test fail, then fix the function (or press Show the fix) and run again. Then try breaking it on purpose in a different way and see which test notices.

The tests are fixed: 2024 → true, 2023 → false, 1900 → false, 2000 → true. Only the function is yours to change.

What makes a good test

Notice which test found the bug: 1900. The easy years (2024, 2023) passed even with broken code. Good tests are picked on purpose. Bugs tend to hide at the edges: the smallest input, the biggest, zero, an empty list, and the place where a rule changes. Those are called edge cases, and a few of them are worth more than a hundred ordinary examples.

A few habits help beginners most:

Real tools do the same thing

Once you have more than a few checks, a test runner saves you from writing check yourself. It finds your tests, runs them all, and prints a tidy summary. Node.js has one built in (the node:test module, available in current versions), so you can try it with nothing to install:

const assert = require("node:assert");
const test = require("node:test");

function isLeapYear(year) {
  return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}

test("1900 is not a leap year", () => {
  assert.strictEqual(isLeapYear(1900), false);
});
test("2000 is a leap year", () => {
  assert.strictEqual(isLeapYear(2000), true);
});

Save that as leap.test.js and run node --test. The summary at the end of the output reports tests 2, pass 2 and fail 0. Other languages have their own versions of the same idea, such as pytest for Python, JUnit for Java and go test for Go. The names change; the shape (input, expected, actual) does not.

What tests can and cannot do

Tests do not prove a program is correct. They prove that the cases you thought of work. A test suite that passes is a safety net, not a guarantee, and it is only as good as the examples in it. What it does give you is confidence to change code later: when you tidy a function or add a feature, you press one button and learn within seconds whether you broke something that used to work.

That is the real payoff. You are not writing tests for today's version of the code. You are writing them for the tired version of you, months from now, who wants to change something and needs to know it is safe.