Skip to main content
Back to Blog
Reference
2026-07-18

Chai Assertions Cheat Sheet: expect, should, and assert

Use this Chai assertions cheat sheet to choose expect, should, or assert, test async failures, inspect objects, and write clearer JavaScript tests.

Chai Assertions Cheat Sheet: expect, should, and assert

Chai gives JavaScript test suites three assertion styles over the same core behavior: expect, should, and assert. The useful question is not which style is fashionable. It is which expression makes a failed test explain the contract with the least ambiguity.

This reference uses realistic API, UI-support, and data-validation examples. Every snippet can sit inside a Mocha test after installing chai. If you are still selecting a runner, read the JavaScript testing frameworks guide. For a complete runner setup, hooks, and suite structure, pair this sheet with the Mocha and Chai testing guide.

Choose an assertion style before the suite chooses for you

All three interfaces ultimately express pass or fail, but they optimize different reading patterns.

StyleTypical formStrongest fitMain caution
expectexpect(actual).to.equal(expected)Behavior-focused tests and fluent chainsA very long chain can hide the important comparison
shouldactual.should.equal(expected)Teams that prefer sentence-like assertionsIt extends Object.prototype and cannot start naturally from null or undefined
assertassert.equal(actual, expected)Compact checks and engineers familiar with xUnit APIsArgument order varies by method, so review expected versus actual carefully

Use one primary style per repository. Mixing styles inside a single test makes scanning harder, especially when an AI coding agent adds assertions from a different convention. A repository instruction such as “use Chai expect, include a failure message on business-critical values” gives both humans and agents a stable pattern.

import { expect, assert, should } from 'chai';

should();

describe('checkout summary', () => {
  const summary = { itemCount: 2, currency: 'INR', total: 1498 };

  it('supports all three interfaces', () => {
    expect(summary.total).to.equal(1498);
    summary.currency.should.equal('INR');
    assert.strictEqual(summary.itemCount, 2);
  });
});

Equality: decide whether identity or structure is the contract

The most common Chai mistake is using equal for objects. equal uses strict equality, so two separately created objects with the same fields are not equal. Use deep.equal when the object or array structure is the intended contract.

Intentexpect or should chainassert equivalent
Strict primitive or reference equality.equal(value)assert.strictEqual(actual, value)
Recursive object or array equality.deep.equal(value)assert.deepEqual(actual, value)
Deliberately non-strict equality.eql(value) for deep equality, not coercionassert.equal(actual, value) performs non-strict equality
Inequality.not.equal(value)assert.notStrictEqual(actual, value)
import { expect } from 'chai';

it('maps a service response into a stable view model', () => {
  const actual = { id: 'ORD-41', lines: [{ sku: 'BOOK', quantity: 2 }] };

  expect(actual).to.deep.equal({
    id: 'ORD-41',
    lines: [{ sku: 'BOOK', quantity: 2 }],
  });
  expect(actual.lines).to.have.lengthOf(1);
});

Deep equality is excellent for small, stable value objects. It is brittle for entire HTTP responses containing timestamps, request IDs, or optional fields. In that case, assert the schema-relevant pieces instead of deleting useful fields from production code to satisfy a test.

Objects and arrays: assert the boundary, not incidental representation

Chai property and membership assertions let a test describe just the boundary it owns. include checks array membership or an object subset. members checks array members without requiring the same order, while ordered.members makes order significant. keys can verify exact or included key sets depending on the chain.

import { expect } from 'chai';

it('returns an accessible user search result', () => {
  const result = {
    id: 'u-17',
    displayName: 'Mina Shah',
    roles: ['editor', 'reviewer'],
    metadata: { source: 'directory', refreshedAt: '2026-07-18T08:00:00Z' },
  };

  expect(result).to.include({ id: 'u-17', displayName: 'Mina Shah' });
  expect(result).to.have.property('roles').that.includes('reviewer');
  expect(result.roles).to.have.members(['reviewer', 'editor']);
  expect(result.metadata).to.have.all.keys('source', 'refreshedAt');
});

For arrays of objects, deep.include is usually clearer than mapping to JSON strings. If duplicates matter, add a length check because membership alone may not communicate cardinality. For a paginated API, assert the returned IDs, count, and ordering separately so a failure says whether selection, pagination, or sorting broke.

Types, presence, and numeric ranges

Type assertions catch adapter errors before downstream assertions produce confusing messages. Chai's a and an language chains accept standard JavaScript type names. Presence needs more care: exist rejects both null and undefined, while undefined, null, true, and false target exact values.

QA contractRecommended assertionWhat it prevents
Generated identifier existsexpect(id).to.existAccepting missing values before later API calls
Flag is truly booleanexpect(enabled).to.be.a('boolean')Strings such as "false" passing truthiness checks
Latency meets an SLOexpect(ms).to.be.lessThan(500)A broad equality assertion on variable timing
Retry count is boundedexpect(count).to.be.within(1, 3)Infinite or unexpectedly eager retries
Text follows a stable patternexpect(code).to.match(/^QA-\d{4}$/)Overfitting to one generated value
it('records a bounded retry after rate limiting', async () => {
  const audit = await runRateLimitedRequest();

  expect(audit.requestId).to.be.a('string').and.not.empty;
  expect(audit.retryCount).to.be.within(1, 3);
  expect(audit.elapsedMs).to.be.greaterThan(0);
  expect(audit.recovered).to.be.true;
  expect(audit.error).to.be.undefined;
});

Avoid expect(value).to.be.ok when the difference between zero, an empty string, null, and false matters. “Truthy” is a JavaScript implementation detail, not usually a business requirement.

Exceptions and rejected promises need different handling

throw inspects a function. Passing the result of a function calls it before Chai can observe the exception. Wrap synchronous work in a function, then match the error type or message. Do not assert an entire volatile message if a stable fragment or error class expresses the contract.

import { expect } from 'chai';

function parseQuantity(raw: string): number {
  const value = Number(raw);
  if (!Number.isInteger(value) || value < 1) {
    throw new RangeError('quantity must be a positive integer');
  }
  return value;
}

it('rejects a fractional quantity', () => {
  expect(() => parseQuantity('1.5'))
    .to.throw(RangeError, 'positive integer');
});

Core Chai does not turn a promise rejection into a throw assertion. Without a promise assertion plugin, use try/catch and ensure the success path cannot silently pass. This explicit pattern is portable and makes the caught value available for detailed checks.

it('rejects an expired session', async () => {
  let caught: unknown;

  try {
    await authenticate({ token: expiredToken });
  } catch (error) {
    caught = error;
  }

  expect(caught).to.be.instanceOf(AuthenticationError);
  expect(caught).to.have.property('code', 'TOKEN_EXPIRED');
});

An alternative is Node's promise rejection assertion from node:assert/strict, but do not mix it casually into a Chai-style suite. If your project adopts a Chai plugin for promise assertions, document the plugin initialization in the shared test bootstrap so local runs and CI load the same behavior.

Strings, regular expressions, and diagnostic messages

Use include for a stable substring and match for a genuine format. Regex anchors matter. /QA-\d+/ accepts extra text, while /^QA-\d+$/ requires the whole string to conform. When AI generates a regex, review escaping and add negative examples alongside the happy path.

Custom assertion messages help when data-driven cases otherwise look identical. With expect, pass a message as the second argument to expect. With the assert interface, many methods accept a final message parameter.

for (const sample of [
  { locale: 'en-IN', text: 'Payment declined', fragment: 'declined' },
  { locale: 'fr-FR', text: 'Paiement refusé', fragment: 'refusé' },
]) {
  it(`shows the decline reason for ${sample.locale}`, () => {
    expect(
      sample.text,
      `rendered message for ${sample.locale}`,
    ).to.include(sample.fragment);
  });
}

Notice that the source file must preserve JavaScript interpolation if this example is copied. Inside this article module, interpolation markers are escaped so the blog template remains literal.

Negation, chaining, and failure quality

Negation is valuable when the forbidden outcome is the contract, such as ensuring a response does not expose passwordHash. It becomes weak when it leaves many incorrect values acceptable. expect(status).not.to.equal(500) still permits 404, 401, or 302. Prefer expect(status).to.equal(200) when the correct outcome is known.

Chaining can combine closely related facts, but one assertion per concept often diagnoses failures better. If a chain checks type, length, content, and order, the first failure may conceal the actual regression. Split checks at system boundaries and keep compact chains for local invariants such as “a non-empty string.”

When reviewing agent-generated tests, ask three questions:

  1. Could an obviously wrong value still pass?
  2. Does the assertion verify observable behavior rather than a private implementation detail?
  3. Would the failure message identify the broken contract without rerunning locally?

That review catches tautologies such as comparing a value to itself, assertions calculated with the same production helper, and snapshots so broad that reviewers stop reading them.

A compact migration map between interfaces

Contractexpectshouldassert
Exact statusexpect(status).to.equal(201)status.should.equal(201)assert.strictEqual(status, 201)
Deep body matchexpect(body).to.deep.equal(expected)body.should.deep.equal(expected)assert.deepEqual(body, expected)
Contains roleexpect(roles).to.include('admin')roles.should.include('admin')assert.include(roles, 'admin')
Error typeexpect(fn).to.throw(TypeError)fn.should.throw(TypeError)assert.throws(fn, TypeError)
Numeric ceilingexpect(ms).to.be.at.most(500)ms.should.be.at.most(500)assert.isAtMost(ms, 500)

Mechanical migration is possible, but preserve semantics. In particular, distinguish strict equality from non-strict assert.equal, and do not convert a deep comparison into reference equality. Run the smallest affected suite after each batch, inspect at least one intentional failure, and let the failure output confirm that expected and actual values are oriented correctly.

Frequently Asked Questions

Should a new Chai suite use expect, should, or assert?

Use expect unless your team already has a consistent convention. It reads naturally, handles null and undefined without the receiver problem of should, and makes strict versus deep equality visible in the chain. The larger quality gain comes from consistency: configure examples, contributor guidance, and AI-agent instructions around one style, then allow exceptions only when another interface materially improves clarity.

Why does equal fail for two objects that look identical?

equal checks strict identity. Two object literals occupy different references even when every property matches. Use deep.equal or assert.deepEqual when recursive values are the contract. Before switching, confirm that structural equality is truly intended. If code is supposed to return the same cached object instance, strict equality is the correct assertion and the failure is useful.

How should I assert a promise rejection without adding a plugin?

Await the operation inside try/catch, store the caught error, and assert its type plus a stable property such as an application error code. Ensure the test fails when no rejection occurs, which the stored-error assertion naturally does. This is more verbose than plugin syntax but requires no Chai extension and allows precise inspection of custom error fields, nested causes, or safe message fragments.

Are deep assertions safe for complete API responses?

They are safe but often too broad. Full responses commonly include generated IDs, timestamps, feature-dependent fields, and ordering that the test does not own. A deep assertion then turns harmless evolution into noise. Prefer a deep comparison for stable nested contracts, plus focused property, membership, and type assertions for variable regions. Keep a separate schema or contract test when the complete response shape itself is the behavior under test.