← Back to DevBytes

Mocha: Complete Testing Guide for Developers

What is Mocha?

Mocha is a feature-rich, flexible JavaScript test framework that runs on Node.js and in the browser. It provides developers with a robust foundation for writing and organizing tests, offering a clean syntax for defining test suites and test cases. Mocha is designed to be asynchronous-first, supporting both synchronous and asynchronous testing patterns out of the box, including promises, async/await, and callbacks.

Unlike some all-in-one testing frameworks, Mocha takes a modular approach: it handles the structure and execution of tests while leaving assertions and mocking to specialized libraries like Chai, Sinon, or Node.js's built-in assert module. This separation of concerns gives developers the freedom to choose the tools that best fit their workflow.

Key Features at a Glance

Why Mocha Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

In modern software development, automated testing is not optional — it's essential. Mocha provides the backbone for a reliable testing strategy. Here's why it has become one of the most widely adopted test frameworks in the Node.js ecosystem:

Separation of Concerns

Mocha focuses solely on test structure and execution. Assertions, mocking, and stubbing are handled by dedicated libraries. This modularity prevents vendor lock-in and allows teams to mix and match tools as their needs evolve. For example, you can use Chai for expressive assertions, Sinon for mocking, and nyc for code coverage — all orchestrated by Mocha.

Asynchronous Testing Made Simple

Testing asynchronous code has historically been painful. Mocha handles this elegantly with built-in promise support and seamless async/await integration. A test that involves database queries, API calls, or file I/O is as straightforward to write as a synchronous test — just return a promise or use async/await, and Mocha waits for resolution.

Comprehensive Reporting

Mocha ships with multiple built-in reporters (spec, dot, nyan, JSON, TAP, etc.) that cater to different contexts — from local development with colorful terminal output to CI/CD pipelines with machine-parseable formats. The spec reporter, for instance, produces nested, hierarchical output that mirrors your test structure, making debugging failures intuitive.

Universal Compatibility

Whether your code runs on Node.js or in the browser, Mocha works consistently across environments. This universality means you can share test suites between server and client code, reducing duplication and ensuring consistent behavior verification.

Getting Started: Installation and Setup

Let's walk through setting up Mocha from scratch. You'll need Node.js installed (version 14 or later is recommended).

Project Initialization

Create a new project directory and initialize it:

mkdir mocha-testing-guide
cd mocha-testing-guide
npm init -y

Installing Mocha and an Assertion Library

Install Mocha as a development dependency, along with Chai (a popular assertion library):

npm install --save-dev mocha chai

Alternatively, you can install Mocha globally for direct CLI usage:

npm install --global mocha

Configuring the Test Script

Update package.json to define a test command. The default test directory is /test, and Mocha looks for JavaScript files matching patterns like *.test.js or *.spec.js:

{
  "name": "mocha-testing-guide",
  "scripts": {
    "test": "mocha --reporter spec --timeout 5000"
  },
  "devDependencies": {
    "chai": "^5.0.0",
    "mocha": "^11.0.0"
  }
}

Now running npm test executes Mocha with the spec reporter and a 5-second timeout per test.

Writing Your First Test Suite

Mocha organizes tests using two primary functions: describe for grouping related tests into suites, and it for defining individual test cases. This nesting creates a clear, readable hierarchy.

Basic Test Structure

Create a file test/math.test.js:

// Import assertion library
const { expect } = require('chai');

// The function we'll be testing (typically imported from your source code)
function add(a, b) {
  return a + b;
}

// Describe block groups related tests
describe('Math Operations', function() {
  
  // A nested describe for a specific function
  describe('#add()', function() {
    
    // Individual test case
    it('should return the sum of two positive numbers', function() {
      const result = add(2, 3);
      expect(result).to.equal(5);
    });

    it('should handle negative numbers correctly', function() {
      const result = add(-2, 3);
      expect(result).to.equal(1);
    });

    it('should return zero when adding zero to zero', function() {
      const result = add(0, 0);
      expect(result).to.equal(0);
    });
  });
});

Run the tests:

npm test

Expected output with the spec reporter:

  Math Operations
    #add()
      ✓ should return the sum of two positive numbers
      ✓ should handle negative numbers correctly
      ✓ should return zero when adding zero to zero

  3 passing (15ms)

Using Different Assertion Styles with Chai

Chai offers three assertion interfaces. Choose the one that fits your team's preference:

const chai = require('chai');

// 1. Assert style (TDD, similar to Node.js assert)
const assert = chai.assert;
assert.equal(add(2, 3), 5);

// 2. Expect style (BDD, chainable)
const expect = chai.expect;
expect(add(2, 3)).to.equal(5);

// 3. Should style (BDD, extends Object.prototype)
const should = chai.should();
add(2, 3).should.equal(5);

Test Hooks: Setup and Teardown

Mocha provides four hooks for managing test lifecycle — setting up preconditions and cleaning up after tests. These hooks are essential for tests that require shared state, database connections, or temporary resources.

The Four Hooks

Practical Example with Hooks

Here's a test suite for a hypothetical user database module, demonstrating proper setup and teardown:

const { expect } = require('chai');
const UserDatabase = require('../src/user-database');

describe('UserDatabase', function() {
  let db;
  const TEST_USERS = [
    { id: 1, name: 'Alice', email: 'alice@example.com' },
    { id: 2, name: 'Bob', email: 'bob@example.com' }
  ];

  // Runs once before all tests in this suite
  before(async function() {
    db = new UserDatabase();
    await db.connect('mongodb://localhost:27017/test-db');
    console.log('Database connection established');
  });

  // Runs before each individual test
  beforeEach(async function() {
    await db.clear();
    await db.insertMany(TEST_USERS);
    console.log('Test data seeded');
  });

  // Runs after each individual test
  afterEach(async function() {
    await db.clear();
    console.log('Test data cleaned up');
  });

  // Runs once after all tests complete
  after(async function() {
    await db.disconnect();
    console.log('Database connection closed');
  });

  describe('#findById()', function() {
    it('should return a user when given a valid ID', async function() {
      const user = await db.findById(1);
      expect(user).to.deep.equal(TEST_USERS[0]);
    });

    it('should return null for a non-existent ID', async function() {
      const user = await db.findById(999);
      expect(user).to.be.null;
    });
  });

  describe('#findByEmail()', function() {
    it('should find a user by email address', async function() {
      const user = await db.findByEmail('bob@example.com');
      expect(user.name).to.equal('Bob');
    });

    it('should return null for unknown email', async function() {
      const user = await db.findByEmail('unknown@example.com');
      expect(user).to.be.null;
    });
  });
});

This example demonstrates a clean test pattern: each test starts with a known database state (seeded in beforeEach), ensuring isolation and repeatability. The afterEach cleans up to prevent cross-test contamination.

Asynchronous Testing in Depth

Mocha excels at asynchronous testing. There are three patterns for telling Mocha that a test is complete: callbacks, promises, and async/await. Understanding all three is crucial because you'll encounter each in different codebases.

Pattern 1: The Done Callback

For callback-based APIs, Mocha passes a done callback to your test function. Call done() when the test completes, or done(error) to indicate failure:

const { expect } = require('chai');
const fs = require('fs');

describe('File System Operations (callback style)', function() {
  
  it('should read a file asynchronously', function(done) {
    fs.readFile('package.json', 'utf8', function(err, data) {
      // Calling done with an error fails the test
      if (err) return done(err);
      
      expect(data).to.be.a('string');
      expect(data).to.include('"name"');
      // Signal test completion
      done();
    });
  });

  it('should fail if done is never called (timeout)', function(done) {
    // Mocha will timeout after the specified duration (default 2000ms)
    // This test will fail because done() is never invoked
    // Uncomment to see timeout behavior:
    // setTimeout(() => { /* oops, forgot done() */ }, 3000);
    done(); // Properly complete the test
  });
});

Pattern 2: Returning a Promise

Simply return a promise from your test function. Mocha waits for it to resolve or reject. This is cleaner than the done callback:

const axios = require('axios');
const { expect } = require('chai');

describe('HTTP API Testing (promise style)', function() {
  
  it('should fetch user data from JSONPlaceholder', function() {
    // Return the promise — Mocha handles the rest
    return axios.get('https://jsonplaceholder.typicode.com/users/1')
      .then(response => {
        expect(response.status).to.equal(200);
        expect(response.data.name).to.equal('Leanne Graham');
        expect(response.data.email).to.be.a('string');
      });
  });

  it('should handle errors properly', function() {
    return axios.get('https://jsonplaceholder.typicode.com/users/99999')
      .then(response => {
        // If we reach here, the test should fail
        throw new Error('Expected request to fail but it succeeded');
      })
      .catch(error => {
        expect(error.response.status).to.equal(404);
      });
  });
});

Pattern 3: Async/Await (Modern Approach)

Async/await is the most readable pattern and the recommended approach for modern Node.js applications:

const { expect } = require('chai');
const { fetchUserData, processUserData } = require('../src/user-service');

describe('User Service (async/await style)', function() {
  
  it('should fetch and process user data', async function() {
    // Test reads like synchronous code
    const rawData = await fetchUserData(42);
    expect(rawData).to.have.property('id', 42);
    
    const processed = await processUserData(rawData);
    expect(processed.fullName).to.be.a('string');
    expect(processed.isActive).to.be.a('boolean');
    expect(processed.lastUpdated).to.be.a('date');
  });

  it('should throw a ValidationError for invalid user IDs', async function() {
    try {
      await fetchUserData(-1);
      // If we reach here, the expected error wasn't thrown
      expect.fail('Expected ValidationError was not thrown');
    } catch (error) {
      expect(error.name).to.equal('ValidationError');
      expect(error.message).to.include('invalid user ID');
    }
  });

  // Testing multiple async operations sequentially
  it('should process multiple users in order', async function() {
    const userIds = [1, 2, 3];
    const results = [];
    
    for (const id of userIds) {
      const data = await fetchUserData(id);
      results.push(data.name);
    }
    
    expect(results).to.deep.equal(['Alice', 'Bob', 'Charlie']);
  });
});

With async/await, there's no need for done() or explicit promise returns — just write async function and use await inside your tests.

Important Async Rules

Controlling Test Execution

Mocha provides several mechanisms to control which tests run and how they behave. These features are invaluable during development, debugging, and CI/CD pipeline configuration.

Skipping Tests

Use it.skip() or describe.skip() to temporarily disable tests without removing the code:

describe('Payment Processor', function() {
  
  // Skip this entire suite while the payment API is being refactored
  describe.skip('Stripe Integration', function() {
    it('should process credit card payments', async function() {
      // This test won't run
    });
  });

  describe('PayPal Integration', function() {
    it('should process PayPal payments', async function() {
      const result = await processPayPal({ amount: 50 });
      expect(result.status).to.equal('completed');
    });

    // Skip a single test that depends on an unfinished feature
    it.skip('should handle PayPal refunds', async function() {
      // Marked as pending — will show in output but won't execute
    });
  });
});

Exclusive Tests (Only)

Use it.only() or describe.only() to run a subset of tests in isolation. This is extremely useful when debugging a specific failure:

describe('Order Service', function() {
  
  it('should create an order', async function() {
    // This runs normally when no .only is present
  });

  // Only this test runs when executing the suite
  it.only('should calculate shipping cost for international orders', async function() {
    const shipping = await calculateShipping({ 
      destination: 'France', 
      weight: 2.5 
    });
    expect(shipping.cost).to.be.within(15, 45);
  });

  it('should apply discount codes', async function() {
    // Skipped because .only is active elsewhere in this file
  });
});

When multiple .only markers exist across different suites, all exclusively marked tests run together. This allows focused testing of related features.

Pending Tests

A test without a callback function is treated as pending — a placeholder for future implementation:

describe('Shopping Cart', function() {
  
  it('should add items to cart');
  
  it('should remove items from cart');
  
  it('should calculate subtotal correctly', function() {
    const cart = new ShoppingCart();
    cart.add({ name: 'Book', price: 29.99 });
    expect(cart.subtotal()).to.equal(29.99);
  });
  
  // Explicit pending with descriptive message
  it('should apply tax based on shipping address');
});

Pending tests appear in the output with a special indicator, reminding the team of remaining work.

Test Retries

For flaky tests (those that occasionally fail due to timing or external dependencies), Mocha supports automatic retries:

describe('External Weather API', function() {
  // Retry each test up to 3 times before marking as failed
  this.retries(3);

  it('should fetch current temperature', async function() {
    const temp = await weatherAPI.getTemperature('London');
    expect(temp).to.be.a('number');
    expect(temp).to.be.within(-50, 60);
  });

  // Override retry count for a specific test
  it('should fetch 7-day forecast', async function() {
    this.retries(5); // More retries for this flaky endpoint
    const forecast = await weatherAPI.getForecast('Tokyo');
    expect(forecast).to.have.lengthOf(7);
  });
});

Use retries sparingly — they're a band-aid for flaky tests, not a substitute for fixing root causes like race conditions or unreliable external services.

CLI Configuration and Advanced Options

Mocha's command-line interface is rich with options that tailor test execution to different environments and workflows.

Common CLI Flags

# Run with a specific reporter
mocha --reporter spec

# Set timeout to 10 seconds for slow integration tests
mocha --timeout 10000

# Run tests matching a pattern (grep)
mocha --grep "payment"

# Run in watch mode — re-executes on file changes
mocha --watch

# Run tests recursively in subdirectories
mocha --recursive

# Output results as JSON for CI consumption
mocha --reporter json > test-results.json

# Bail after the first test failure
mocha --bail

# Run tests in parallel (experimental)
mocha --parallel --jobs 4

# Check for global variable leaks
mocha --check-leaks

# Require modules before tests run (e.g., Babel, ts-node)
mocha --require ts-node/register

# Specify test file(s) explicitly
mocha test/math.test.js test/user.test.js

Configuration File (.mocharc)

For complex projects, use a configuration file to centralize options. Mocha supports multiple formats: .mocharc.js, .mocharc.json, .mocharc.yml, or mocha key in package.json:

// .mocharc.js
module.exports = {
  // Reporter configuration
  reporter: 'spec',
  
  // Timeout for each test (in milliseconds)
  timeout: 5000,
  
  // Bail after first failure — useful in CI
  bail: true,
  
  // Recursively find test files
  recursive: true,
  
  // Test file patterns
  spec: [
    'test/**/*.test.js',
    'test/**/*.spec.js'
  ],
  
  // Exclude certain directories
  ignore: [
    'test/fixtures/**',
    'test/external/**'
  ],
  
  // Watch mode options
  watch: false,
  'watch-files': ['src/**/*.js', 'test/**/*.js'],
  'watch-ignore': ['node_modules'],
  
  // Parallel execution
  parallel: false,
  jobs: 4,
  
  // Require modules before tests
  require: [
    'chai/register-expect',
    './test/helpers/setup.js'
  ],
  
  // Environment-specific overrides
  env: {
    NODE_ENV: 'test'
  }
};

Programmatic Usage

You can also run Mocha from within Node.js scripts, which is useful for custom tooling or integrating with build systems:

const Mocha = require('mocha');
const path = require('path');

async function runTests() {
  const mocha = new Mocha({
    reporter: 'spec',
    timeout: 5000,
    color: true
  });

  // Add test files
  const testDir = path.join(__dirname, 'test');
  mocha.addFile(path.join(testDir, 'math.test.js'));
  mocha.addFile(path.join(testDir, 'user.test.js'));

  // Run and capture results
  const failures = await new Promise((resolve) => {
    mocha.run((failures) => {
      resolve(failures);
    });
  });

  console.log(`Tests completed with ${failures} failure(s)`);
  
  // Exit with appropriate code for CI
  process.exitCode = failures ? 1 : 0;
}

runTests().catch(err => {
  console.error('Test runner crashed:', err);
  process.exit(1);
});

Testing Patterns and Real-World Examples

Let's explore practical testing scenarios that you'll encounter in production codebases. Each example demonstrates a complete, self-contained test suite with proper setup and assertions.

Example 1: Testing an Express.js API

Testing HTTP endpoints requires simulating requests and verifying responses. Here's a complete example using Supertest alongside Mocha and Chai:

// File: test/api/users.test.js
const { expect } = require('chai');
const request = require('supertest');
const app = require('../../src/app');
const { setupTestDatabase, teardownTestDatabase } = require('../helpers/db');

describe('Users API', function() {
  
  // Increase timeout for database operations
  this.timeout(10000);

  before(async function() {
    await setupTestDatabase();
  });

  after(async function() {
    await teardownTestDatabase();
  });

  describe('GET /api/users', function() {
    it('should return a list of users with status 200', async function() {
      const response = await request(app)
        .get('/api/users')
        .expect('Content-Type', /json/)
        .expect(200);

      expect(response.body).to.be.an('array');
      expect(response.body).to.have.lengthOf.at.least(1);
      expect(response.body[0]).to.include.keys('id', 'name', 'email');
    });

    it('should support pagination with limit and offset', async function() {
      const response = await request(app)
        .get('/api/users?limit=2&offset=0')
        .expect(200);

      expect(response.body).to.have.lengthOf.at.most(2);
    });
  });

  describe('GET /api/users/:id', function() {
    it('should return a single user by ID', async function() {
      const response = await request(app)
        .get('/api/users/1')
        .expect(200);

      expect(response.body).to.be.an('object');
      expect(response.body.id).to.equal(1);
      expect(response.body.name).to.be.a('string');
    });

    it('should return 404 for non-existent user', async function() {
      const response = await request(app)
        .get('/api/users/99999')
        .expect(404);

      expect(response.body).to.have.property('error');
      expect(response.body.error).to.include('not found');
    });
  });

  describe('POST /api/users', function() {
    it('should create a new user and return 201', async function() {
      const newUser = {
        name: 'Charlie',
        email: 'charlie@example.com',
        age: 28
      };

      const response = await request(app)
        .post('/api/users')
        .send(newUser)
        .expect(201);

      expect(response.body).to.include(newUser);
      expect(response.body.id).to.be.a('number');
    });

    it('should return 400 for invalid user data', async function() {
      const invalidUser = { name: '' }; // Missing required fields

      const response = await request(app)
        .post('/api/users')
        .send(invalidUser)
        .expect(400);

      expect(response.body.error).to.be.a('string');
    });
  });
});

Example 2: Testing with Mocks and Stubs

When unit testing, you often need to isolate the code under test from its dependencies. Sinon provides powerful mocking capabilities:

// File: test/services/notification.test.js
const { expect } = require('chai');
const sinon = require('sinon');
const notificationService = require('../../src/services/notification');
const emailGateway = require('../../src/gateways/email');
const smsGateway = require('../../src/gateways/sms');

describe('Notification Service', function() {
  let emailSendStub;
  let smsSendStub;

  beforeEach(function() {
    // Stub external gateway methods to avoid actual API calls
    emailSendStub = sinon.stub(emailGateway, 'send');
    smsSendStub = sinon.stub(smsGateway, 'send');
  });

  afterEach(function() {
    // Restore original methods after each test
    sinon.restore();
  });

  describe('#notifyUser()', function() {
    it('should send an email notification for high-priority alerts', async function() {
      // Configure stub to resolve successfully
      emailSendStub.resolves({ status: 'sent', messageId: 'abc-123' });

      const result = await notificationService.notifyUser({
        userId: 42,
        message: 'Your order has shipped!',
        priority: 'high'
      });

      expect(result.success).to.be.true;
      
      // Verify the stub was called with correct arguments
      expect(emailSendStub.calledOnce).to.be.true;
      expect(emailSendStub.firstCall.args[0]).to.deep.include({
        to: sinon.match.string,
        subject: sinon.match.string,
        body: sinon.match.string
      });
    });

    it('should fall back to SMS when email fails', async function() {
      // Email stub rejects, SMS stub resolves
      emailSendStub.rejects(new Error('SMTP server down'));
      smsSendStub.resolves({ delivered: true });

      const result = await notificationService.notifyUser({
        userId: 42,
        message: 'Urgent: security alert',
        priority: 'critical'
      });

      expect(result.success).to.be.true;
      expect(result.fallbackUsed).to.equal('sms');
      expect(emailSendStub.calledOnce).to.be.true;
      expect(smsSendStub.calledOnce).to.be.true;
    });

    it('should throw when all notification channels fail', async function() {
      emailSendStub.rejects(new Error('Email failed'));
      smsSendStub.rejects(new Error('SMS failed'));

      try {
        await notificationService.notifyUser({
          userId: 42,
          message: 'Test message',
          priority: 'high'
        });
        expect.fail('Should have thrown');
      } catch (error) {
        expect(error.message).to.include('All notification channels failed');
        expect(error.code).to.equal('NOTIFICATION_FAILURE');
      }
    });
  });
});

Example 3: Snapshot Testing with Custom Serializers

Snapshot testing captures the output of a function and compares it against a stored reference. This is excellent for testing UI components, API responses, or complex object transformations:

// File: test/serializers/user-serializer.test.js
const { expect } = require('chai');
const fs = require('fs');
const path = require('path');
const { serializeUserProfile } = require('../../src/serializers/user-serializer');

describe('User Profile Serializer (snapshot testing)', function() {
  const snapshotDir = path.join(__dirname, '..', 'snapshots');
  let currentSnapshots = {};

  before(function() {
    if (!fs.existsSync(snapshotDir)) {
      fs.mkdirSync(snapshotDir, { recursive: true });
    }
  });

  after(function() {
    const snapshotPath = path.join(snapshotDir, 'user-serializer.json');
    fs.writeFileSync(snapshotPath, JSON.stringify(currentSnapshots, null, 2));
  });

  it('should serialize a complete user profile consistently', function() {
    const userInput = {
      id: 42,
      firstName: 'Jane',
      lastName: 'Doe',
      email: 'jane@example.com',
      dateOfBirth: new Date('1990-05-15'),
      preferences: {
        newsletter: true,
        theme: 'dark',
        language: 'en'
      },
      lastLogin: new Date('2024-01-15T08:30:00Z'),
      roles: ['user', 'editor']
    };

    const serialized = serializeUserProfile(userInput);
    
    // Store snapshot
    currentSnapshots['complete profile'] = serialized;

    // Verify structure
    expect(serialized).to.be.an('object');
    expect(serialized.displayName).to.equal('Jane Doe');
    expect(serialized.age).to.be.a('number');
    expect(serialized.preferences).to.be.an('object');
    expect(serialized.roles).to.deep.equal(['user', 'editor']);
    
    // Verify date formatting
    expect(serialized.dateOfBirth).to.match(/^\d{4}-\d{2}-\d{2}$/);
    expect(serialized.lastLogin).to.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
  });

  it('should handle edge cases: missing optional fields', function() {
    const minimalUser = {
      id: 7,
      firstName: 'Min',
      lastName: 'User',
      email: 'min@example.com'
    };

    const serialized = serializeUserProfile(minimalUser);
    currentSnapshots['minimal profile'] = serialized;

    expect(serialized.displayName).to.equal('Min User');
    expect(serialized.preferences).to.deep.equal({});
    expect(serialized.roles).to.deep.equal(['user']);
    expect(serialized.dateOfBirth).to.be.null;
  });
});

Best Practices for Effective Mocha Testing

Writing tests is a skill that improves with practice and discipline. Here are battle-tested best practices that will make your Mocha test suites maintainable, reliable, and fast.

1. Structure Tests Clearly

Organize test files to mirror your source code structure. For a module at src/services/payment.js, place its tests at test/services/payment.test.js. Use nested describe blocks to create a readable hierarchy that serves as living documentation:

// Good: Clear, nested structure
describe('PaymentService', function() {
  describe('#processPayment()', function() {
    describe('with valid credit card', function() {
      it('should authorize the transaction');
      it('should deduct the correct amount');
    });
    describe('with expired credit card', function() {
      it('should return a decline reason');
      it('should not charge the customer');
    });
  });
});

2. Keep Tests Isolated and Atomic

Each test should set up its own state and not depend on the execution order of other tests. Use beforeEach and afterEach to reset shared state. Never rely on test execution order — Mocha runs tests in the order they're defined within a file, but this is not guaranteed across files or in parallel mode.

3. Use Descriptive Test Names

Test names should describe the expected behavior clearly. A good test name reads like a specification: "should return 400 when email is invalid" or "should emit 'error' event on connection timeout". Avoid vague names like "test #42" or "works correctly".

4. Follow the AAA Pattern

Structure test bodies with Arrange, Act, Assert:

it('should apply discount to premium members', async function() {
  // Arrange: Set up the scenario
  const user = { membership: 'premium', cartTotal: 100 };
  const discountService = new DiscountService();
  
  // Act: Execute the code under test
  const discountedPrice = await discountService.calculate(user);
  
  // Assert: Verify the outcome
  expect(discountedPrice).to.equal(80);
});

5. Avoid Testing Implementation Details

Test the public API and observable behavior, not internal state or private methods. If you refactor the implementation, the tests should still pass as long as the behavior remains correct. This principle makes tests resilient to code changes:

// Bad: Testing internal implementation
expect(cache._internalMap.has('user:42')).to.be.true;

// Good: Testing observable behavior
const user = await userService.getById(42);
expect(user.name).to.equal('Alice');
// Second call should be fast (cached) — test behavior, not internals
const cachedUser = await userService.getById(42);
expect(cachedUser).to.deep.equal(user);

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles