Testing and Debugging
Writing code is only half the job. The other half is making sure it works correctly and fixing it when it doesn't. Debugging is the art of finding and fixing problems in existing code. Testing is the practice of writing code that automatically verifies your code behaves as expected. Together, they are essential skills for every developer.
What You'll Learn
By the end of this lesson you will build the machinery testing runs on, by hand: an assertion helper that
throws a labelled error when a value is wrong and stays silent when it is right, a table-driven runner that
counts passes and failures, and a spy that records how many times a function was called. Along the way you
will take a function that is right for some inputs and wrong for others and find the defect by isolating
it rather than guessing. You arrive able to write functions and objects with methods (03-functions,
06-objects-and-prototypes) and to throw and catch errors (09-error-handling); you leave able to prove a
function works instead of hoping it does, and to read a failure back to its cause.
Every testing library you will ever use is these three pieces with more polish on top. Building them once is what makes the polished versions legible.
Console Methods
Most developers only use console.log, but the console has many more tools for debugging:
// console.log - general output
console.log("Basic log message");
// console.warn - warnings (usually yellow in browsers)
console.warn("This is a warning");
// console.error - errors (usually red in browsers)
console.error("This is an error message");
// console.table - display data in a table format
const users = [
{ name: "Alice", age: 30, role: "Developer" },
{ name: "Bob", age: 25, role: "Designer" },
{ name: "Charlie", age: 35, role: "Manager" }
];
console.table(users);
// console.group / console.groupEnd - group related logs
console.group("User Details");
console.log("Name: Alice");
console.log("Age: 30");
console.log("Role: Developer");
console.groupEnd();
// console.time / console.timeEnd - measure execution time
console.time("loop");
let sum = 0;
for (let i = 0; i < 1000000; i++) {
sum += i;
}
console.timeEnd("loop");
console.log("Sum:", sum);
// console.count - count how many times something happens
for (let i = 0; i < 5; i++) {
if (i % 2 === 0) console.count("even");
else console.count("odd");
}
Debugging Strategies
When something goes wrong, having a systematic approach saves hours of frustration:
// Strategy 1: Binary Search Debugging
// Narrow down the problem by testing midpoints
function processData(items) {
// Step 1: Is the input correct?
console.log("Input:", items);
const filtered = items.filter(function(item) { return item.active; });
// Step 2: Is filtering correct?
console.log("After filter:", filtered);
const mapped = filtered.map(function(item) {
return { name: item.name, score: item.value * 10 };
});
// Step 3: Is mapping correct?
console.log("After map:", mapped);
const sorted = mapped.sort(function(a, b) { return b.score - a.score; });
// Step 4: Is sorting correct?
console.log("After sort:", sorted);
return sorted;
}
const testData = [
{ name: "A", value: 3, active: true },
{ name: "B", value: 7, active: false },
{ name: "C", value: 1, active: true },
{ name: "D", value: 9, active: true }
];
const result = processData(testData);
console.log("Final result:", result);
Strategy 2: Reproduce and Isolate. Extract the problem into the smallest possible example, feed it known inputs whose correct outputs you can compute by hand, and add a checkpoint after each step so you can see exactly where the numbers first go wrong. The bug below is a perfect candidate: it is right for some inputs and wrong for others, which is exactly the signal that says "isolate step by step." Apply the method — don't just read it.
Reading a stack trace
Isolation is what you reach for when nothing crashes. When something does crash, the runtime has already done most of the work for you — but only if you read the trace instead of skimming it. Here is a real one, produced by running a program that serialized an object and then called a method on the result:
/tmp/main.js:13
roundTripped.complete();
^
TypeError: roundTripped.complete is not a function
at Object.<anonymous> (/tmp/main.js:13:14)
at Module._compile (node:internal/modules/cjs/loader:1521:14)
at Module._extensions..js (node:internal/modules/cjs/loader:1623:10)
at Module.load (node:internal/modules/cjs/loader:1266:32)
at Module._load (node:internal/modules/cjs/loader:1091:12)
at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:164:12)
at node:internal/main/run_main_module:28:49
Four questions, in this order. They turn a wall of text into a hypothesis:
- What is the error type?
TypeError— a value was not of the kind the operation needed. That already rules out whole families: it is not aSyntaxError(the file parsed) and not aReferenceError(the nameroundTrippedresolved to something). - What does the message say, exactly? "roundTripped.complete is not a function." Read it literally.
It does not say
roundTrippedis missing — it says the property is not callable. So the object exists; the method does not. - Which is the first frame in code you wrote?
at Object.<anonymous> (/tmp/main.js:13:14). Every frame below it isnode:internal/...— the runtime's own machinery for loading your file, which is almost never where your bug is. Skip down the list until you reach a path that is yours. That line and column is where to look first. - What is the hypothesis? Line 13 assumed a serialized-and-reparsed object still had its prototype
methods.
JSON.parsereturns plain objects, so it does not. That is a testable claim: logtypeof roundTripped.completebefore the call and seeundefined.
The habit worth building is step 3. A long trace looks intimidating because most of it belongs to the runtime; the top frame in your own code is usually the only line that matters, and finding it is mechanical rather than clever.
Debug
calculateDiscount should return the price after a percentage discount plus a member bonus. Hand-computed expectations: $100/10%/gold → 85, $200/20%/silver → 154, $50/0%/none → 50. Two of the three come out wrong. Use reproduce-and-isolate — commit a hypothesis about which step is wrong — then fix it so all three match.
const assert = require('assert');
function calculateDiscount(price, discountPercent, memberLevel) {
const baseDiscount = price * (discountPercent / 100);
let memberBonus = 0;
if (memberLevel === "gold") memberBonus = 0.05;
if (memberLevel === "silver") memberBonus = 0.03;
const totalDiscount = baseDiscount + memberBonus;
const finalPrice = price - totalDiscount;
return finalPrice;
}
const gold = calculateDiscount(100, 10, "gold");
const silver = calculateDiscount(200, 20, "silver");
const none = calculateDiscount(50, 0, "none");
assert.strictEqual(gold, 85, 'gold: expected 85, got ' + gold);
assert.strictEqual(silver, 154, 'silver: expected 154, got ' + silver);
assert.strictEqual(none, 50, 'none: expected 50, got ' + none);
console.log(gold); // want 85
console.log(silver); // want 154
console.log(none); // want 50Expected output:
85 154 50
Transfer
Reproduce-and-isolate found the discount bug: feed known inputs, add a checkpoint after each step, and watch for where the numbers first go wrong. New setting — a nightly data pipeline reads a CSV, cleans it, joins it against a database, and writes a report. The report has been wrong for a week. Nobody has a stack trace, because nothing crashes. Which move carries the SAME isolation idea across, and what makes it the same idea?
Building a Test Framework
Understanding how test frameworks work demystifies testing. Let's build a mini framework from scratch:
// A minimal test framework
const TestRunner = {
passed: 0,
failed: 0,
tests: [],
test(name, fn) {
this.tests.push({ name, fn });
},
expect(actual) {
return {
toBe(expected) {
if (actual !== expected) {
throw new Error("Expected " + JSON.stringify(expected) + " but got " + JSON.stringify(actual));
}
},
toEqual(expected) {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error("Expected " + JSON.stringify(expected) + " but got " + JSON.stringify(actual));
}
},
toBeTruthy() {
if (!actual) {
throw new Error("Expected truthy but got " + JSON.stringify(actual));
}
},
toBeFalsy() {
if (actual) {
throw new Error("Expected falsy but got " + JSON.stringify(actual));
}
},
toContain(item) {
if (!actual.includes(item)) {
throw new Error("Expected " + JSON.stringify(actual) + " to contain " + JSON.stringify(item));
}
},
toThrow() {
try {
actual();
throw new Error("Expected function to throw but it didn't");
} catch (e) {
if (e.message === "Expected function to throw but it didn't") throw e;
// Error was thrown as expected
}
}
};
},
run() {
console.log("Running " + this.tests.length + " tests...\n");
this.tests.forEach(function(t) {
try {
t.fn();
TestRunner.passed++;
console.log(" PASS: " + t.name);
} catch (error) {
TestRunner.failed++;
console.log(" FAIL: " + t.name);
console.log(" " + error.message);
}
});
console.log("\nResults: " + this.passed + " passed, " + this.failed + " failed");
}
};
// Now write tests using our framework!
function add(a, b) { return a + b; }
function capitalize(str) { return str.charAt(0).toUpperCase() + str.slice(1); }
function isEven(n) { return n % 2 === 0; }
TestRunner.test("add returns correct sum", function() {
TestRunner.expect(add(2, 3)).toBe(5);
TestRunner.expect(add(-1, 1)).toBe(0);
TestRunner.expect(add(0, 0)).toBe(0);
});
TestRunner.test("capitalize uppercases first letter", function() {
TestRunner.expect(capitalize("hello")).toBe("Hello");
TestRunner.expect(capitalize("world")).toBe("World");
});
TestRunner.test("isEven correctly identifies even numbers", function() {
TestRunner.expect(isEven(4)).toBeTruthy();
TestRunner.expect(isEven(7)).toBeFalsy();
TestRunner.expect(isEven(0)).toBeTruthy();
});
TestRunner.run();
Test-Driven Development
TDD follows a simple cycle: write a failing test first, then write the minimum code to make it pass, then refactor. This approach ensures every piece of code has a purpose and a test:
// TDD Example: Building a Stack data structure
// Step 1: Write the tests FIRST
const tests = [];
let passCount = 0;
let failCount = 0;
function test(name, fn) { tests.push({ name, fn }); }
function expect(actual) {
return {
toBe(expected) {
if (actual !== expected) throw new Error("Expected " + expected + ", got " + actual);
},
toEqual(expected) {
if (JSON.stringify(actual) !== JSON.stringify(expected))
throw new Error("Expected " + JSON.stringify(expected) + ", got " + JSON.stringify(actual));
}
};
}
// Write tests before implementation
test("Stack starts empty", function() {
const stack = new Stack();
expect(stack.size()).toBe(0);
expect(stack.isEmpty()).toBe(true);
});
test("push adds items to the stack", function() {
const stack = new Stack();
stack.push("a");
stack.push("b");
expect(stack.size()).toBe(2);
expect(stack.isEmpty()).toBe(false);
});
test("pop removes and returns the top item", function() {
const stack = new Stack();
stack.push("a");
stack.push("b");
expect(stack.pop()).toBe("b");
expect(stack.pop()).toBe("a");
expect(stack.size()).toBe(0);
});
test("peek returns top item without removing it", function() {
const stack = new Stack();
stack.push("a");
stack.push("b");
expect(stack.peek()).toBe("b");
expect(stack.size()).toBe(2);
});
test("pop on empty stack returns undefined", function() {
const stack = new Stack();
expect(stack.pop()).toBe(undefined);
});
// Step 2: Write the implementation to make tests pass
function Stack() {
this.items = [];
}
Stack.prototype.push = function(item) {
this.items.push(item);
};
Stack.prototype.pop = function() {
return this.items.pop();
};
Stack.prototype.peek = function() {
return this.items[this.items.length - 1];
};
Stack.prototype.size = function() {
return this.items.length;
};
Stack.prototype.isEmpty = function() {
return this.items.length === 0;
};
// Step 3: Run the tests
console.log("Stack TDD Tests\n");
tests.forEach(function(t) {
try {
t.fn();
passCount++;
console.log(" PASS: " + t.name);
} catch (e) {
failCount++;
console.log(" FAIL: " + t.name + " - " + e.message);
}
});
console.log("\n" + passCount + " passed, " + failCount + " failed");
Here is the setting for the block below — the subject under test, and the frame runCase sits in:
function classify(n) {
if (n < 0) return "negative";
if (n === 0) return "zero";
return "positive";
}
function runCase(input, expected) {
// the four lines of the block below go here
}
runCase(5, "positive");
// -> PASS: classify(5) -> positive
runCase(0, "negative");
// -> FAIL: classify(0) -> zero
Arrange the code
Reassemble the body of runCase, the heart of a table-driven test runner: it takes one input and its expected result, runs the subject under test, decides whether that counts as a pass, and reports the outcome. Only one arrangement of these four lines runs at all — the other twenty-three crash. Why must they run in this order, and which constraint pins each line?
const actual = classify(input);console.log(status + ": classify(" + input + ") -> " + actual);const status = ok ? "PASS" : "FAIL";const ok = actual === expected;
Recall
Without scrolling up: the Stack you just test-drove is a constructor with methods on its prototype, and its tests check both normal behavior and the empty-stack case. Drawing on earlier lessons — what makes a unit like this straightforward to test, and what category of case must the tests deliberately include?
Testing Edge Cases
Good tests cover not just the happy path but also boundary conditions and error cases:
// Function to test
function parseAge(input) {
if (input === null || input === undefined) return null;
// An empty or whitespace-only string is not a valid age. (Number("") is 0,
// so without this guard the empty-string test below would wrongly return 0.)
if (typeof input === "string" && input.trim() === "") return null;
const age = Number(input);
if (isNaN(age)) return null;
if (!Number.isInteger(age)) return null;
if (age < 0 || age > 150) return null;
return age;
}
// Comprehensive test suite
const results = { pass: 0, fail: 0 };
function assert(condition, message) {
if (condition) {
results.pass++;
console.log(" PASS: " + message);
} else {
results.fail++;
console.log(" FAIL: " + message);
}
}
console.log("parseAge tests:\n");
// Happy path
assert(parseAge(25) === 25, "valid number returns the number");
assert(parseAge("30") === 30, "valid string number returns parsed number");
assert(parseAge(0) === 0, "zero is a valid age");
assert(parseAge(150) === 150, "150 is the max valid age");
// Edge cases
assert(parseAge(-1) === null, "negative numbers return null");
assert(parseAge(151) === null, "numbers over 150 return null");
assert(parseAge(25.5) === null, "decimals return null");
assert(parseAge("abc") === null, "non-numeric strings return null");
assert(parseAge("") === null, "empty string returns null");
// Null/undefined
assert(parseAge(null) === null, "null returns null");
assert(parseAge(undefined) === null, "undefined returns null");
// Tricky inputs
assert(parseAge("25") === 25, "string '25' is valid");
assert(parseAge(Infinity) === null, "Infinity returns null");
assert(parseAge(NaN) === null, "NaN returns null");
console.log("\n" + results.pass + " passed, " + results.fail + " failed");
One input is deliberately missing from that suite, and it is the one worth stopping on.
Predict
parseAge is the same function you just read: it rejects null, blank strings, non-numeric strings, decimals, and anything outside 0 to 150. Now it is handed a boolean. Predict what each of these three logs prints before you run it — and note that a test suite which never tried a boolean would never have told you.
function parseAge(input) {
if (input === null || input === undefined) return null;
if (typeof input === "string" && input.trim() === "") return null;
const age = Number(input);
if (isNaN(age)) return null;
if (!Number.isInteger(age)) return null;
if (age < 0 || age > 150) return null;
return age;
}
console.log(parseAge(true));
console.log(parseAge(false));
console.log(parseAge([]));Try It Yourself
Reading about test helpers is not the same as writing them. This is a build task — and here it is doubly the point, because the thing you are building is the machinery this whole lesson runs on. Every mini-framework above (the throw new Error(...) assertions, the pass/fail check counter, the tests.forEach runner) was hand-rolled. Now you build those pieces yourself: an assertion that throws a labeled message, a table-driven runner that counts passes and failures, and a spy that records how many times a function was called.
Three functions are stubbed out and the checks below them fail until each one behaves correctly. Run it as-is and it fails immediately, telling you which helper is still missing. Implement each until every check passes and it prints All checks passed. Nothing above hands you all three answers verbatim, so you will have to assemble them from the patterns you just met.
Build
Finish the build. Three test helpers are stubbed out and the checks below them fail until each one behaves correctly. Run it as-is to see which check fails first, decide what that helper is missing, then implement the three functions until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
const assert = require('assert');
// The subject under test. Do NOT change this function.
function classify(n) {
if (n < 0) return "negative";
if (n === 0) return "zero";
return "positive";
}
// TODO 1: throw an Error whose message is 'label' when actual !== expected;
// stay silent (throw nothing) when they are equal. This is the assertion
// helper every mini-framework above hand-rolled with throw new Error(...).
// expectEqual(2, 2, "adds") -> (silent)
// expectEqual(2, 3, "adds") -> throws Error with message "adds"
function expectEqual(actual, expected, label) {
// your code here
}
// TODO 2: run a table of cases against 'fn'. Each case is [input, expected].
// Call fn(input), compare to expected, and count passes and failures.
// Return an object { pass, fail }. A case passes when fn(input) === expected.
// runTests([[1, "positive"], [0, "zero"]], classify) -> { pass: 2, fail: 0 }
function runTests(cases, fn) {
// your code here
}
// TODO 3: wrap 'fn' in a spy. Return a NEW function that calls through to fn
// (returning its result) and also tracks how many times it was called on a
// '.count' property of the returned function, starting at 0.
// const spy = countCalls(classify); spy(5); spy(-1); spy.count -> 2
function countCalls(fn) {
// your code here
}
// --- Build checks: these must all pass. Do not edit below this line. ---
assert.throws(
() => expectEqual(1, 2, "mismatch label"),
{ message: "mismatch label" },
"expectEqual should throw an Error whose message is the label when values differ",
);
assert.doesNotThrow(
() => expectEqual(7, 7, "same"),
"expectEqual should stay silent (throw nothing) when actual equals expected",
);
assert.throws(
() => expectEqual("7", 7, "type matters"),
{ message: "type matters" },
"expectEqual should use strict comparison — the string '7' is not the number 7",
);
assert.deepStrictEqual(
runTests([[5, "positive"], [0, "zero"], [-3, "negative"]], classify),
{ pass: 3, fail: 0 },
"runTests should return { pass, fail } counting each case where fn(input) === expected",
);
assert.deepStrictEqual(
runTests([[5, "zero"], [0, "zero"]], classify),
{ pass: 1, fail: 1 },
"runTests should count a case as a failure when fn(input) does not equal expected",
);
const spy = countCalls(classify);
spy(5);
spy(-1);
spy(0);
assert.strictEqual(
spy.count,
3,
"countCalls should track how many times the wrapped function was called on .count",
);
assert.strictEqual(
spy(9),
"positive",
"countCalls' wrapper should return the underlying function's result",
);
console.log("All checks passed.");
console.log("Table run:", runTests([[5, "positive"], [0, "zero"], [-3, "negative"]], classify));
console.log("Spy count after 4 calls:", spy.count);Expected output:
All checks passed.
Table run: { pass: 3, fail: 0 }
Spy count after 4 calls: 4Once it passes, try two variations and predict each before running:
- Loosen the comparison. Change
expectEqualto compare with!=(loose) instead of!==(strict), then write down which build check breaks, and what the failure message says, before running. - Forget the spy's return. In
countCalls, keep the.countbump but drop thereturnin front offn(...args), then write down what each of the last two checks does — pass or fail — before running.
After you have written your prediction down, check it against what actually happens.
What you should have seen
- The
"type matters"case fails, andassert.throwsreports "Missing expected exception". Loose"7" != 7coerces tofalse, soexpectEqualstays silent exactly where it was supposed to complain. That is why an assertion helper must use strict equality: a loose one cannot see the bug class it exists to catch. - The
.countcheck still passes — the counter still increments — butspy(9)now yieldsundefined, so the "wrapper should return the underlying function's result" assert fails withundefinedvs'positive'. A spy has to call through and hand back the real result, not just tally calls.
Key Takeaways
- Use
console.warn,console.error,console.table,console.time, andconsole.groupfor richer debugging - Debug systematically: reproduce the bug, isolate it, add checkpoints, then fix it
- Unit tests verify individual functions work correctly in isolation
- Test-driven development writes tests first, then implementation, leading to better-designed code
- Always test edge cases: empty inputs, null/undefined, boundary values, and invalid data
- A simple assertion function is all you need to start testing right away
Next Steps
With testing and debugging skills in your toolkit, you are ready to learn about Web APIs and the Fetch API in the next lesson. You will use everything you have learned, including error handling, async/await, and testing, to build real-world API interactions.
Pro Tip: If a bug is hard to reproduce, add logging around the suspicious area and wait for it to happen. The logs will tell you the state of your program at the exact moment things went wrong. This is called "printf debugging" and it is still one of the most effective techniques.
Next lesson
Web APIs and Fetch
Learn the JavaScript Fetch API to make HTTP requests, handle JSON responses, and build robust API clients with error handling.
24 min