JavaScript Testing
A quick reference for JavaScript testing concepts — why testing matters, types of tests, testing methodologies, and writing tests with Mocha and Node's assert library.
Why Test?
Why Test? — automated testing catches bugs before users do, is faster and more reliable than manual testing, and reduces the cost of fixing errors later.
| Term | Description |
|---|---|
| Bug | An error, fault, or flaw in software that makes it behave unexpectedly. |
| Manual testing | A human interacts with the app (clicking, typing) and compares actual vs expected behaviour. |
| Automated testing | Software runs the tests and compares actual vs expected behaviour. Faster, more reliable, and maintainable than manual testing. |
| Test suite | The collection of all tests for an application. |
| Implementation code | The code that defines how the app works (e.g. index.js). |
| Test code | The code that defines the tests, usually named to match (e.g. index-test.js) and stored alongside the implementation code. |
| Regression | When a feature that previously worked breaks because of a later change elsewhere in the code. |
Tests as documentation:
What separates tests from other documentation: they can be EXECUTED,
so the outcome reflects the CURRENT state of the software. Plain-text
docs just describe behaviour and can go stale — tests prove it right now.
Note: testing reduces bugs and the time spent fixing them, but it
cannot eliminate all bugs — new ones can always appear from code
changes or external dependencies.
it('accepts the customer name', () => {
const name = 'Hungry Person';
browser.url('/');
browser.setValue('#name', name);
browser.click('#submit-order');
browser.url('/');
assert.include(browser.getText('#deliver-to'), name);
});
Typical workflow:
1. Write code and corresponding tests
2. Run the test suite (e.g. npm test)
3. All tests pass → development complete
4. A test fails → fix the code, return to step 2
Running the full suite after every change catches regressions immediately —
if an old test starts failing after a new feature is added, something broke.
Testing Types
| Test type | Scope | External services (DB/API) |
|---|---|---|
| Unit test | Smallest testable unit — a single function in isolation. | Mocked — fake data/behaviour stands in for the real thing. |
| Integration test | How units work together (e.g. is fetched data formatted correctly for display). | The handling of incoming data is tested, but the data itself is still mocked. |
| End to end test | Full user flow through the app, as a real user would experience it (also called e2e / UI layer test). | Real — actual database and external API are used. |
Testing pyramid — unit → integration → e2e:
Each step up increases resource intensity (time, computation, money)
and the amount of code being tested. Unit tests are written first,
but in a mature app all three run together, each giving different feedback.
Typical PR feedback loop:
1. Make code changes
2. Open a pull request
3. Tests run automatically (unit, integration, sometimes e2e)
4. If a test fails → fix locally, push again
5. Repeat until all tests pass → PR can be merged
Testing Methodologies
| Methodology | Description |
|---|---|
| Test-first approach | Tests are written BEFORE the code that satisfies them — flips the usual order. Covers TDD, BDD, SBE, and ATDD. |
| TDD (Test-driven Development) | Test-first. Smallest unit tested is a function/class. Forces developers to understand requirements before writing code — only code that is needed gets written. |
| BDD (Behavior-driven Development) | Test-first, like TDD, but the smallest unit is a "feature", not a function. Tests are written in plain language with input from product owners/stakeholders, so they describe how the product behaves rather than technical implementation detail. |
TDD vs BDD — what differs:
TDD → unit = function/class, language is technical, written by developers
BDD → unit = feature, language is plain English, written collaboratively
with product owners/stakeholders so it reads from a user's perspective
A team is not locked into one methodology — different approaches can be
combined at different stages of development. TDD is the main methodology
used going forward in this course.
Mocha — Automate and Organize Tests
| Command / Syntax | Description |
|---|---|
| npm init | Creates package.json to manage the project's packages. |
| npm install mocha -D |
Installs Mocha as a DEV dependency (devDependencies in
package.json — not bundled into production).
Capital -D matters: lowercase -d is npm's verbose log-level flag, not --save-dev. |
| "test": "mocha" | Add to scripts in package.json so npm test runs Mocha, instead of calling the binary directly from node_modules. |
| describe(string, callback) | Groups related tests. Nest describe blocks to mirror the structure of the implementation code. |
| it(string, callback) | Defines a single test with a human-readable description. |
| assert.ok(expr) | From Node's assert module. Throws an AssertionError if expr is falsy — Mocha catches it and reports the test as failed. |
| before(callback) | Runs once, before the first test in the describe block. |
| beforeEach(callback) | Runs before every test in the describe block — common setup, deduplicated. |
| afterEach(callback) | Runs after every test — common teardown, deduplicated. |
| after(callback) | Runs once, after the last test in the describe block. |
Importing your OWN implementation code to test it:
require() is also how you pull in the file you're actually testing,
not just built-in/npm modules like 'assert' or 'fs'.
// index.js — the implementation file
const rooster = { crow: () => 'cock-a-doodle-doo' };
module.exports = rooster; // <-- makes rooster importable
// test/index_test.js — the test file
const assert = require('assert');
const rooster = require('../index.js'); // .js extension is optional —
// require('../index') also works
// ../ because test files usually
// live in a test/ subfolder
describe('rooster', () => {
it('crows', () => {
assert.strictEqual(rooster.crow(), 'cock-a-doodle-doo');
});
});
const assert = require('assert');
describe('Math', () => {
describe('.max', () => {
it('returns the argument with the highest value', () => {
assert.ok(Math.max(1, 2) === 2);
});
});
});
Nesting rule: methods on the SAME object go inside ONE outer describe
block, as SIBLING inner describe blocks — not as two separate top-level
describe blocks.
const cup = {};
cup.pour = () => 'Poured once!';
cup.fill = () => 'Cup filled!';
// Correct — one 'cup' block, two methods nested as siblings inside it:
describe('cup', () => {
describe('.pour', () => {
// test pour here
});
describe('.fill', () => {
// test fill here
});
});
// Wrong — two separate top-level 'cup' blocks (loses the shared structure):
describe('cup', () => {
describe('.pour', () => { });
});
describe('cup', () => {
describe('.fill', () => { });
});
The four phases of a test:
1. Setup — prepare anything the test needs (variables, files, etc.)
2. Exercise — EXECUTE the functionality being tested (just run it, don't check anything yet)
3. Verify — CHECK the result of the exercise phase against what was expected
4. Teardown — reset the environment back to how it was before the test
Easy mix-up: Exercise = run the code. Verify = check the outcome.
They are two separate steps — exercise does not compare anything itself.
Teardown matters because tests run in succession and may share state.
Without it, one test's leftover changes (a file, a record, a value)
can cause the NEXT test to fail, or the SAME test to fail on a second run.
// fs example — teardown deletes the file so the next test/run starts clean
const fs = require('fs');
let path, str;
describe('appendFileSync', () => {
before(() => {
path = './message.txt';
});
afterEach(() => {
fs.unlinkSync(path);
});
it('writes a string to a text file', () => {
str = 'Hello Node.js';
fs.appendFileSync(path, str);
const contents = fs.readFileSync(path);
assert.equal(contents.toString(), str);
});
it('writes an empty string to a text file', () => {
str = '';
fs.appendFileSync(path, str);
const contents = fs.readFileSync(path);
assert.equal(contents.toString(), str);
});
});
Hook execution order — before runs ONCE up front, afterEach runs after
EACH individual test (not just at the very end):
describe('interrupting cow', () => {
before(() => {
console.log('cow enters the conversation...');
});
afterEach(() => {
console.log('MOO!');
});
it('has four legs', () => {});
it('moos above 20 decibels', () => {});
});
// Output:
// cow enters the conversation...
// has four legs
// MOO!
// moos above 20 decibels
// MOO!
Watch out — Future Alan's mistakes to avoid repeating:
When moving repeated setup/teardown lines OUT of it() blocks and INTO a
hook, remove the line from EVERY it() block it was duplicated in — not
just the first one. A half-finished refactor leaves stale duplicate code
that's easy to miss.
.pop() gotcha (see Arrays section on the JavaScript page): it mutates the
array and returns the REMOVED item, not the array. In tests, .pop() must
be called on the actual variable — calling it on a fresh array literal
like ['a','b'].pop() does nothing useful, since nothing holds a reference
to that array afterward.
Characteristics of a good test:
Fast — runs quickly so it can be run often
Complete — covers all the relevant cases, including edge cases
Reliable — passes/fails consistently, not flaky
Isolated — does not depend on or affect other tests (teardown helps)
Maintainable — easy to read, update, and extend
Expressive — describe()/it() strings clearly explain what is being tested
Write Expressive Tests — assert methods
An expressive test states its intent up front through the method name, rather than hiding the check inside an expression the reader has to parse. assert.ok(result === expected) works, but assert.equal(result, expected) tells you what's being checked before you even read the arguments.
| Method | Comparison | Use for |
|---|---|---|
| assert.ok(expr) | Truthy check on a single expression. | General assertions — least expressive, since the check is hidden inside the expression. |
| assert.equal(a, b) | Loose == — converts types before comparing. | Equality of primitives where type doesn't matter. For objects/arrays this still compares by REFERENCE, not contents — two separate arrays with the same values will throw. |
| assert.strictEqual(a, b) | Strict === — no type conversion. | Equality where type matters. Recommended over assert.equal() since the official docs' July 2021 update. Same reference-only caveat for objects/arrays. |
| assert.notStrictEqual(a, b) | Strict !==. | Confirming two values are NOT strictly equal. |
| assert.deepEqual(a, b) | Loose comparison of VALUES inside objects/arrays — converts types before comparing each property/element. | Comparing two distinct objects or arrays where type of individual values doesn't matter. |
| assert.deepStrictEqual(a, b) | Strict comparison of VALUES inside objects/arrays, not object reference. | Comparing two distinct objects or arrays that should contain the same data, type included. |
| assert.throws(fn, matcher) | Calls fn and asserts that it throws an error. Fails the test if fn does NOT throw. Optional second argument (e.g. a regex) checks the error MESSAGE matches too, not just that something was thrown. | Confirming that code correctly throws/rejects bad input — e.g. testing error handling itself. |
Why deepStrictEqual is needed for objects/arrays:
const a = {relation: 'twin', age: '17'};
const b = {relation: 'twin', age: '17'};
assert.equal(a, b); // throws — different objects in memory
assert.strictEqual(a, b); // throws — same reason
assert.deepStrictEqual(a, b); // passes — compares a.relation===b.relation, a.age===b.age
Arrays are objects too, so deepStrictEqual() checks each element with ===:
assert.deepStrictEqual([1, 2, 3], [1, 2, 3]); // passes
assert.deepStrictEqual([1, 2, 3], [1, 2, '3']); // throws — 3 !== '3'
assert.throws() — testing that your code throws an error correctly:
Pass a FUNCTION, not the result of calling it — assert.throws() needs to
call it itself inside a try...catch to check for the thrown error.
function checkAge(age) {
if (age < 0) {
throw Error('Age cannot be negative');
}
}
describe('checkAge', () => {
it('throws when age is negative', () => {
assert.throws(() => checkAge(-1)); // correct — passes, checkAge(-1) does throw
// assert.throws(checkAge(-1)); // WRONG — calls checkAge(-1) immediately
// while building the argument list, so
// the error throws BEFORE assert.throws()
// ever gets a chance to run, and the
// test fails with an uncaught error
});
it('throws the expected error MESSAGE', () => {
// Second argument (a regex) checks the error message itself,
// not just that SOMETHING was thrown:
assert.throws(() => checkAge(-1), /negative/);
});
});
Full list of assert methods: Node.js assert documentation
Chai is another assertion library, often paired with Mocha as an alternative to Node's built-in assert — covered properly once the lesson gets there. For now, just a reference: Chai Guide
TDD with Mocha
Test-driven development (TDD) means writing the test BEFORE the implementation code. It feels backwards at first — most coding instinct is to write code then check it works. TDD flips that: decide what "working" means first (write a failing test), let the test tell you exactly what's missing, write the smallest thing that satisfies it, repeat.
| Phase | What happens |
|---|---|
| Red | Write a test describing the behaviour you want. It SHOULD fail — there's no implementation yet, or not enough of one. |
| Green | Write the MINIMUM code needed to fix the current error message. Don't worry about quality yet — "be shameless." |
| Refactor | Clean up test and/or implementation code now that you have passing tests as a safety net. Test often during this phase — if it turns red, undo the last change. |
Running Mocha against specific files:
mocha test/index_test.js → run just this one test file
mocha test/**/*_test.js → run every file ending in _test.js,
at any depth under test/
(** = recurse through subfolders,
* = match any characters)
Codecademy note: workspaces often contain test/test.js, which runs when
you click "Check Work". mocha test/**/*_test.js targets your own test
files without re-running that one.
Full red-green-refactor walkthrough — Calculate.sum():
1. RED — write the test before Calculate exists at all:
assert.strictEqual(Calculate.sum([1,2,3]), 6);
→ ReferenceError: Calculate is not defined
2. GREEN — minimum code to satisfy THIS ONE test (deliberately fake):
const Calculate = { sum(inputArray) { return 6; } };
→ passes, but sum([4,5,6,7]) would wrongly also return 6
3. RED — add a second test that the hardcoded value can't satisfy:
assert.strictEqual(Calculate.sum([4,5,6,7]), 22);
→ AssertionError: 6 == 22 — a single hardcoded value can
never survive multiple test cases, which is what forces
a real implementation
4. GREEN — write the real logic that satisfies BOTH tests:
sum(inputArray) {
let totalSum = 0;
for (let i = 0; i < inputArray.length; i++) {
totalSum += inputArray[i];
}
return totalSum;
}
5. REFACTOR — clean up once green, e.g. swap the loop for .reduce():
sum(inputArray) {
return inputArray.reduce((acc, val) => acc + val);
}
Run tests after every refactor step to confirm still green.
Edge cases — push further once the "normal" cases pass:
Empty array breaks the .reduce() version above (no initial value to
start from, so reduce() throws on an empty array). Following TDD, write
the failing test FIRST:
it('returns zero for an empty array', () => {
const expectedResult = 0;
const inputArray = [];
const result = Calculate.sum(inputArray);
assert.strictEqual(result, expectedResult);
});
Then write the minimum fix — a guard before .reduce():
sum(inputArray) {
if (inputArray.length === 0) {
return 0;
}
return inputArray.reduce((acc, val) => acc + val);
}
Type-checking edge case, using assert.throws() with a regex to check
the error MESSAGE, not just that something was thrown:
it('raises an error if the input is not a string', () => {
const exercise = () => Phrase.initials(14);
assert.throws(exercise, /only use string/);
});
Watch out — Future Alan's notes:
Refactoring isn't just adding new code — it includes DELETING code that's
no longer needed. Swapping a for-loop for .reduce() left a leftover
`let totalSum = 0;` that nothing used anymore — dead code like that
should be removed as part of the refactor step, not left behind.
.reduce() without an initial value throws on an empty array. Either pass
a starting value — .reduce((acc, val) => acc + val, 0) — or guard for
the empty case explicitly before calling it, as shown above.
Code Coverage
Code coverage measures the percentage of application code that gets executed by the test suite. Test coverage is a related but different metric — the percentage of required FEATURES/specs that are tested, not lines of code. A program can have 100% of one and still be missing the other.
| Criteria | Question it answers |
|---|---|
| Function coverage | Has each function been called at least once? |
| Statement coverage | Has each individual statement/line been executed? |
| Path coverage | Has every edge in the control-flow graph been executed — every branch combination, not just every line? |
| Condition coverage | Has each boolean sub-expression evaluated to BOTH true and false at some point? |
function numSum(x, y) {
if (x && y) {
return (x + y);
} else {
return null;
}
}
Building up coverage on numSum(), one criterion at a time:
numSum(1, 2); // 100% FUNCTION coverage — called at least once
numSum(1, 2); // + calling with a falsy arg also executes the
numSum(1, false); // else branch → 100% STATEMENT + PATH coverage
// (both return statements have now run)
numSum(1, 2); // To get CONDITION coverage, x and y each need
numSum(1, false); // to evaluate true AND false at some point:
numSum(false, 1); // x=1(true), y=2(true)
numSum(); // x=1(true), y=false(false)
// x=false(false), y=1(true)
// x=undefined(false), y=undefined(false)
// → 100% CONDITION coverage
The 100% coverage myth:
100% coverage on ALL FOUR criteria does NOT mean the code is bug-free —
it only means the existing tests are thorough relative to OTHER test
suites with lower coverage. Coverage tracks WHETHER code ran, not
whether the result was actually correct for every kind of input.
Example — numSum() above has 100% coverage on every criterion, but:
numSum('1', '2'); // returns '12' (string concatenation), not 3
// nothing above caught this, because no test used
// string arguments — coverage can't reveal what
// you didn't think to test in the first place.
Treat coverage criteria as a guide for building testing intuition, not
a substitute for deliberately targeting real use cases and edge cases.
Mocking & Spies
| Term | Description |
|---|---|
| Mock | A fake version of an external service/dependency (API, database) used so a test can focus on just the unit being tested, without depending on — or being broken by — that other service. |
| Spy | Wraps an existing function to OBSERVE and record its calls (arguments, return value, errors thrown) without changing how it behaves. Used for assertions about HOW a function was used, not just what it returned. |
| Sinon.js | A JS library providing standalone fakes, spies, and mocks, usable with any test framework — used widely, including at Codecademy. |
Mocking in unit tests vs integration tests:
Unit tests — mock freely. A feature with steps fetch → format →
render can be tested at the render step alone by
mocking already-formatted data, skipping the first
two steps entirely (including bad/unexpected mock
input, to test error-handling display).
Integration tests — mock EXTERNAL services only (API, database), but
do NOT mock the INTERNAL functions between them
(e.g. the function that formats raw data). The
point of an integration test is to confirm internal
pieces actually work together correctly.
const robot = {
greet(name) { // Unit being tested
return 'Hello ' + name;
}
};
test('greet should return hello codey', () => {
sinon.spy(robot, 'greet'); // Setup: wrap the method with a spy
robot.greet('codey'); // Exercise: call it as normal
expect(robot.greet.called).to.be.true; // was it called at all?
expect(robot.greet.calledWith('codey')).to.be.true; // called with this arg?
expect(robot.greet.returned('Hello codey')).to.be.true; // returned this value?
robot.greet.restore(); // Teardown: remove the spy,
}); // restoring the original method
Watch out:
A spy only OBSERVES — it never changes what the wrapped function does.
That's the difference from a mock, which REPLACES real behaviour with
fake behaviour. Always call .restore() after spying, same idea as any
other teardown — leaving a spy attached can affect later tests.