Suppose you are building the results portal for your school. Somewhere inside it sits a small function that decides whether a student has passed a subject. CBSE's rule is simple: 33 out of 100 is the passing mark. You write the function, run it a couple of times in your head with a few marks you make up — 80, 45, 20 — see "Pass", "Pass", "Fail" printed correctly, and move on. Weeks later, a classmate "cleans up" the code, and one character quietly changes: >= becomes >. Nobody notices, because nobody reruns your original checks. The portal ships. A student who scored exactly 33 — the boundary value, the one number the rule was actually written for — is shown as "Fail". That single silent change would have failed a student who had, in fact, passed.
This is not a hypothetical edge case invented for a textbook. Boundary values — the exact number at which behaviour is supposed to switch — are where real bugs live, precisely because they are the numbers people forget to re-check by hand. The fix isn't to be more careful next time. The fix is to write the checks down once, as code, so that a machine reruns all of them, instantly, every single time anything in the program changes. That is what unit testing is, and Jest is the tool this chapter will use to do it in JavaScript.
What exactly is a "unit" test?
A program is built out of small pieces — mostly functions. A unit is the smallest one of those pieces that still does something meaningful on its own: usually a single function. A unit test is a small, automated piece of code whose only job is to call that function with a specific input and check that the output is exactly what it should be. Not "roughly right", not "looked fine when I glanced at the console" — exactly what it should be, checked by code, every time.
This is different from testing the whole application by clicking through it in a browser, which is called manual or end-to-end testing. End-to-end testing is slow, and a human has to be sitting there doing it. A unit test for the getResult function above doesn't open a browser, doesn't touch a database, doesn't need a human to look at anything. It calls one function with one input and compares one output. Because it's this small and this fast, you can have hundreds of them, and run all of them in under a second, every time you save a file.
Meet Jest
Jest is a JavaScript testing framework — a library that gives you the two tools you need to write unit tests: a way to describe a test (test), and a way to state what you expect (expect). It was originally built at Facebook and is now maintained as open-source software; it remains one of the most widely used test runners for JavaScript, and for years it shipped as the default test tool in Create React App.
To use it in a project, you install it as a development dependency and add a script to run it:
npm install --save-dev jest
{
"scripts": {
"test": "jest"
}
}
Jest has one convention you must follow: it automatically finds and runs any file whose name ends in .test.js (or that sits inside a folder named __tests__). You never have to tell Jest which files contain tests — the naming convention does that for you. Running npm test or npx jest from the project's root folder scans the whole project, runs every test it finds, and prints a summary.
Your first test, traced line by line
Here is the passing-marks function, saved in its own file so it can be imported wherever it's needed:
// mathUtils.js
function getResult(marks) {
if (marks >= 33) {
return "Pass";
}
return "Fail";
}
module.exports = { getResult };
And here is a second file, its test file, sitting next to it:
// mathUtils.test.js
const { getResult } = require('./mathUtils');
test('marks of 33 should Pass (the boundary case)', () => {
expect(getResult(33)).toBe('Pass');
});
test('marks of 32 should Fail', () => {
expect(getResult(32)).toBe('Fail');
});
Read this the way Jest reads it. test(...) takes two things: a plain-English description of what's being checked, and a function containing the actual check. Inside that function, expect(getResult(33)) first runs getResult(33), which returns the string "Pass", and wraps that returned value so Jest can inspect it. .toBe('Pass') is a matcher — it compares the wrapped value against 'Pass' and either lets the test pass silently, or throws a descriptive error that Jest catches and reports as a failure. Nothing here prints to the console the way console.log does. The test either succeeds quietly or fails loudly, and Jest collects the results of every test in the file before printing one summary.
Running npm test on this file prints something like:
PASS ./mathUtils.test.js
✓ marks of 33 should Pass (the boundary case)
✓ marks of 32 should Fail
Tests: 2 passed, 2 total
Notice which case was deliberately chosen: 33, not 80. A test written with 80 as the input would have passed both before and after the >=/> bug from the opening story, because 80 is nowhere near the boundary — it would tell you nothing useful. The test above was written specifically at the value where the rule's behaviour switches, because that's the value most likely to expose a mistake. Good unit tests are not random pokes at a function; they are deliberately chosen to attack the places where a function is most likely to be wrong.
Watching a test actually catch the bug
Now replay the classmate's "cleanup" against this test file. Suppose mathUtils.js is edited to read:
function getResult(marks) {
if (marks > 33) { // someone changed >= to > during a "cleanup"
return "Pass";
}
return "Fail";
}
Nobody needs to remember to manually re-test marks of exactly 33 — the saved test does it automatically the next time npm test runs:
FAIL ./mathUtils.test.js
✓ marks of 32 should Fail
✕ marks of 33 should Pass (the boundary case)
expect(received).toBe(expected)
Expected: "Pass"
Received: "Fail"
This is the entire point of writing the test in the first place. The check you did once, carefully, by hand, is now permanent. It runs again every time the code changes, forever, without you having to remember what to check or how. A bug that would have silently reached real students instead shows up as a red line in a terminal, before the code is ever deployed. This particular kind of failure — a change breaking behaviour that used to work — is called a regression, and catching regressions automatically is the single biggest reason unit testing exists.
Matchers: how expect() actually compares things
toBe is Jest's most basic matcher, and it works for numbers, strings, and booleans exactly the way you'd expect: it checks that the two values are identical. But Jest has many matchers, each suited to a different kind of check:
toBe(value)— exact match, best for primitives (numbers, strings, booleans)toEqual(value)— matches the contents of an object or array, field by fieldtoBeGreaterThan(n)/toBeLessThan(n)— numeric comparisonstoContain(item)— checks an array includes a valuetoThrow(message)— checks that calling a function raises an error
Choosing the right matcher matters more than it looks. Using the wrong one produces a test that either fails when the code is actually correct, or — worse — passes when the code is actually broken. The next section walks through the single most common way Grade 9 programmers get this wrong.
The toBe vs toEqual trap
Consider a function that splits a canteen bill. Three friends order food worth ₹500 and want to split it evenly, with any leftover paise-equivalent tracked separately:
function splitBill(amount, people) {
if (people <= 0) {
throw new Error('Number of people must be positive');
}
return {
perPerson: Math.floor(amount / people),
remainder: amount % people
};
}
Trace it by hand first, because the arithmetic matters: ₹500 divided by 3 friends is 166.67, and you can't hand someone a fraction of a rupee in this split, so Math.floor(500 / 3) gives 166 per person. Three people paying ₹166 each accounts for ₹498, leaving a remainder of ₹500 − ₹498 = ₹2, which is exactly what 500 % 3 computes. So splitBill(500, 3) should return the object { perPerson: 166, remainder: 2 }.
A natural first attempt at testing this reuses the matcher from the marks example:
test('splits ₹500 among 3 friends (wrong matcher)', () => {
expect(splitBill(500, 3)).toBe({ perPerson: 166, remainder: 2 });
});
This test fails — and the function is not broken. Here is why. In JavaScript, primitive values like numbers, strings, and booleans have no identity separate from their value: the number 166 is 166, wherever it appears, so toBe comparing two numbers is really just comparing two values, and that's why it worked perfectly in the getResult tests. Objects are different. Every time { perPerson: 166, remainder: 2 } is written in code, JavaScript builds a brand-new object at a new location in memory. The object returned by splitBill(500, 3) and the object written directly inside the test are two separate objects that happen to hold equal values — and toBe checks whether two things are literally the same object in memory, not whether their contents match. Two different objects with identical contents will always fail toBe, no matter how correct the code is.
The diagram below makes this concrete: two objects sitting at two different addresses, holding the same values.
The fix is to swap the matcher, not the code:
test('splits ₹500 among 3 friends', () => {
expect(splitBill(500, 3)).toEqual({ perPerson: 166, remainder: 2 });
});
toEqual ignores where the two objects live in memory and instead walks through both, field by field, checking that perPerson matches perPerson and remainder matches remainder. This test passes, correctly, because the function's actual behaviour is correct. The rule to keep permanently: use toBe for primitives (numbers, strings, booleans), and use toEqual whenever you're comparing an object or an array.
Testing that a function fails on purpose: toThrow
Look again at splitBill: it deliberately throws an error when people is zero or negative, rather than silently computing Math.floor(500 / 0), which in JavaScript would quietly produce Infinity instead of a sensible error. Testing this "it should fail loudly" behaviour needs its own matcher, and it needs to be called in a specific way. Here is the version that looks reasonable but is actually broken:
// Broken: this line throws immediately, before expect() ever runs
test('throws when people is zero - broken test', () => {
expect(splitBill(500, 0)).toThrow('Number of people must be positive');
});
The problem is order of operations. JavaScript evaluates splitBill(500, 0) first, to compute the argument it will hand to expect. But splitBill(500, 0) throws immediately — so the error crashes the test function right there, before expect or toThrow ever get a chance to run. Jest reports this as the whole test crashing, not as a controlled "yes, it threw as expected" pass.
The fix is to wrap the call inside a small anonymous function, so that the throwing happens later, at a moment toThrow is actively watching for it:
test('throws when people is zero', () => {
expect(() => splitBill(500, 0)).toThrow('Number of people must be positive');
});
Now expect receives a function — not the result of calling it — and only calls that function itself, inside a try/catch it controls, at the moment toThrow checks it. This small detail, wrapping a call you expect to throw inside () => ..., is one of the most common sources of confusion for anyone new to Jest, and it's worth memorizing rather than re-deriving each time.
Grouping related tests with describe()
As a file collects more tests, related ones can be grouped under a shared label using describe, which nests naturally in the printed output:
describe('getResult()', () => {
test('33 marks passes', () => {
expect(getResult(33)).toBe('Pass');
});
test('32 marks fails', () => {
expect(getResult(32)).toBe('Fail');
});
});
describe('splitBill()', () => {
test('splits evenly with no remainder', () => {
expect(splitBill(360, 4)).toEqual({ perPerson: 90, remainder: 0 });
});
test('splits with a remainder', () => {
expect(splitBill(500, 3)).toEqual({ perPerson: 166, remainder: 2 });
});
test('rejects zero people', () => {
expect(() => splitBill(500, 0)).toThrow();
});
});
describe doesn't change how the tests run — each test inside it still runs independently, and a failure in one doesn't stop the others. It only changes how results are organized and printed, which matters once a real project has dozens of functions and hundreds of tests spread across many files.
What a passing test does and does not prove
Here is a misconception worth naming directly, because it's easy to fall into right after writing your first few passing tests: a green checkmark next to test('32 marks fails', ...) does not mean getResult has no bugs. It means exactly one thing — that for the single input 32, the function returned exactly what was expected. It says nothing whatsoever about what the function does for marks of 33, or 0, or 100, or −5, or 101, unless a test exists for each of those inputs too.
This is exactly the trap in the opening story. Testing getResult(80) and getResult(20) and watching both pass would have felt like proof the function was correct — and it would have been completely useless at catching the >=-to-> bug, because 80 and 20 are nowhere near where that bug lives. A function is not "tested" because some tests for it pass; it's well-tested when the specific inputs likely to break it have been checked, and boundary values are almost always on that list. For getResult, that means testing 33 (the boundary itself), and ideally also 32 (just below it) and 34 (just above it) — not just numbers picked at random from the middle of the range.
The same discipline applies to splitBill: beyond the ordinary case of ₹500 among 3 friends, worthwhile tests include an amount that divides evenly (₹360 among 4, remainder zero) and the deliberately invalid case of zero people, which is precisely why that test exists in the describe block above. Each of those was chosen because it exercises a different path through the function's logic, not because it was the next number that came to mind.
Practice: test it yourself
Work through these before checking any explanation, the way you'd attack a board exam problem:
- Given
getResultas originally written (with>=), what doestest('0 marks fails', () => { expect(getResult(0)).toBe('Fail'); })report — pass or fail? Trace the condition by hand before answering. - Write a test asserting that
splitBill(1000, 5)returns{ perPerson: 200, remainder: 0 }. Which matcher must you use, and why would the other one fail even on correct code? - A classmate writes
expect(splitBill(-100, 4)).toEqual({ perPerson: -25, remainder: 0 })and it passes. Does this test provesplitBillcorrectly handles negative bill amounts, or does it only prove the arithmetic works out for this one negative number? What would you add to actually guard against negative amounts? - Rewrite this broken test so it works:
expect(splitBill(500, -2)).toThrow();— identify exactly which line evaluates first and why that breaks the test. - Why does
expect(getResult(33)).toBe(getResult(33))pass, whileexpect(splitBill(500,3)).toBe(splitBill(500,3))fails, even though both sides call the exact same function with the exact same arguments twice?
Summary
A unit test is a small, automated function that calls one piece of your code with a specific input and checks the output against an exact expected value, using Jest's test and expect. Tests live in files ending .test.js, which Jest finds and runs automatically via npm test. The matcher you choose has to match the kind of value you're checking: toBe for primitives like numbers and strings, because JavaScript compares those by value; toEqual for objects and arrays, because two separately built objects are never the same object even when every field matches; and toThrow for functions that are supposed to fail on purpose, always wrapped in () => ... so the throw happens where Jest is watching for it rather than before the test even starts. describe groups related tests without changing how they run. Most importantly, a test only proves what it specifically checks — a handful of passing tests on comfortable, middle-of-the-range inputs proves nothing about the boundary values where real bugs actually hide, which is exactly why the tests worth writing are the ones aimed deliberately at those boundaries.
Think About It
Think about this: How would you explain unit testing with jest to a friend who has never seen a computer? What real-world analogy would you use? Imagine you had to build a system using these concepts — what would be your first step? Try this: before moving on, write down three things you learned and one question you still have.