mrkeyoor.com_
Thu 06 Aug 01:01 UTC
npmTestingupdated 05 Aug 2026

chai

Chai is a standalone assertion library for Node and the browser: it gives you the expect(value).to.deep.equal(...) chains, plus assert and should styles, and stays agnostic about which test runner drives them. It was the assertion half of the classic Mocha + Chai stack for a decade, and it lives on inside modern tools too: Vitest's expect is built on Chai's core. Version 6 ships as a single bundled ESM file with zero runtime dependencies.

Verdict

Still the best standalone assertion library, and the right call next to Mocha or a custom runner. If your runner already ships an expect, and Vitest's is literally built on Chai, adding it separately buys you nothing but a second API to argue about.

API stability3/5The assertion chains themselves have been stable for a decade, but packaging has not: v5 went ESM-only and v6 removed every deep import in favor of one bundled file, each time breaking plugins and old imports.
Docs4/5chaijs.com documents every matcher with examples, the styles guide honestly explains should's downsides, and the plugin API is written up; parts of the site still read as if Mocha 3 were current.
Maintenance4/5Releases through December 2025 and a push the day before this review, with a small named core team; roughly 90 open issues and PRs and a cadence that is mostly dependency upkeep between features.
Ecosystem4/5A large official plugin catalog and the quiet distinction of powering Vitest's expect; the discount is that plugins have repeatedly needed months to catch up after each major.

Use it if

  • You run Mocha, which ships with no assertions at all and pairs with Chai by long-standing convention
  • You want one assertion vocabulary shared across unit tests in Node and integration tests running in a real browser
  • You need the plugin surface: chai-as-promised, sinon-chai, chai-http and dozens of others extend the chains for domain-specific checks
  • You prefer readable failure output; Chai's deep-equality diffs and object inspection come from purpose-built internals like deep-eql and loupe
Skip it if

Setup reality

npm install --save-dev chai and you are done dependency-wise; v6 has no runtime deps. The real work is module format and majors: v6 is ESM-only and bundled into a single index.js, so any old code or plugin importing chai/lib/* breaks, CJS suites need Node's require(esm) or a stay on v4, and TypeScript wants @types/chai matched to your major. Three majors since 2023 means old Stack Overflow answers regularly target an API arrangement that no longer exists.

Patterns

Core expect assertionsexpect-basics

import { expect } from 'chai'

expect(user.name).to.equal('ada')
expect(user.tags).to.have.lengthOf(3)
expect(user.active).to.be.true
expect(user.deletedAt).to.be.null

Chain words like to, be, and have are pure sugar with no behavior of their own; .equal is strict === while object comparison needs .deep (next pattern).

Compare objects and arrays structurallydeep-equality

import { expect } from 'chai'

expect(result).to.deep.equal({ id: 1, tags: ['a', 'b'] })

// deep also applies to include and members
expect(list).to.deep.include({ id: 2 })

Without .deep, .equal on two identical-looking objects fails on reference identity. Deep comparison is structural via deep-eql, so it handles Maps, Sets, and cycles.

Classic assert style instead of chainsassert-style

import { assert } from 'chai'

assert.strictEqual(sum(2, 2), 4)
assert.deepStrictEqual(cfg, { retries: 3 })
assert.isTrue(flag, 'flag should be set')
assert.throws(() => parse(''), TypeError)

Same engine, TDD-flavored surface, and the optional message argument reads naturally here. Pick one style per repo; mixing expect and assert in one suite is noise.

Assert that a function throwsthrow-assertions

import { expect } from 'chai'

expect(() => JSON.parse('{')).to.throw(SyntaxError)
expect(() => validate(user)).to.throw(/email is required/)
expect(() => safeOp()).to.not.throw()

Pass the function itself, not its result: expect(fn()).to.throw() runs fn before Chai can catch anything and the test explodes instead of asserting.

Assert promise rejections with chai-as-promisedasync-rejections

import * as chai from 'chai'
import chaiAsPromised from 'chai-as-promised'

chai.use(chaiAsPromised)
const { expect } = chai

await expect(fetchUser(-1)).to.be.rejectedWith(RangeError)
await expect(fetchUser(1)).to.eventually.have.property('name')

Chai core does not await anything. You need chai-as-promised v8+ next to chai 5/6, and you must await the assertion itself or the test passes before the promise settles.

Register a plugin once for the whole suiteregister-plugin

// test/setup.js
import * as chai from 'chai'
import sinonChai from 'sinon-chai'

chai.use(sinonChai)

// mocha: mocha --require ./test/setup.js
// then in tests: expect(spy).to.have.been.calledOnce

chai.use mutates the shared chai instance, so one setup file loaded first covers every spec. Check a plugin's peer range before upgrading chai majors; that is where breakage shows up.

Assert on nested pathsnested-properties

import { expect } from 'chai'

expect(res.body).to.have.nested.property('user.roles[0]', 'admin')
expect(cfg).to.have.property('timeout').that.is.a('number').above(0)

nested enables dot and bracket path syntax; escape real dots in key names with double backslashes. The .that chain lets you keep asserting on the property's value.

Compare arrays ignoring orderarray-members

import { expect } from 'chai'

expect([3, 1, 2]).to.have.members([1, 2, 3])
expect(rows).to.have.deep.members([{ id: 1 }, { id: 2 }])
expect(perm).to.include.members([2, 3])

members is set-style comparison, so order does not matter; add .ordered when it should. Use deep.members for arrays of objects or every element fails on identity.

Floating point and range assertionsapproximate-numbers

import { expect } from 'chai'

expect(0.1 + 0.2).to.be.closeTo(0.3, 1e-9)
expect(latencyMs).to.be.within(0, 250)
expect(score).to.be.at.least(0.9)

closeTo takes an absolute delta, not a percentage. Never .equal floats; the 0.1 + 0.2 case is the canonical failure.

Check a subset of an object's keyspartial-object-match

import { expect } from 'chai'

expect(response).to.deep.include({ status: 'ok', code: 200 })
expect(user).to.include.all.keys('id', 'email')
expect(user).to.not.have.any.keys('password', 'ssn')

deep.include on an object asserts a subset without listing every field, which keeps tests from breaking when unrelated fields are added.

Alternatives

PackageRegistryPick it when
vitestnpmStarting fresh in 2026: its built-in expect is Chai-based, so you get the same chains without wiring anything.
expectnpmYou want Jest's assertion API standalone, including its mock-aware matchers, inside another runner.
jestnpmYou want runner, mocking, snapshots, and assertions as one integrated, zero-decision package.