Mocha & Chai
A practical reference for writing tests with Mocha (the runner) and Chai (the assertion library) — the classic JavaScript testing combo, still common in API tests, library code, and Cypress's underlying runner.
Test Structure
Mocha organises tests with describe() (suites) and it() (test cases). Both take a name and a callback.
describe("Login Page", function () {
describe("with valid credentials", function () {
it("redirects to dashboard", function () {
// test body
});
it("displays a welcome message", function () {
// test body
});
});
describe("with invalid credentials", function () {
it("shows an error message", function () {
// test body
});
});
});Aliases
| Function | Alias | When |
|---|---|---|
describe | context | When the inner block describes a condition (e.g. context("when logged out")) |
it | specify | Same as it — choose what reads best |
Focused / skipped
describe.only("just this suite", function () { /* ... */ });
it.only("just this test", function () { /* ... */ });
describe.skip("skip whole suite", function () { /* ... */ });
it.skip("skip this test", function () { /* ... */ });.only is great while debugging — but a leftover .only in CI silently passes only the focused tests. Most teams ban it via lint rules (mocha/no-exclusive-tests).
Pending tests
it("should also handle X"); // no callback — marked pendingHooks
describe("UserService", function () {
before(function () { // once before any test in this block
// start the test DB, seed data
});
after(function () { // once after all tests
// stop the test DB
});
beforeEach(function () { // before EVERY test, including nested
// reset state
});
afterEach(function () { // after EVERY test
// clean up
});
it("creates a user", function () { /* ... */ });
});Named hooks
Mocha shows the hook name on failure — useful when a hook causes the failure:
before("seed test database", async function () {
await db.migrate();
await db.seed();
});Nested hooks
Outer beforeEach runs before inner beforeEach. Outer afterEach runs after inner afterEach. This lets you layer setup:
describe("Cart", function () {
beforeEach(function () { this.cart = new Cart(); });
describe("when empty", function () {
it("total is 0", function () {
expect(this.cart.total()).to.equal(0);
});
});
describe("with items", function () {
beforeEach(function () { this.cart.add({ price: 10 }); });
it("total reflects the items", function () {
expect(this.cart.total()).to.equal(10);
});
});
});Root-level hooks
Hooks declared outside any describe apply to every test in the run. Use sparingly — they're easy to forget.
Chai Assertion Styles
Chai ships three interfaces. Pick one and stick with it.
// BDD — most popular
const { expect } = require("chai");
expect(value).to.equal(expected);
// BDD — adds .should onto Object.prototype (mutates global!)
const should = require("chai").should();
value.should.equal(expected);
// TDD — Node's `assert`-style
const { assert } = require("chai");
assert.equal(actual, expected);Equality and truthiness
expect(result).to.equal(42); // ===
expect(result).to.not.equal(0);
expect(obj).to.deep.equal({ name: "QA" }); // structural compare
expect(obj).to.eql({ name: "QA" }); // alias for deep.equal
expect(value).to.be.true;
expect(value).to.be.false;
expect(value).to.be.null;
expect(value).to.be.undefined;
expect(value).to.exist; // not null and not undefined
expect(value).to.be.ok; // truthyType checking
expect("hello").to.be.a("string");
expect([1, 2]).to.be.an("array");
expect(42).to.be.a("number");
expect({}).to.be.an("object");
expect(() => {}).to.be.a("function");
expect(null).to.be.a("null");Numeric comparison
expect(10).to.be.above(5);
expect(10).to.be.below(20);
expect(10).to.be.at.least(10);
expect(10).to.be.at.most(10);
expect(5).to.be.within(1, 10);
expect(0.1 + 0.2).to.be.closeTo(0.3, 0.0001);Strings
expect("hello world").to.include("world");
expect("hello world").to.contain("world"); // alias
expect("hello").to.have.lengthOf(5);
expect("hello").to.have.length.above(3);
expect("test@example.com").to.match(/^.+@.+\..+$/);
expect("Hello").to.startWith("He");
expect("Hello").to.endWith("lo");Arrays
expect([1, 2, 3]).to.include(2);
expect([1, 2, 3]).to.have.lengthOf(3);
expect([1, 2, 3]).to.have.members([3, 2, 1]); // same items, any order
expect([1, 2, 3]).to.have.ordered.members([1, 2, 3]);
expect([1, 2, 3]).to.deep.include({ id: 1 }); // for arrays of objects
expect([1, 2, 3]).to.be.an("array").that.is.not.empty;Objects
expect(obj).to.have.property("name");
expect(obj).to.have.property("name", "QA");
expect(obj).to.have.nested.property("user.address.city");
expect(obj).to.have.all.keys("id", "name", "email");
expect(obj).to.have.any.keys("name", "alias");
expect(obj).to.have.deep.property("data", { id: 1 });
expect(obj).to.deep.include({ name: "QA" }); // partial deep matchErrors
expect(() => badFn()).to.throw();
expect(() => badFn()).to.throw(Error);
expect(() => badFn()).to.throw("error message");
expect(() => badFn()).to.throw(TypeError, /invalid/);
expect(() => goodFn()).to.not.throw();Async Testing
Three ways — pick the one that matches your code style.
async / await — the modern default
it("fetches a user", async function () {
const user = await api.getUser(1);
expect(user).to.have.property("name");
expect(user.name).to.equal("Ada");
});Returning a promise
it("fetches a user", function () {
return api.getUser(1).then(user => {
expect(user.name).to.equal("Ada");
});
});Mocha sees the returned promise and waits for it.
done callback (older code only)
it("loads with a callback", function (done) {
fs.readFile("config.json", (err, data) => {
if (err) return done(err);
expect(JSON.parse(data)).to.have.property("apiUrl");
done();
});
});Pick one per test. Mixing
donewithasync/promise return causes Mocha to either time out or pass before the assertions finish.
Timeouts
it("slow integration test", async function () {
this.timeout(15_000); // 15 seconds for THIS test
await someSlowOperation();
});
describe("integration", function () {
this.timeout(30_000); // applies to every test in this block
it("...", async function () { /* ... */ });
});
// Disable timeout entirely (only when you really mean it)
this.timeout(0);Mocha's default timeout is 2000ms. Don't crank it up to hide flakes — figure out the slowness first.
Asserting on rejected promises
With chai-as-promised:
const chai = require("chai");
const chaiAsPromised = require("chai-as-promised");
chai.use(chaiAsPromised);
const { expect } = chai;
it("rejects with a useful error", async function () {
await expect(api.getUser(0)).to.eventually.be.rejectedWith("invalid id");
await expect(api.getUser(1)).to.eventually.have.property("name", "Ada");
});Configuration (.mocharc.yml)
A .mocharc.yml, .mocharc.cjs, or .mocharc.json at the project root configures Mocha for every run.
spec:
- 'test/**/*.spec.ts'
require:
- 'ts-node/register'
- './test/setup.ts'
recursive: true
timeout: 10000
slow: 2000
reporter: 'mochawesome'
reporter-option:
- 'reportDir=mochawesome-report'
- 'reportFilename=index'
parallel: true
jobs: 4
retries: 2
bail: false
extension:
- 'ts'
- 'js'| Option | Purpose |
|---|---|
spec | Glob(s) for test files |
require | Modules loaded before tests (TS register, global setup, env load) |
recursive | Search subdirectories under spec paths |
timeout | Per-test timeout (ms); 0 disables |
slow | Threshold above which a test is reported as slow |
reporter | Built-in or installed reporter name |
parallel | Run files in parallel worker processes |
jobs | Parallel worker count (defaults to CPU count − 1) |
retries | Auto-retry failing tests N times |
bail | Stop the whole run on the first failure |
extension | File extensions to consider as tests (with recursive) |
forbid-only | Fail the run if any .only is left — recommended for CI |
CLI flags override config: npx mocha --grep "login" --bail.
Reporters
Built-in
npx mocha --reporter spec # default — hierarchical
npx mocha --reporter dot # dot per test
npx mocha --reporter nyan # the cat
npx mocha --reporter json # machine output
npx mocha --reporter min # only pass/fail line
npx mocha --reporter progress # bar
npx mocha --reporter list # one line per testmochawesome — single-file HTML
npm install -D mochawesome
npx mocha --reporter mochawesome \
--reporter-options reportDir=mocha-report,reportFilename=indexProduces mocha-report/index.html — self-contained, easy to attach as a CI artifact.
mocha-junit-reporter — JUnit XML for CI
npm install -D mocha-junit-reporter
npx mocha --reporter mocha-junit-reporter \
--reporter-options mochaFile=test-results/junit.xmlGitHub Actions, Jenkins, GitLab — they all parse this format.
Multiple reporters at once — mocha-multi-reporters
npm install -D mocha-multi-reporters mocha-junit-reporter mochawesomereporter-options.json:
{
"reporterEnabled": "spec, mocha-junit-reporter, mochawesome",
"mochaJunitReporterReporterOptions": { "mochaFile": "test-results/junit.xml" },
"mochawesomeReporterOptions": { "reportDir": "mochawesome-report" }
}Run:
npx mocha --reporter mocha-multi-reporters \
--reporter-options configFile=reporter-options.jsonChai Plugins
Chai is small by default — plugins add the matchers you need.
| Plugin | Adds |
|---|---|
chai-http | HTTP assertions: chai.request(app).get('/users').end(...) |
chai-as-promised | eventually / rejectedWith / fulfilled for promise assertions |
chai-things | Array element matchers: arr.should.contain.something.like({ … }) |
chai-subset | Partial object match: expect(obj).to.containSubset({ … }) |
chai-datetime | closeToTime, withinTime, ISO-string equality |
sinon-chai | expect(spy).to.have.been.calledOnceWith(arg) |
const chai = require("chai");
chai.use(require("chai-as-promised"));
chai.use(require("sinon-chai"));
chai.use(require("chai-subset"));
expect(promise).to.eventually.equal("ok");
expect(actual).to.containSubset({ user: { id: 1 } });
expect(spy).to.have.been.calledWith("foo");Mocha with Sinon (Mocking)
Sinon provides spies, stubs, mocks, and fake timers. It pairs naturally with Mocha — afterEach(() => sinon.restore()) is the cleanup pattern.
Spies — observe without changing behaviour
const sinon = require("sinon");
const sinonChai = require("sinon-chai");
chai.use(sinonChai);
it("logs each request", function () {
const log = sinon.spy(console, "log");
service.handle("hello");
expect(log).to.have.been.calledOnce;
expect(log).to.have.been.calledWith("[svc] hello");
log.restore();
});Stubs — replace behaviour
afterEach(() => sinon.restore());
it("calls the API with the right id", async function () {
const stub = sinon.stub(api, "getUser").resolves({ id: 1, name: "Ada" });
const user = await userService.load(1);
expect(stub).to.have.been.calledOnceWith(1);
expect(user.name).to.equal("Ada");
});
it("retries on transient failure", async function () {
const stub = sinon
.stub(api, "getUser")
.onFirstCall().rejects(new Error("503"))
.onSecondCall().resolves({ id: 1 });
const user = await retryingClient.load(1);
expect(stub).to.have.been.calledTwice;
expect(user.id).to.equal(1);
});Mocks — set expectations up front
it("calls save exactly once", function () {
const mock = sinon.mock(repo);
mock.expects("save").once().withArgs(sinon.match({ id: 42 }));
service.persist({ id: 42, name: "Ada" });
mock.verify();
});Fake timers — control time
it("fires the callback after 1s", function () {
const clock = sinon.useFakeTimers();
const cb = sinon.spy();
scheduleAfter(1000, cb);
clock.tick(999);
expect(cb).to.not.have.been.called;
clock.tick(1);
expect(cb).to.have.been.calledOnce;
clock.restore();
});useFakeTimers() mocks Date.now(), setTimeout, setInterval, requestAnimationFrame, process.nextTick — useful for testing scheduling logic without sleeping.
sinon.restore() discipline
Stubs and spies modify their target. If you forget to restore, later tests see the stubbed behaviour. Always pair with cleanup:
describe("UserService", function () {
afterEach(() => sinon.restore()); // ← undoes every stub/spy/mock from the test
});