Mastering Node.js Backend Testing with Mocha and Chai

Unit testing is important because it checks small parts of code to make sure they work right and finds bugs early. It's important to do these tests before releasing an app. This guide will cover unit testing with Mocha and Chai.

Why Mocha and Chai?

Mocha is a feature-rich JavaScript test framework that runs on Node.js, making asynchronous testing simple and enjoyable. It provides functions that execute in a specific order, collecting test results and offering accurate reporting.

Chai is a BDD/TDD assertion library that can be used with any JavaScript testing framework. It offers several interfaces, allowing developers to choose the assertion style they find most comfortable.

Benefits of using Mocha and Chai

  1. Readable and expressive assertions
    Chai provides different assertion styles and syntax options that work well with Mocha, allowing you to choose the style that suits your needs for clarity and readability.

  2. Support for asynchronous testing
    Mocha handles asynchronous testing easily, letting you test asynchronous code in Node.js applications without needing extra libraries or complex setups.

Setting Up the Testing Environment

Installing dependencies

First, let's set up a new Node.js project and install the necessary dependencies:

mkdir auth-api-testing
cd auth-api-testing
npm init -y

# Install production dependencies
npm install express jsonwebtoken mongoose bcryptjs dotenv

# Install development dependencies
npm install --save-dev mocha chai chai-http supertest nyc

Setup testing scripts

Add the following scripts to your package.json:

{
  "scripts": {
    "test": "NODE_ENV=test mocha --timeout 10000 --exit",
    "test:coverage": "nyc npm test"
  }
}

Project Overview

Before diving into testing, let's understand the application we'll be testing. We're building a simple but secure authentication API with the following features.

Application Structure

src/
├── models/
│   └── user.model.js       # User schema and model
├── routes/
│   ├── auth.routes.js      # Authentication routes
│   └── user.routes.js      # User management routes
├── middleware/
│   ├── auth.middleware.js  # JWT verification middleware
│   └── validate.js         # Request validation middleware
├── controllers/
│   ├── auth.controller.js  # Authentication logic
│   └── user.controller.js  # User management logic
└── app.js                  # Express application setup

API Endpoints

  1. Authentication Endpoints:
POST /api/auth/register
- Registers new user
- Accepts: { email, password, name }
- Returns: { token, user }

POST /api/auth/login
- Authenticates existing user
- Accepts: { email, password }
- Returns: { token, user }
  1. User Endpoints:
GET /api/users/profile
- Gets current user profile
- Requires: JWT Authentication
- Returns: User object

PUT /api/users/profile
- Updates user profile
- Requires: JWT Authentication
- Accepts: { name, email }
- Returns: Updated user object

Environment setup for testing

Create a .env.test file for test-specific configuration:

PORT=3001
MONGODB_URI=mongodb://localhost:27017/auth-api-test
JWT_SECRET=your-test-secret-key

Understanding Test Structure

Let's create our first test file test/auth.test.js

const chai = require('chai');
const chaiHttp = require('chai-http');
const app = require('../src/app');
const User = require('../src/models/user.model');

chai.use(chaiHttp);
const expect = chai.expect;

describe('Auth API Tests', () => {
  // Runs before all tests
  before(async () => {
    await User.deleteMany({});
  });

  // Runs after each test
  afterEach(async () => {
    await User.deleteMany({});
  });

  // Test suites will go here
});

Test Lifecycle Hooks

Mocha provides several hooks for test setup and cleanup:

  • before(): Runs once before all tests

  • after(): Runs once after all tests

  • beforeEach(): Runs before each test

  • afterEach(): Runs after each test

Mocha's describe() and it() Blocks

Tests are organized using describe() blocks for grouping related tests and it() blocks for individual test cases:

describe('Auth API Tests', () => {
  describe('POST /api/auth/register', () => {
    it('should register a new user successfully', async () => {
      // Test implementation
    });

    it('should return error when email already exists', async () => {
      // Test implementation
    });
  });
});

Writing our first test suite

Testing Registration and Login

describe('POST /api/auth/register', () => {
  it('should register a new user successfully', async () => {
    const res = await chai
      .request(app)
      .post('/api/auth/register')
      .send({
        email: 'test@example.com',
        password: 'Password123!',
        name: 'Test User'
      });

    expect(res).to.have.status(201);
    expect(res.body).to.have.property('token');
    expect(res.body).to.have.property('user');
    expect(res.body.user).to.have.property('email', 'test@example.com');
  });

  it('should return 400 when email already exists', async () => {
    // First create a user
    await chai
      .request(app)
      .post('/api/auth/register')
      .send({
        email: 'test@example.com',
        password: 'Password123!',
        name: 'Test User'
      });

    // Try to create another user with same email
    const res = await chai
      .request(app)
      .post('/api/auth/register')
      .send({
        email: 'test@example.com',
        password: 'Password123!',
        name: 'Test User 2'
      });

    expect(res).to.have.status(400);
    expect(res.body).to.have.property('error');
  });
});

Testing Authentication

describe('Protected Routes', () => {
  let token;
  let userId;

  beforeEach(async () => {
    // Create a test user and get token
    const res = await chai
      .request(app)
      .post('/api/auth/register')
      .send({
        email: 'test@example.com',
        password: 'Password123!',
        name: 'Test User'
      });

    token = res.body.token;
    userId = res.body.user._id;
  });

  it('should get user profile with valid token', async () => {
    const res = await chai
      .request(app)
      .get('/api/users/profile')
      .set('Authorization', `Bearer ${token}`);

    expect(res).to.have.status(200);
    expect(res.body).to.have.property('email', 'test@example.com');
  });

  it('should return 401 with invalid token', async () => {
    const res = await chai
      .request(app)
      .get('/api/users/profile')
      .set('Authorization', 'Bearer invalid-token');

    expect(res).to.have.status(401);
  });
});

Database Testing

Setting Up Test Database

const mongoose = require('mongoose');

before(async () => {
  await mongoose.connect(process.env.MONGODB_URI_TEST);
});

after(async () => {
  await mongoose.connection.dropDatabase();
  await mongoose.connection.close();
});

Testing CRUD Operations

describe('User CRUD Operations', () => {
  it('should update user profile', async () => {
    const res = await chai
      .request(app)
      .put(`/api/users/${userId}`)
      .set('Authorization', `Bearer ${token}`)
      .send({
        name: 'Updated Name'
      });

    expect(res).to.have.status(200);
    expect(res.body).to.have.property('name', 'Updated Name');
  });

  it('should delete user account', async () => {
    const res = await chai
      .request(app)
      .delete(`/api/users/${userId}`)
      .set('Authorization', `Bearer ${token}`);

    expect(res).to.have.status(200);

    // Verify user is deleted
    const user = await User.findById(userId);
    expect(user).to.be.null;
  });
});

Best Practices

Keep Test Atomic

  • Each test should stand alone and not depend on other tests

  • Tests should be able to run in any sequence

  • Use the before, after, beforeEach, and afterEach hooks for proper setup and cleanup

      describe('User API', () => {
        beforeEach(async () => {
          // Reset database state
          await User.deleteMany({});
          // Create fresh test data
          await User.create(testData);
        });
    
        it('should create a user', async () => {
          // This test has a clean state
        });
      });
    

    Follow the AAA Pattern

    • Arrange: Set up test data and conditions

    • Act: Execute the code being tested

    • Assert: Verify the results

    it('should update user profile', async () => {
      // Arrange
      const user = await createTestUser();
      const updateData = { name: 'New Name' };

      // Act
      const response = await chai
        .request(app)
        .put(`/api/users/${user._id}`)
        .send(updateData);

      // Assert
      expect(response).to.have.status(200);
      expect(response.body.name).to.equal(updateData.name);
    });

Test Edge Cases

Test boundary conditions, error scenarios, invalid inputs, and empty or null values.

    describe('User Registration', () => {
      it('should handle empty email', async () => {
        const response = await chai
          .request(app)
          .post('/api/auth/register')
          .send({ email: '', password: 'valid123' });

        expect(response).to.have.status(400);
        expect(response.body.error).to.include('email');
      });

      it('should handle invalid email format', async () => {
        const response = await chai
          .request(app)
          .post('/api/auth/register')
          .send({ email: 'notanemail', password: 'valid123' });

        expect(response).to.have.status(400);
        expect(response.body.error).to.include('valid email');
      });
    });

Use Descriptive Test Names

Test names should clearly describe the scenario being tested, follow a consistent naming convention, and include the expected behavior.

    // Good examples
    it('should return 404 when user does not exist', async () => {});
    it('should handle special characters in username', async () => {});
    it('should maintain password hash after update', async () => {});

    // Bad examples
    it('test user creation', async () => {});
    it('error case', async () => {});

Mock External Dependencies

  • Use stubs and mocks for external services

  • Isolate the code being tested

  • Control test environment

    const sinon = require('sinon');

    describe('Email Service', () => {
      let emailStub;

      beforeEach(() => {
        emailStub = sinon.stub(emailService, 'send');
      });

      afterEach(() => {
        emailStub.restore();
      });

      it('should send welcome email after registration', async () => {
        await userController.register(testUser);
        expect(emailStub.calledOnce).to.be.true;
        expect(emailStub.firstCall.args[0]).to.equal(testUser.email);
      });
    });

Handle Promises Properly

  • Always return promises or use async/await

  • Use proper error handling

  • Test both success and failure cases

    // Good - using async/await
    it('should handle async operations', async () => {
      try {
        const result = await someAsyncOperation();
        expect(result).to.exist;
      } catch (error) {
        expect(error).to.exist;
      }
    });

    // Good - using promises
    it('should handle promises', () => {
      return somePromiseOperation()
        .then(result => {
          expect(result).to.exist;
        })
        .catch(error => {
          expect(error).to.exist;
        });
    });

Set Appropriate Timeouts

  • Set realistic timeouts for async operations

  • Configure global timeouts in mocha config

  • Override timeouts for specific tests when needed

    // Set timeout for specific test
    it('should handle long running operations', async function() {
      this.timeout(5000); // 5 seconds
      const result = await longRunningOperation();
      expect(result).to.exist;
    });

Introducing Keploy Unit Test Generator

Writing manual test cases for mocha with chai, though effective, often has several challenges:

  1. Time-consuming: Making detailed test suites by hand can take a lot of time, especially for large codebases.

  2. Difficult to maintain: As your application changes, updating and maintaining manual tests becomes more complex and prone to errors.

  3. Inconsistent coverage: Developers might focus on the main paths, missing edge cases or error scenarios that could cause bugs in production.

  4. Skill-dependent: The quality and effectiveness of manual tests depend heavily on the developer's testing skills and familiarity with the codebase.

  5. Repetitive and tedious: Writing similar test structures for multiple components or functions can be boring, possibly leading to less attention to detail.

  6. Delayed feedback: The time needed to write manual tests can slow down development, delaying important feedback on code quality and functionality.

To tackle these issues, Keploy has introduced a ut-gen that uses AI to automate and improve the testing process, which is the first implementation of the Meta LLM research paper that understands code semantics and creates meaningful unit tests.

It aims to automate unit test generation (UTG) by quickly producing thorough unit tests. This reduces the need for repetitive manual work, improves edge cases by expanding the tests to cover more complex scenarios often missed manually, and increases test coverage to ensure complete coverage as the codebase grows.

Key Features

  • Automate unit test generation (UTG): Quickly generate comprehensive unit tests and reduce redundant manual effort.

  • Improve edge cases: Extend and improve the scope of tests to cover more complex scenarios that are often missed manually.

  • Boost test coverage: As the codebase grows, ensuring exhaustive coverage becomes feasible.

Conclusion

In conclusion, mastering Node.js backend testing with Mocha and Chai is important for developers who want their applications to be reliable and strong. By using Mocha's testing framework and Chai's clear assertion library, developers can create detailed test suites that cover many parts of their code, from simple unit tests to complex async operations. Following best practices like keeping tests focused, using clear names, and handling promises correctly can greatly improve your testing process. By using these tools and techniques in your development workflow, you can find bugs early, improve code quality, and deliver more secure and efficient applications.

FAQ’s

What's the difference between Mocha and Chai, and do I need both?

While both are testing tools, they serve different purposes. Mocha is a testing framework that provides the structure for organizing and running tests (using describe() and it() blocks). Chai is an assertion library that provides functions for verifying results (like expect(), should, and assert). While you can use Mocha without Chai, using them together gives you a more complete and expressive testing solution.

How do I set up and clean up test data between tests?

Mocha provides several lifecycle hooks for managing test data:

  • before(): Run once before all tests

  • beforeEach(): Run before each test

  • afterEach(): Run after each test

  • after(): Run once after all tests

Example:

javascriptCopybeforeEach(async () => {
    // Reset database state
    await User.deleteMany({});
    // Create fresh test data
    await User.create(testData);
});

afterEach(async () => {
    // Clean up after each test
    await User.deleteMany({});
});

How should I structure my tests for better organization and maintenance?

  1. Group related tests using describe() blocks

  2. Use descriptive test names that clearly state the expected behavior

  3. Follow the AAA (Arrange-Act-Assert) pattern in each test

  4. Keep tests atomic and independent

  5. Organize test files to mirror your source code structure

Example:

describe('User Authentication', () => {
    describe('Registration', () => {
        it('should successfully register with valid credentials', async () => {
            // Arrange
            const userData = { email: 'test@example.com', password: 'valid123' };
            // Act
            const response = await registerUser(userData);
            // Assert
            expect(response).to.have.status(201);
        });
    });
});

What's the difference between before, beforeEach, after, and afterEach?

'before' runs once before all tests, 'beforeEach' runs before each individual test, 'afterEach' runs after each individual test, and 'after' runs once after all tests are complete. These are useful for setting up and cleaning up test data.

How do I test async code?

You can use async/await or return promises in your tests. Just add 'async' before your test function and use 'await' when calling async operations. Make sure to set appropriate timeouts for longer operations.

10
Subscribe to my newsletter

Read articles from Abhishek kushwaha directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Abhishek kushwaha
Abhishek kushwaha

🥑intern @keployio| @github Campus Expert 🚩| Developer | Building @hackthesource| x1 @github certified