chai is a popular assertion library for JavaScript testing, but its core features often need expansion for modern development workflows. These four packages extend chai to handle specific scenarios: chai-as-promised simplifies promise testing, chai-jest-snapshot brings snapshot testing to Chai, chai-spies provides lightweight function spying, and sinon-chai integrates the robust Sinon spy and stub library with Chai assertions. Together, they allow developers to keep Chai as their central assertion tool while covering async flows, regression testing, and behavior verification.
chai is a flexible assertion library, but real-world testing often requires more than basic equality checks. Developers frequently need to verify asynchronous outcomes, catch visual regressions with snapshots, or ensure functions are called correctly. The packages chai-as-promised, chai-jest-snapshot, chai-spies, and sinon-chai extend Chai to handle these specific needs. Let's break down how they work and when to use each one.
Testing asynchronous code can be messy without the right tools. You often end up nesting callbacks or manually handling resolved states.
chai-as-promised adds the eventually property to Chai assertions. This allows you to write assertions that look synchronous but wait for the Promise to settle.
// chai-as-promised
const promise = fetchUser(id);
// Wait for resolution and check value
await expect(promise).to.eventually.have.property('name', 'Alice');
// Check for rejection
await expect(promise).to.be.rejectedWith('User not found');
chai-jest-snapshot, chai-spies, and sinon-chai do not handle Promise resolution directly. You must await the promise before passing the result to their assertions.
// chai-spies / sinon-chai / chai-jest-snapshot
const result = await fetchUser(id);
// chai-spies
expect(spy).to.have.been.called();
// sinon-chai
expect(stub).to.have.returned(result);
// chai-jest-snapshot
expect(result).to.matchSnapshot();
💡 Tip: If you are testing async code,
chai-as-promisedis usually required alongside the other tools. They solve different problems and often work together in the same test suite.
Snapshot testing saves a serialized output and compares it against future runs to detect unintended changes. This is a core feature of Jest, but Chai users can access it too.
chai-jest-snapshot brings this capability to Chai. It creates .snap files and compares values against them.
// chai-jest-snapshot
import { matchSnapshot } from 'chai-jest-snapshot';
chai.use(matchSnapshot);
const component = render(<Button />);
expect(component.toJSON()).to.matchSnapshot();
chai-as-promised, chai-spies, and sinon-chai do not offer snapshot capabilities. They focus on logic and behavior rather than output structure.
// chai-as-promised / chai-spies / sinon-chai
// No snapshot feature available
// You must manually assert properties
expect(component).to.have.property('type', 'button');
⚠️ Note: Using
chai-jest-snapshotrequires configuring a snapshot resolver similar to Jest. If you are already using Jest as a runner, you do not need this package — just use native Jest snapshots.
Verifying that a function was called — and how it was called — is critical for unit testing. There are two main approaches here: built-in spies or external libraries.
chai-spies provides a lightweight spy function built specifically for Chai. It does not require external dependencies.
// chai-spies
import chai from 'chai';
import chaiSpies from 'chai-spies';
chai.use(chaiSpies);
const spy = chai.spy(() => {});
obj.method = spy;
obj.method('arg1');
expect(spy).to.have.been.called();
expect(spy).to.have.been.called.with('arg1');
sinon-chai bridges Chai assertions with Sinon's powerful test doubles. Sinon offers spies, stubs, mocks, and fake timers.
// sinon-chai
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
chai.use(sinonChai);
const stub = sinon.stub(api, 'fetch').returns({ data: 'mock' });
const spy = sinon.spy();
api.process(spy);
expect(spy).to.have.been.calledOnce;
expect(stub).to.have.been.calledWith('id');
chai-as-promised and chai-jest-snapshot do not provide spying features. They focus on assertions and output comparison.
// chai-as-promised / chai-jest-snapshot
// No spying feature available
// Must be paired with chai-spies or sinon-chai for call tracking
Choosing the right tool also depends on long-term maintenance and ecosystem support.
chai-as-promised is widely adopted and stable. It is the de facto standard for Promise testing in Chai. Most modern test runners support it without extra configuration beyond plugin registration.
chai-jest-snapshot is niche. It is useful for specific migrations but adds complexity if you are not already using Jest's snapshot format. Maintenance is slower compared to core Jest features.
chai-spies is lightweight but less feature-rich. It receives updates infrequently. For simple call tracking, it works well, but it lacks advanced features like fake timers or deep stubbing.
sinon-chai benefits from the massive Sinon ecosystem. Sinon is actively maintained and used across many frameworks. If you need to fake time, mock XHR, or stub deep properties, Sinon is the stronger choice.
| Feature | chai-as-promised | chai-jest-snapshot | chai-spies | sinon-chai |
|---|---|---|---|---|
| Primary Goal | Promise Assertions | Snapshot Testing | Lightweight Spying | Robust Test Doubles |
| Async Support | ✅ Native (eventually) | ❌ Manual await | ❌ Manual await | ❌ Manual await |
| Snapshots | ❌ | ✅ matchSnapshot | ❌ | ❌ |
| Spies | ❌ | ❌ | ✅ chai.spy() | ✅ sinon.spy() |
| Stubs/Mocks | ❌ | ❌ | ❌ | ✅ Full Sinon API |
| Dependencies | None (Chai only) | Jest snapshot utils | None (Chai only) | Requires sinon |
These packages are not mutually exclusive — in fact, they are often combined.
chai-as-promised: Almost every modern JavaScript project uses Promises. This package is essential for clean async tests.chai-spies for small, simple projects where adding Sinon is too much weight. Use sinon-chai for large applications where you need stubs, mocks, and advanced isolation.chai-jest-snapshot if you are committed to Chai but need snapshot testing. If you can switch to Jest, native snapshots are easier to manage.Ideal Stack: chai + chai-as-promised + sinon-chai is the most robust combination for professional frontend architecture. It covers async flows, behavior verification, and complex mocking without mixing too many testing philosophies.
Choose chai-as-promised when your codebase relies heavily on Promises or async/await patterns and you want to avoid manual .then() chains in tests. It is the standard solution for asserting promise resolution or rejection within the Chai ecosystem. This package is essential if you need to test asynchronous outcomes without wrapping assertions in callback functions.
Choose chai-jest-snapshot if you are committed to using Chai for assertions but want the benefits of snapshot testing without switching to the Jest test runner. It is useful for legacy projects migrating to snapshots or teams standardizing on Mocha/Vitest with Chai. Be aware that this mixes testing philosophies, so ensure your team is comfortable managing snapshot files outside of Jest.
Choose chai-spies for simple spying needs where installing the larger Sinon library feels like overkill. It works well for small projects or specific unit tests that only require basic call verification without complex stubbing or mocking. However, for advanced isolation needs, you may find its feature set limited compared to Sinon.
Choose sinon-chai when you need powerful test doubles like stubs, mocks, and fakes alongside your assertions. It is the industry standard for complex behavior verification and integrates seamlessly with Chai's language. This is the best choice for large-scale applications where isolating units requires robust control over dependencies.
Chai as Promised extends Chai with a fluent language for asserting facts about promises.
Instead of manually wiring up your expectations to a promise's fulfilled and rejected handlers:
doSomethingAsync().then(
function (result) {
result.should.equal("foo");
done();
},
function (err) {
done(err);
}
);
you can write code that expresses what you really mean:
return doSomethingAsync().should.eventually.equal("foo");
or if you have a case where return is not preferable (e.g. style considerations) or not possible (e.g. the testing framework doesn't allow returning promises to signal asynchronous test completion), then you can use the following workaround (where done() is supplied by the test framework):
doSomethingAsync().should.eventually.equal("foo").notify(done);
Notice: either return or notify(done) must be used with promise assertions. This can be a slight departure from the existing format of assertions being used on a project or by a team. Those other assertions are likely synchronous and thus do not require special handling.
should/expect InterfaceThe most powerful extension provided by Chai as Promised is the eventually property. With it, you can transform any existing Chai assertion into one that acts on a promise:
(2 + 2).should.equal(4);
// becomes
return Promise.resolve(2 + 2).should.eventually.equal(4);
expect({ foo: "bar" }).to.have.property("foo");
// becomes
return expect(Promise.resolve({ foo: "bar" })).to.eventually.have.property("foo");
There are also a few promise-specific extensions (with the usual expect equivalents also available):
return promise.should.be.fulfilled;
return promise.should.eventually.deep.equal("foo");
return promise.should.become("foo"); // same as `.eventually.deep.equal`
return promise.should.be.rejected;
return promise.should.be.rejectedWith(Error); // other variants of Chai's `throw` assertion work too.
assert InterfaceAs with the should/expect interface, Chai as Promised provides an eventually extender to chai.assert, allowing any existing Chai assertion to be used on a promise:
assert.equal(2 + 2, 4, "This had better be true");
// becomes
return assert.eventually.equal(Promise.resolve(2 + 2), 4, "This had better be true, eventually");
And there are, of course, promise-specific extensions:
return assert.isFulfilled(promise, "optional message");
return assert.becomes(promise, "foo", "optional message");
return assert.doesNotBecome(promise, "foo", "optional message");
return assert.isRejected(promise, Error, "optional message");
return assert.isRejected(promise, /error message regex matcher/, "optional message");
return assert.isRejected(promise, "substring to search error message for", "optional message");
Chai as Promised does not have any intrinsic support for testing promise progress callbacks. The properties you would want to test are probably much better suited to a library like Sinon.JS, perhaps in conjunction with Sinon–Chai:
var progressSpy = sinon.spy();
return promise.then(null, null, progressSpy).then(function () {
progressSpy.should.have.been.calledWith("33%");
progressSpy.should.have.been.calledWith("67%");
progressSpy.should.have.been.calledThrice;
});
By default, the promises returned by Chai as Promised's assertions are regular Chai assertion objects, extended with a single then method derived from the input promise. To change this behavior, for instance to output a promise with more useful sugar methods such as are found in most promise libraries, you can override chaiAsPromised.transferPromiseness. Here's an example that transfer's Q's finally and done methods:
import {setTransferPromiseness} from 'chai-as-promised';
setTransferPromiseness(function (assertion, promise) {
assertion.then = promise.then.bind(promise); // this is all you get by default
assertion.finally = promise.finally.bind(promise);
assertion.done = promise.done.bind(promise);
});
Another advanced customization hook Chai as Promised allows is if you want to transform the arguments to the asserters, possibly asynchronously. Here is a toy example:
import {transformAsserterArgs} from 'chai-as-promised';
setTransformAsserterArgs(function (args) {
return args.map(function (x) { return x + 1; });
});
Promise.resolve(2).should.eventually.equal(2); // will now fail!
Promise.resolve(3).should.eventually.equal(2); // will now pass!
The transform can even be asynchronous, returning a promise for an array instead of an array directly. An example of that might be using Promise.all so that an array of promises becomes a promise for an array. If you do that, then you can compare promises against other promises using the asserters:
// This will normally fail, since within() only works on numbers.
Promise.resolve(2).should.eventually.be.within(Promise.resolve(1), Promise.resolve(6));
setTransformAsserterArgs(function (args) {
return Promise.all(args);
});
// But now it will pass, since we transformed the array of promises for numbers into
// (a promise for) an array of numbers
Promise.resolve(2).should.eventually.be.within(Promise.resolve(1), Promise.resolve(6));
Chai as Promised is compatible with all promises following the Promises/A+ specification.
Notably, jQuery's promises were not up to spec before jQuery 3.0, and Chai as Promised will not work with them. In particular, Chai as Promised makes extensive use of the standard transformation behavior of then, which jQuery<3.0 does not support.
Angular promises have a special digest cycle for their processing, and need extra setup code to work with Chai as Promised.
Some test runners (e.g. Jasmine, QUnit, or tap/tape) do not have the ability to use the returned promise to signal asynchronous test completion. If possible, I'd recommend switching to ones that do, such as Mocha, Buster, or blue-tape. But if that's not an option, Chai as Promised still has you covered. As long as your test framework takes a callback indicating when the asynchronous test run is over, Chai as Promised can adapt to that situation with its notify method, like so:
it("should be fulfilled", function (done) {
promise.should.be.fulfilled.and.notify(done);
});
it("should be rejected", function (done) {
otherPromise.should.be.rejected.and.notify(done);
});
In these examples, if the conditions are not met, the test runner will receive an error of the form "expected promise to be fulfilled but it was rejected with [Error: error message]", or "expected promise to be rejected but it was fulfilled."
There's another form of notify which is useful in certain situations, like doing assertions after a promise is complete. For example:
it("should change the state", function (done) {
otherState.should.equal("before");
promise.should.be.fulfilled.then(function () {
otherState.should.equal("after");
}).should.notify(done);
});
Notice how .notify(done) is hanging directly off of .should, instead of appearing after a promise assertion. This indicates to Chai as Promised that it should pass fulfillment or rejection directly through to the testing framework. Thus, the above code will fail with a Chai as Promised error ("expected promise to be fulfilled…") if promise is rejected, but will fail with a simple Chai error (expected "before" to equal "after") if otherState does not change.
async/await and Promise-Friendly Test RunnersSince any assertion that must wait on a promise returns a promise itself, if you're able to use async/await and your test runner supports returning a promise from test methods, you can await assertions in tests. In many cases you can avoid using Chai as Promised at all by performing a synchronous assertion after an await, but awaiting rejectedWith is often more convenient than using try/catch blocks without Chai as Promised:
it('should work well with async/await', async () => {
(await Promise.resolve(42)).should.equal(42)
await Promise.reject(new Error()).should.be.rejectedWith(Error);
});
To perform assertions on multiple promises, use Promise.all to combine multiple Chai as Promised assertions:
it("should all be well", function () {
return Promise.all([
promiseA.should.become("happy"),
promiseB.should.eventually.have.property("fun times"),
promiseC.should.be.rejectedWith(TypeError, "only joyful types are allowed")
]);
});
This will pass any failures of the individual promise assertions up to the test framework, instead of wrapping them in an "expected promise to be fulfilled…" message as would happen if you did return Promise.all([…]).should.be.fulfilled. If you can't use return, then use .should.notify(done), similar to the previous examples.
Do an npm install chai-as-promised to get up and running. Then:
import * as chai from 'chai';
import chaiAsPromised from 'chai-as-promised';
chai.use(chaiAsPromised);
// Then either:
const expect = chai.expect;
// or:
const assert = chai.assert;
// or:
chai.should();
// according to your preference of assertion style
You can of course put this code in a common test fixture file; for an example using Mocha, see the Chai as Promised tests themselves.
Note when using other Chai plugins: Chai as Promised finds all currently-registered asserters and promisifies them, at the time it is installed. Thus, you should install Chai as Promised last, after any other Chai plugins, if you expect their asserters to be promisified.
If you're using Karma, check out the accompanying karma-chai-as-promised plugin.
Chai as Promised requires support for ES modules and modern JavaScript syntax. If your browser doesn't support this, you will need to transpile it down using a tool like Babel.