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

chai-as-promised: Testing Promises with Chai

Master chai-as-promised testing promises with readable fulfillment, rejection, and eventually assertions that catch false positives in async QA suites.

chai-as-promised: Testing Promises with Chai

Promise tests are easiest to trust when the assertion reads like the contract: this operation must fulfill with a value, or this invalid request must reject with a specific error. Chai-as-promised adds that vocabulary to Chai. It does not make asynchronous work synchronous, and it does not remove the need to give Mocha a promise to wait for. Its value is a precise, reviewable assertion layer over promise settlement.

This article builds a small API-client test suite around eventually, fulfilled, rejected, and rejectedWith. For a wider view of runners and assertion libraries, consult the JavaScript testing frameworks guide. If suite setup or basic Chai syntax is unfamiliar, the Mocha and Chai guide supplies that foundation.

Install and register the plugin once

Install Mocha, Chai, and chai-as-promised in the project that owns the tests. TypeScript execution also needs the project's chosen compiler or loader, but that choice is independent of the plugin.

npm install --save-dev mocha chai chai-as-promised

Register chai-as-promised before tests use its properties. In an ESM setup, a shared test bootstrap module can import the Chai namespace, load the plugin, and call use.

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

chai.use(chaiAsPromised);

export const expect = chai.expect;

Tests can import expect from that bootstrap module. A project may instead load a setup file through its established Mocha configuration. The important property is ordering: registration happens before the first promise assertion, and it happens in a predictable place rather than being repeated in individual specs.

Setup decisionRecommended practiceTest-suite benefit
Plugin registrationCentralize it in test bootstrapEvery spec has the same assertion language
Assertion importRe-export the configured expectPrevents accidental use of unconfigured Chai
Module syntaxMatch the repository's ESM or CommonJS setupAvoids loader errors unrelated to assertions
Dependency scopeKeep test libraries in development dependenciesProduction bundles do not need test tooling

If an AI coding agent generates a spec, point it at the shared bootstrap file. This stops the agent from adding a second Chai instance or inventing per-file setup. Ready-made QA skills can also be installed from qaskills.sh with the qaskills CLI when you want a reusable testing workflow rather than another copied prompt.

Model a promise API with stable contracts

Useful examples need more than Promise.resolve(42). Consider a checkout client that validates input, calls a transport, and returns the receipt from a successful response. It throws a domain error before transport for an invalid amount and propagates transport failures.

export class InvalidAmountError extends Error {}

type Receipt = { paymentId: string; status: 'accepted' };
type Transport = (request: {
  path: string;
  body: { amount: number };
}) => Promise<{ receipt: Receipt }>;

export async function submitPayment(
  amount: number,
  transport: Transport
): Promise<Receipt> {
  if (amount <= 0) {
    throw new InvalidAmountError('amount must be positive');
  }

  const response = await transport({
    path: '/payments',
    body: { amount },
  });
  return response.receipt;
}

There are three independent contracts to cover: fulfillment shape, validation rejection, and infrastructure rejection. Keeping those cases separate gives failures a clear diagnostic meaning. One oversized test that tries several amounts and errors often obscures which settlement behavior changed.

Assert fulfillment and the eventual value

The fulfilled assertion checks settlement only. Use it when the fact of successful completion is itself the contract, for example a void-returning cache refresh. When the result matters, eventually transfers the eventual fulfillment value into ordinary Chai assertions.

import { expect } from './test-bootstrap';
import { submitPayment } from '../src/submit-payment';

describe('submitPayment fulfillment', () => {
  it('fulfills with the accepted receipt', async () => {
    const transport = async () => ({
      receipt: { paymentId: 'pay_104', status: 'accepted' as const },
    });

    await expect(submitPayment(2500, transport)).to.eventually.deep.equal({
      paymentId: 'pay_104',
      status: 'accepted',
    });
  });
});

Await the assertion itself. Chai-as-promised produces a promise-aware assertion, so dropping await recreates the classic false-pass risk. An alternative is to return the assertion from a non-async Mocha test.

it('completes a cache refresh', () => {
  const refresh = () => Promise.resolve();

  return expect(refresh()).to.be.fulfilled;
});

Choose assertions that expose business meaning. eventually.have.property('status', 'accepted') is more useful than fulfillment alone for a payment workflow, while deep equality is appropriate when the exact response object is a supported contract. Avoid asserting incidental fields such as generated timestamps unless the test controls them.

IntentAssertion formWhat a failure tells the reviewer
Any successful settlementawait expect(promise).to.be.fulfilledThe operation rejected
Exact resolved objectawait expect(promise).to.eventually.deep.equal(expected)Settlement or response shape changed
One resolved fieldawait expect(promise).to.eventually.have.property(name, value)Required output field is missing or wrong
Resolved collection sizeawait expect(promise).to.eventually.have.lengthOf(count)Returned collection cardinality changed

Specify rejection type and message deliberately

The rejected assertion proves only that a promise rejects. rejectedWith can narrow the expectation to an error constructor and message. Prefer the strongest stable contract. A domain error class and a durable message are good candidates; a full upstream stack trace is not.

import { InvalidAmountError, submitPayment } from '../src/submit-payment';

it('rejects a zero amount with a domain error', async () => {
  let transportCalls = 0;
  const transport = async () => {
    transportCalls += 1;
    throw new Error('transport should not run');
  };

  await expect(submitPayment(0, transport)).to.be.rejectedWith(
    InvalidAmountError,
    'amount must be positive'
  );
  expect(transportCalls).to.equal(0);
});

Notice the second oracle. The rejection is necessary, but the absence of a transport call proves validation happened at the correct layer. Without it, an implementation could send an invalid payment, receive an unrelated failure, and still satisfy a broad rejection assertion.

For an infrastructure failure, identity may matter because the client promises to propagate the original error:

it('propagates the transport failure', async () => {
  const networkError = new Error('connection reset');
  const transport = async () => Promise.reject(networkError);

  const assertion = expect(submitPayment(900, transport)).to.be.rejected;
  await assertion;

  await expect(submitPayment(900, transport)).to.be.rejectedWith(
    Error,
    'connection reset'
  );
});

In production tests, avoid invoking an operation twice merely to make two assertion styles. The example contrasts the styles. If duplicate execution has side effects, make one assertion that captures the supported contract, or catch the rejection once and inspect the error object with standard Chai assertions.

Combine eventual assertions with interaction evidence

Promise matchers describe the output channel. They do not automatically prove that the dependency received the right request or that no extra call occurred. Capture arguments in a fake transport, then assert them after the promise assertion settles.

it('posts the amount to the payments endpoint', async () => {
  const requests: Array<{ path: string; body: { amount: number } }> = [];
  const transport = async (request: {
    path: string;
    body: { amount: number };
  }) => {
    requests.push(request);
    return {
      receipt: { paymentId: 'pay_205', status: 'accepted' as const },
    };
  };

  await expect(submitPayment(1750, transport)).to.eventually.include({
    status: 'accepted',
  });
  expect(requests).to.deep.equal([
    { path: '/payments', body: { amount: 1750 } },
  ]);
});

This pattern is valuable for agent-generated tests. Ask the agent to identify both the settlement oracle and the interaction oracle. Then review whether the interaction is publicly observable behavior or implementation trivia. Endpoint and request body usually belong to an adapter contract; the number of internal helper calls often does not.

Test concurrency without losing failed assertions

Batch workflows introduce two levels of promises: each operation and the aggregate. Decide whether the product contract is all-or-nothing or partial success. Promise.all rejects when an input rejects, while Promise.allSettled fulfills with a status record for every input. Test the primitive the implementation actually promises.

it('rejects the batch when one payment fails', async () => {
  const good = Promise.resolve({ paymentId: 'p1', status: 'accepted' });
  const bad = Promise.reject(new Error('issuer unavailable'));

  await expect(Promise.all([good, bad])).to.be.rejectedWith(
    'issuer unavailable'
  );
});

it('reports both outcomes for an all-settled batch', async () => {
  const results = Promise.allSettled([
    Promise.resolve('accepted'),
    Promise.reject(new Error('declined')),
  ]);

  await expect(results).to.eventually.satisfy(
    (items: PromiseSettledResult<string>[]) =>
      items[0].status === 'fulfilled' && items[1].status === 'rejected'
  );
});

Do not create several promise assertions inside forEach and assume Mocha tracks them. Build an array and await Promise.all, or use a sequential loop. An assertion library cannot compensate for promises that the test runner never receives.

Troubleshoot assertion chains that do not run

When a chai-as-promised test behaves strangely, separate plugin wiring from promise behavior.

ObservationProbable issueRepair
eventually is unknownPlugin was not registered on the Chai instance in useImport the configured bootstrap assertion
Test passes before rejection appearsPromise assertion was neither awaited nor returnedAwait or return the complete chain
rejectedWith fails on textMessage is transformed or unstableAssert the documented error type or stable fragment
Test hangsSystem promise never settlesInspect unresolved fakes, callbacks, and timers
Type checking lacks plugin propertiesType declarations are not visible to the test compilationCheck dependency installation and test tsconfig scope

Run the smallest relevant spec and grep by its title while diagnosing. These are documented Mocha CLI behaviors and keep noise low.

npx mocha "test/submit-payment.spec.ts"
npx mocha "test/submit-payment.spec.ts" --grep "rejects a zero amount"

Read a green promise test skeptically. It should be possible to point at the awaited or returned assertion, explain what fulfillment or rejection contract it proves, and identify any interaction evidence needed to rule out the wrong failure source.

Frequently Asked Questions

Does chai-as-promised replace async and await?

No. It extends Chai with assertions that understand thenables, while async and await control how the test waits for asynchronous work. A common pattern is await expect(operation()).to.eventually.equal(value). You can instead return that assertion chain from a non-async Mocha test. In both forms, Mocha must receive the assertion's promise so a late failure is attached to the correct test.

When should I use fulfilled instead of eventually?

Use fulfilled when successful settlement is the complete requirement, such as a command that intentionally resolves without a value. Use eventually when the fulfillment value carries a contract worth checking, including a receipt, collection, status, or transformed model. For most query and API-client tests, validating the value provides stronger regression protection than confirming that the operation merely avoided rejection.

Can rejectedWith verify a custom error class?

Yes, pass the custom error constructor and, when stable, the expected message. This distinguishes a deliberate domain rejection from an accidental TypeError or transport failure. Also assert relevant side effects, such as proving the transport was not called after local validation failed. Avoid coupling to stack traces or volatile upstream wording because those details make tests brittle without strengthening the user-facing contract.

Why did an eventually assertion pass after I removed await?

The test likely ended before the promise-aware assertion settled. If the underlying promise fulfilled as expected, the detached assertion produced no visible problem; if it later failed, it could appear as an unhandled rejection outside the test. Restore await or return the entire assertion chain. Then deliberately break the expected value once to confirm that the spec fails for the intended reason.