cyberangles blog

Chai-As-Promised: How to Use Multiple Expect Statements in a Single Test

Testing asynchronous code can be tricky. Whether you’re working with API calls, database operations, or any async function, ensuring your tests accurately validate behavior—especially when multiple conditions need checking—requires the right tools. Enter Chai-As-Promised, a plugin for the Chai assertion library that simplifies testing promises.

In this blog, we’ll dive deep into using Chai-As-Promised to write clean, maintainable tests with multiple expect statements. We’ll cover setup, common pitfalls, step-by-step examples, best practices, and troubleshooting tips to help you master asynchronous assertions.

2025-12

Table of Contents#

  1. What is Chai-As-Promised?
  2. Why Use Multiple Expect Statements in One Test?
  3. Common Pitfalls with Asynchronous Assertions
  4. Setup: Installing and Configuring Chai-As-Promised
  5. Step-by-Step Guide: Multiple Expects with Chai-As-Promised
  6. Best Practices for Multiple Expect Statements
  7. Troubleshooting Common Issues
  8. References

What is Chai-As-Promised?#

Chai is a popular BDD/TDD assertion library for JavaScript, known for its readable syntax (e.g., expect(value).to.equal(5)). However, vanilla Chai struggles with promises: without special handling, assertions run before the promise resolves, leading to false positives or unhandled rejections.

Chai-As-Promised extends Chai to support promises directly. It adds the eventually modifier, which tells Chai to wait for the promise to settle (resolve or reject) before running the assertion. This makes testing async code as straightforward as testing sync code.

Why Use Multiple Expect Statements in One Test?#

A single test often needs to validate multiple aspects of a promise’s behavior. For example:

  • After fetching a user, check their id, name, and email.
  • When a promise rejects, verify the error message, status code, and stack trace.

Using multiple expect statements in one test keeps related checks grouped, improving readability and reducing redundant setup (e.g., avoiding repeated API calls for separate tests).

Common Pitfalls with Asynchronous Assertions#

Before diving into solutions, let’s highlight pitfalls to avoid:

  • Unhandled Rejections: Forgetting to handle promise rejections causes tests to fail cryptically.
  • False Positives: Tests pass because assertions run before the promise settles (e.g., checking undefined instead of the resolved value).
  • Overcomplicated .then() Chains: Nested .then() calls with multiple assertions become unreadable.

Setup: Installing and Configuring Chai-As-Promised#

Prerequisites#

  • Node.js (v14+ recommended).
  • A test runner (we’ll use Mocha; Chai-As-Promised works with Jest, Jasmine, etc.).

Step 1: Install Dependencies#

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

Step 2: Configure Chai-As-Promised#

In your test file, require Chai and Chai-As-Promised, then enable the plugin:

// test/example.test.js  
const chai = require('chai');  
const chaiAsPromised = require('chai-as-promised');  
 
// Enable Chai-As-Promised  
chai.use(chaiAsPromised);  
 
// Get Chai's expect function  
const expect = chai.expect;  

Step-by-Step Guide: Multiple Expects with Chai-As-Promised#

We’ll use a mock async function to demonstrate:

// mock-api.js  
const getUser = async (userId) => {  
  if (userId === 999) {  
    throw { status: 404, message: 'User not found' };  
  }  
  return { id: userId, name: 'John Doe', email: '[email protected]' };  
};  
 
module.exports = { getUser };  

Testing Resolved Promises#

Let’s test getUser(1) and validate the resolved user’s id, name, and email.

async/await simplifies readability. Await the promise once, then run multiple synchronous assertions:

const { getUser } = require('./mock-api');  
 
describe('getUser (resolved)', () => {  
  it('should return a user with id, name, and valid email', async () => {  
    // Await the promise once  
    const user = await getUser(1);  
 
    // Multiple expect statements  
    expect(user).to.be.an('object');  
    expect(user.id).to.equal(1);  
    expect(user.name).to.equal('John Doe');  
    expect(user.email).to.include('@'); // Check email format  
  });  
});  

Approach 2: Use eventually for Chained Assertions#

Chai-As-Promised’s eventually modifier lets you chain assertions directly on the promise:

it('should return a user with id, name, and valid email (chained)', () => {  
  // No need for async/await—return the promise!  
  return expect(getUser(1))  
    .to.eventually.be.an('object')  
    .and.to.have.property('id', 1)  
    .and.to.have.property('name', 'John Doe')  
    .and.to.have.property('email').that.includes('@');  
});  

Why return the promise? Test runners like Mocha wait for returned promises to settle, ensuring assertions run after the promise resolves.

Testing Rejected Promises#

Now test getUser(999), which rejects with an error. We’ll validate the error’s status and message.

Approach 1: async/await with try/catch#

describe('getUser (rejected)', () => {  
  it('should reject with 404 and "User not found"', async () => {  
    try {  
      await getUser(999);  
      // If no error, fail the test  
      expect.fail('Expected promise to reject');  
    } catch (err) {  
      // Multiple error assertions  
      expect(err).to.be.an('object');  
      expect(err.status).to.equal(404);  
      expect(err.message).to.equal('User not found');  
    }  
  });  
});  

Approach 2: to.be.rejected with Chained Assertions#

Chai-As-Promised provides to.be.rejected to test rejections cleanly:

it('should reject with 404 and "User not found" (chained)', () => {  
  return expect(getUser(999))  
    .to.be.rejected  
    .and.to.be.an('object')  
    .and.to.have.property('status', 404)  
    .and.to.have.property('message', 'User not found');  
});  

This is more concise than try/catch for rejection tests.

Best Practices#

1. Keep Tests Focused#

Test one behavior per test, even with multiple assertions. For example:
✅ Good: Test "fetch user" with assertions for id, name, email.
❌ Bad: Test "fetch user" and "update user" in one test.

2. Use Descriptive Error Messages#

Add messages to expect statements for clarity when tests fail:

expect(user.name).to.equal('John Doe', 'User name mismatch');  

3. Fail Fast#

Chai-As-Promised stops executing assertions after the first failure, preventing irrelevant errors. For example:
If user.id is 2 instead of 1, the test fails immediately—no need to check name or email.

4. Prefer async/await for Readability#

While chained eventually assertions work, async/await is often clearer for multiple unrelated checks.

Troubleshooting Common Issues#

Issue 1: Test Passes When It Should Fail#

Cause: The test doesn’t wait for the promise to settle.
Fix: Return the promise or use async/await:

// ❌ Fails silently (test finishes before promise resolves)  
it('bad test', () => {  
  getUser(1).then(user => {  
    expect(user.id).to.equal(2); // Runs too late  
  });  
});  
 
// ✅ Good: Return the promise  
it('good test', () => {  
  return getUser(1).then(user => {  
    expect(user.id).to.equal(2);  
  });  
});  
 
// ✅ Better: Use async/await  
it('best test', async () => {  
  const user = await getUser(1);  
  expect(user.id).to.equal(2);  
});  

Issue 2: "Cannot read property 'eventually' of undefined"#

Cause: Chai-As-Promised not installed/enabled.
Fix: Ensure chai.use(chaiAsPromised) is called before using expect.

Issue 3: Unhandled Rejection Warnings#

Cause: Forgetting to test rejected promises.
Fix: Use to.be.rejected or try/catch to handle rejections explicitly.

References#

Conclusion#

Chai-As-Promised simplifies testing asynchronous code by letting you write multiple expect statements with confidence. By combining it with async/await or chained eventually assertions, you can validate promise behavior cleanly and efficiently. Remember to keep tests focused, use descriptive messages, and handle rejections explicitly to avoid common pitfalls.

Happy testing! ☕