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
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.