TL;DR
Learn JavaScript testing and debugging. Write unit tests, use TDD, master console methods, and build reliable debugging strategies.
Key concepts
- JavaScript testing
- JavaScript debugging
- unit testing JS
- test driven development JavaScript
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
- How to use console methods beyond
console.log - Debugging strategies and techniques
- How to write unit tests from scratch
- Test-driven development (TDD) basics
- Assertion patterns for validating behavior
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.
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
Reproduce-and-isolate found it: the member bonus was a rate (0.05) added as if it were already dollars. The "none" case passed only because its bonus was zero, masking the defect — which is exactly why a function that is "right sometimes" needs isolation, not guesswork. The fix, baseDiscount + (price * memberBonus), converts the rate to an amount before adding.
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");
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(true) === 1, "boolean true converts to 1");
assert(parseAge(Infinity) === null, "Infinity returns null");
assert(parseAge(NaN) === null, "NaN returns null");
console.log("\n" + results.pass + " passed, " + results.fail + " failed");
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: 4
Once it passes, try two variations and predict each before running:
- Loosen the comparison. Change
expectEqualto compare with!=(loose) instead of!==(strict), then predict which build check breaks. The"type matters"case fails —assert.throwsreports "Missing expected exception" — because loose"7" != 7coerces tofalse, soexpectEqualstays silent when it should have thrown. An instructive assert failure showing why an assertion helper must use strict equality. - Forget the spy's return. In
countCalls, keep the.countbump but drop thereturnin front offn(...args), then predict the last two checks. The.countcheck still passes (the counter still increments), butspy(9)now yieldsundefined, so the "wrapper should return the underlying function's result" assert fails —undefinedvs'positive'. An instructive assert failure showing a spy must 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.
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.