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.