Welcome to Chapter 10 of our Node.js backend development journey! In this pivotal chapter, we shift our focus from building features to ensuring their reliability, correctness, and maintainability through comprehensive testing. A robust test suite is the bedrock of any production-ready application, providing confidence for future development, refactoring, and deployments.

We will establish a multi-layered testing strategy covering Unit, Integration, and End-to-End (E2E) tests. We’ll leverage industry-standard tools like Jest for our primary test runner and assertion library, and Supertest for making HTTP requests to our API in integration and E2E scenarios. By the end of this chapter, you will have a solid understanding of how to write effective tests for various components of your application, significantly enhancing its quality and stability.

This chapter assumes you have a working API with authentication, authorization, and database interactions from previous chapters. We will build upon those existing features to demonstrate how to test them effectively. Our goal is to create working, testable code that adheres to best practices, ensuring that each new feature introduced is thoroughly validated before it ever reaches production.

Planning & Design

Before diving into code, let’s outline our testing strategy. We’ll follow the widely accepted “testing pyramid” model, which suggests a higher proportion of fast, isolated unit tests, a moderate amount of integration tests, and a smaller number of comprehensive E2E tests.

The Testing Pyramid

graph TD End-to-End_Tests[End-to-End Tests] Integration_Tests[Integration Tests] Unit_Tests[Unit Tests] Unit_Tests --> Integration_Tests Integration_Tests --> End-to-End_Tests style Unit_Tests fill:#0f0,stroke:#333,stroke-width:2px style Integration_Tests fill:#ff0,stroke:#333,stroke-width:2px style End-to-End_Tests fill:#f00,stroke:#333,stroke-width:2px subgraph Speed Unit_Tests -- Fast --> Integration_Tests Integration_Tests -- Slower --> End-to-End_Tests end subgraph Scope Unit_Tests -- "Smallest Scope" --> Integration_Tests Integration_Tests -- "Medium Scope" --> End-to-End_Tests End-to-End_Tests -- "Broadest Scope" --> Unit_Tests end

*   **Unit Tests:** Focus on individual functions, methods, or classes in isolation. They should be fast and mock all external dependencies (e.g., database calls, API requests, file system operations).
*   **Integration Tests:** Verify the interaction between different components (e.g., a controller interacting with a service, or a service interacting with a database). These tests often require a real (but isolated) database instance.
*   **End-to-End (E2E) Tests:** Simulate real user scenarios by interacting with the deployed application (or a locally running instance) as a whole. For a backend API, this means making a sequence of HTTP requests to simulate a multi-step user flow (e.g., register, login, create resource, update resource, delete resource).

#### Test File Structure

We'll adopt a common practice of placing test files alongside the code they test, typically in a `__tests__` directory or with a `.test.js` (or `.spec.js`) suffix. For this project, we'll use a `tests/` directory at the root, with subdirectories mirroring our `src/` structure, allowing for clear separation and organization.

. ├── src/ │ ├── config/ │ ├── controllers/ │ ├── middlewares/ │ ├── models/ │ ├── routes/ │ ├── services/ │ └── app.js ├── tests/ │ ├── unit/ │ │ ├── services/ │ │ └── utils/ │ ├── integration/ │ │ ├── controllers/ │ │ └── routes/ │ └── e2e/ │ └── api.test.js ├── package.json └── jest.config.js


### Step-by-Step Implementation

#### 1. Setup Test Environment

First, let's install our testing dependencies: Jest for the test runner and Supertest for HTTP assertions.

```bash
npm install --save-dev jest supertest @jest/globals dotenv
  • jest: The primary testing framework.
  • supertest: A library to test HTTP assertions, making it easy to test API endpoints.
  • @jest/globals: Provides global Jest functions without needing to import them (optional, but good practice for clarity).
  • dotenv: To load environment variables for testing.

Next, configure Jest. Create a jest.config.js file at the root of your project.

// jest.config.js
module.exports = {
  // Automatically clear mock calls and instances between every test
  clearMocks: true,

  // Indicates whether the coverage information should be collected while executing the test
  collectCoverage: true,

  // An array of glob patterns indicating a set of files for which coverage information should be collected
  collectCoverageFrom: ['src/**/*.js', '!src/app.js', '!src/config/*.js', '!src/routes/*.js', '!src/server.js'],

  // The directory where Jest should output its coverage files
  coverageDirectory: 'coverage',

  // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
  testPathIgnorePatterns: ['/node_modules/', '/dist/'],

  // The test environment that will be used for testing
  testEnvironment: 'node',

  // Run tests from one or more projects
  projects: [
    {
      displayName: 'unit',
      testMatch: ['<rootDir>/tests/unit/**/*.test.js'],
      setupFiles: ['dotenv/config'], // Load .env for unit tests
    },
    {
      displayName: 'integration',
      testMatch: ['<rootDir>/tests/integration/**/*.test.js'],
      setupFiles: ['dotenv/config'], // Load .env for integration tests
      // Setup files for integration tests (e.g., database setup/teardown)
      setupFilesAfterEnv: ['<rootDir>/tests/integration/setup.js'],
    },
    {
      displayName: 'e2e',
      testMatch: ['<rootDir>/tests/e2e/**/*.test.js'],
      setupFiles: ['dotenv/config'], // Load .env for e2e tests
      // Setup files for e2e tests (e.g., starting/stopping the server)
      setupFilesAfterEnv: ['<rootDir>/tests/e2e/setup.js'],
    },
  ],
};

Explanation:

  • clearMocks: Ensures a clean state for mocks between tests.
  • collectCoverage: Enables code coverage reporting.
  • collectCoverageFrom: Specifies which files to include in coverage reports, excluding configuration and main entry files.
  • coverageDirectory: Where coverage reports will be saved.
  • testPathIgnorePatterns: Excludes node_modules and dist from test discovery.
  • testEnvironment: 'node': Specifies the testing environment for Node.js.
  • projects: This is crucial for organizing different types of tests. Each project has:
    • displayName: A unique name for the test suite.
    • testMatch: A glob pattern to find test files for this project.
    • setupFiles: Files to run before the test environment is set up (e.g., loading .env).
    • setupFilesAfterEnv: Files to run after the test environment is set up, but before each test file (e.g., database connection, server start/stop).

Now, update your package.json scripts to easily run tests:

// package.json
{
  "name": "your-project",
  "version": "1.0.0",
  "description": "Node.js backend project",
  "main": "src/server.js",
  "scripts": {
    "start": "node src/server.js",
    "dev": "nodemon src/server.js",
    "test": "jest",
    "test:unit": "jest --projects unit",
    "test:integration": "jest --projects integration",
    "test:e2e": "jest --projects e2e",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  },
  "dependencies": {
    // ... your existing dependencies
  },
  "devDependencies": {
    "jest": "^29.x.x",
    "supertest": "^6.x.x",
    "@jest/globals": "^29.x.x",
    "dotenv": "^16.x.x",
    "nodemon": "^3.x.x"
  }
}

Environment Variables for Testing: Create a .env.test file in your project root. This will hold environment variables specifically for your tests. For instance, you might use a separate test database.

# .env.test
NODE_ENV=test
PORT=3001
DATABASE_URL=postgresql://user:password@localhost:5433/test_db
JWT_SECRET=supersecretjwtkeyfortesting
LOG_LEVEL=warn

Ensure your src/config/index.js (or similar) loads the correct .env file based on NODE_ENV.

// src/config/index.js (example, adjust to your actual config loading)
require('dotenv').config({ path: process.env.NODE_ENV === 'test' ? '.env.test' : '.env' });

const config = {
  env: process.env.NODE_ENV || 'development',
  port: process.env.PORT || 3000,
  database: {
    url: process.env.DATABASE_URL || 'postgresql://user:password@localhost:5432/main_db',
  },
  jwt: {
    secret: process.env.JWT_SECRET || 'your_default_secret',
    expiresIn: process.env.JWT_EXPIRES_IN || '1h',
  },
  logLevel: process.env.LOG_LEVEL || 'info',
  // ... other configurations
};

module.exports = config;

2. Unit Testing

Let’s pick a simple utility function or service method to unit test. Suppose we have a utils/auth.js that generates JWT tokens.

File: src/utils/auth.js

// src/utils/auth.js
const jwt = require('jsonwebtoken');
const config = require('../config');
const logger = require('../utils/logger'); // Assuming you have a logger utility

/**
 * Generates a JWT token for a given user.
 * @param {object} user - The user object containing id, email, roles.
 * @returns {string} The generated JWT token.
 */
function generateAuthToken(user) {
  try {
    const payload = {
      id: user.id,
      email: user.email,
      roles: user.roles,
    };
    const token = jwt.sign(payload, config.jwt.secret, {
      expiresIn: config.jwt.expiresIn,
    });
    logger.debug(`Generated JWT token for user: ${user.email}`);
    return token;
  } catch (error) {
    logger.error(`Error generating auth token for user ${user.email}: ${error.message}`);
    throw new Error('Failed to generate authentication token');
  }
}

/**
 * Verifies a JWT token.
 * @param {string} token - The JWT token to verify.
 * @returns {object} The decoded payload if valid.
 * @throws {Error} If the token is invalid or expired.
 */
function verifyAuthToken(token) {
  try {
    const decoded = jwt.verify(token, config.jwt.secret);
    logger.debug('Verified JWT token successfully.');
    return decoded;
  } catch (error) {
    logger.warn(`JWT token verification failed: ${error.message}`);
    if (error.name === 'TokenExpiredError') {
      throw new Error('Authentication token expired');
    }
    if (error.name === 'JsonWebTokenError') {
      throw new Error('Invalid authentication token');
    }
    throw new Error('Failed to verify authentication token');
  }
}

module.exports = {
  generateAuthToken,
  verifyAuthToken,
};

Now, let’s write unit tests for generateAuthToken and verifyAuthToken.

File: tests/unit/utils/auth.test.js

// tests/unit/utils/auth.test.js
const { generateAuthToken, verifyAuthToken } = require('../../../src/utils/auth');
const jwt = require('jsonwebtoken');
const config = require('../../../src/config');
const logger = require('../../../src/utils/logger'); // Import logger to mock it

// Mock the logger to prevent console output during tests
jest.mock('../../../src/utils/logger', () => ({
  debug: jest.fn(),
  info: jest.fn(),
  warn: jest.fn(),
  error: jest.fn(),
}));

describe('Auth Utilities - Unit Tests', () => {
  const mockUser = { id: 'user123', email: 'test@example.com', roles: ['user'] };
  const mockSecret = 'test_jwt_secret_for_unit_tests';
  const mockExpiresIn = '1h';

  // Set mock config values for JWT
  beforeAll(() => {
    config.jwt.secret = mockSecret;
    config.jwt.expiresIn = mockExpiresIn;
  });

  afterEach(() => {
    jest.clearAllMocks(); // Clear mock calls after each test
  });

  describe('generateAuthToken', () => {
    it('should generate a valid JWT token', () => {
      const token = generateAuthToken(mockUser);
      expect(token).toBeDefined();
      expect(typeof token).toBe('string');

      // Verify the token content
      const decoded = jwt.verify(token, mockSecret);
      expect(decoded.id).toBe(mockUser.id);
      expect(decoded.email).toBe(mockUser.email);
      expect(decoded.roles).toEqual(mockUser.roles);
      expect(decoded.exp).toBeDefined();
      expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining(`Generated JWT token for user: ${mockUser.email}`));
    });

    it('should throw an error if JWT signing fails', () => {
      // Temporarily mock jwt.sign to throw an error
      jest.spyOn(jwt, 'sign').mockImplementationOnce(() => {
        throw new Error('JWT signing failed');
      });

      expect(() => generateAuthToken(mockUser)).toThrow('Failed to generate authentication token');
      expect(logger.error).toHaveBeenCalledWith(expect.stringContaining(`Error generating auth token for user ${mockUser.email}: JWT signing failed`));
    });
  });

  describe('verifyAuthToken', () => {
    it('should successfully verify a valid token', () => {
      const token = jwt.sign(mockUser, mockSecret, { expiresIn: mockExpiresIn });
      const decoded = verifyAuthToken(token);
      expect(decoded.id).toBe(mockUser.id);
      expect(decoded.email).toBe(mockUser.email);
      expect(decoded.roles).toEqual(mockUser.roles);
      expect(logger.debug).toHaveBeenCalledWith('Verified JWT token successfully.');
    });

    it('should throw an error for an expired token', () => {
      // Generate an expired token
      const expiredToken = jwt.sign(mockUser, mockSecret, { expiresIn: '0s' });
      // Wait for a short moment to ensure token expires
      jest.advanceTimersByTime(100); // Advance timers by 100ms
      expect(() => verifyAuthToken(expiredToken)).toThrow('Authentication token expired');
      expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('JWT token verification failed: jwt expired'));
    });

    it('should throw an error for an invalid token (malformed)', () => {
      const invalidToken = 'invalid.jwt.token';
      expect(() => verifyAuthToken(invalidToken)).toThrow('Invalid authentication token');
      expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('JWT token verification failed: jwt malformed'));
    });

    it('should throw an error for an invalid token (wrong secret)', () => {
      const tokenWithWrongSecret = jwt.sign(mockUser, 'wrong_secret', { expiresIn: mockExpiresIn });
      expect(() => verifyAuthToken(tokenWithWrongSecret)).toThrow('Invalid authentication token');
      expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('JWT token verification failed: invalid signature'));
    });
  });
});

Explanation:

  • Mocking: We use jest.mock to mock the logger utility. This prevents test logs from cluttering the console and allows us to assert if the logger was called correctly. jest.spyOn is used to temporarily mock jwt.sign to simulate an error.
  • beforeAll / afterEach: beforeAll sets up global test configurations, while afterEach cleans up mocks to ensure test isolation.
  • Assertions: We use Jest’s expect API (toBeDefined, toBe, toEqual, toThrow, toHaveBeenCalledWith) to verify behavior.
  • Timer Mocks: jest.advanceTimersByTime is used to simulate time passing for testing token expiration without actually waiting. Remember to add jest.useFakeTimers() at the top of the test file if you use this. For this specific case, setting expiresIn: '0s' is usually enough.

To run these unit tests:

npm run test:unit

3. Integration Testing (API Endpoints)

Integration tests verify that different parts of our application work together, especially focusing on API endpoints and their interaction with services and the database.

First, we need a way to connect to a test database and manage its state. Create tests/integration/setup.js.

File: tests/integration/setup.js

// tests/integration/setup.js
const { connectDB, disconnectDB } = require('../../src/config/database'); // Assuming you have database connection utility
const { seedDatabase, clearDatabase } = require('./database-helper'); // We'll create this next
const app = require('../../src/app'); // Your main Express/Fastify app instance
const supertest = require('supertest');

let server; // To hold the server instance
global.request = supertest(app); // Make supertest instance globally available for convenience

beforeAll(async () => {
  // Ensure NODE_ENV is set to 'test'
  process.env.NODE_ENV = 'test';
  require('dotenv').config({ path: '.env.test' }); // Load test environment variables

  // 1. Connect to the test database
  await connectDB();
  console.log('Connected to test database.');

  // 2. Clear and seed the database with initial data
  await clearDatabase();
  await seedDatabase();
  console.log('Test database cleared and seeded.');

  // 3. Start the server
  server = app.listen(process.env.PORT || 3001, () => {
    console.log(`Test server running on port ${process.env.PORT || 3001}`);
  });
});

afterAll(async () => {
  // 1. Disconnect from the database
  await disconnectDB();
  console.log('Disconnected from test database.');

  // 2. Close the server
  await server.close();
  console.log('Test server closed.');
});

afterEach(async () => {
  // Optionally clear database or rollback transactions after each test
  // This helps ensure test isolation
  await clearDatabase(); // Or use transactions for faster rollback
  await seedDatabase();
});

Database Helper for Test Data: Create tests/integration/database-helper.js. This file will contain functions to clear and seed your test database. Adjust these functions based on your ORM (TypeORM, Prisma, Mongoose, etc.) and schema.

File: tests/integration/database-helper.js

// tests/integration/database-helper.js
const { AppDataSource } = require('../../src/config/database'); // Assuming TypeORM or similar
const logger = require('../../src/utils/logger');
const bcrypt = require('bcryptjs');

// Example models (adjust to your actual models)
const User = require('../../src/models/User'); // Assuming you have a User model
const Product = require('../../src/models/Product'); // Assuming you have a Product model

async function clearDatabase() {
  if (AppDataSource.isInitialized) {
    try {
      const entities = AppDataSource.entityMetadatas;
      for (const entity of entities) {
        const repository = AppDataSource.getRepository(entity.name);
        await repository.query(`TRUNCATE TABLE "${entity.tableName}" RESTART IDENTITY CASCADE;`);
      }
      logger.info('Test database cleared successfully.');
    } catch (error) {
      logger.error(`Error clearing test database: ${error.message}`);
      throw error;
    }
  }
}

async function seedDatabase() {
  if (AppDataSource.isInitialized) {
    try {
      const userRepository = AppDataSource.getRepository(User);
      const productRepository = AppDataSource.getRepository(Product);

      // Create a test admin user
      const hashedPassword = await bcrypt.hash('password123', 10);
      const adminUser = userRepository.create({
        username: 'admin_test',
        email: 'admin@test.com',
        password: hashedPassword,
        roles: ['admin', 'user'],
        isActive: true,
      });
      await userRepository.save(adminUser);

      // Create a test regular user
      const regularUserPassword = await bcrypt.hash('userpassword', 10);
      const regularUser = userRepository.create({
        username: 'user_test',
        email: 'user@test.com',
        password: regularUserPassword,
        roles: ['user'],
        isActive: true,
      });
      await userRepository.save(regularUser);

      // Create some test products
      await productRepository.save([
        productRepository.create({ name: 'Test Product 1', description: 'Description 1', price: 10.99, stock: 100 }),
        productRepository.create({ name: 'Test Product 2', description: 'Description 2', price: 20.50, stock: 50 }),
      ]);

      logger.info('Test database seeded successfully.');
    } catch (error) {
      logger.error(`Error seeding test database: ${error.message}`);
      throw error;
    }
  }
}

module.exports = {
  clearDatabase,
  seedDatabase,
};

Mocking the main server file To ensure Jest can properly control the server lifecycle (start/stop), modify your src/server.js to export the app instance, and only start the server if it’s not being run in a test environment.

File: src/server.js (example for an Express app)

// src/server.js
const app = require('./app'); // Your Express app instance
const config = require('./config');
const logger = require('./utils/logger');
const { connectDB } = require('./config/database');

// Connect to database
connectDB()
  .then(() => {
    logger.info('Database connected successfully.');
    // Only start the server if not in test environment
    if (config.env !== 'test') {
      app.listen(config.port, () => {
        logger.info(`Server running on port ${config.port} in ${config.env} mode.`);
      });
    }
  })
  .catch((err) => {
    logger.error('Failed to connect to database:', err);
    process.exit(1);
  });

module.exports = app; // Export the app for testing

Now, let’s write an integration test for a user registration and login flow.

File: tests/integration/routes/auth.test.js

// tests/integration/routes/auth.test.js
const { AppDataSource } = require('../../../src/config/database');
const User = require('../../../src/models/User'); // Assuming you have a User model
const config = require('../../../src/config');
const jwt = require('jsonwebtoken');

describe('Auth Routes - Integration Tests', () => {
  let userRepository;

  beforeAll(() => {
    userRepository = AppDataSource.getRepository(User);
  });

  it('should register a new user successfully', async () => {
    const newUser = {
      username: 'newuser',
      email: 'newuser@example.com',
      password: 'securePassword123',
    };

    const response = await global.request
      .post('/api/auth/register') // Assuming your registration endpoint
      .send(newUser)
      .expect(201); // Expect 201 Created

    expect(response.body).toHaveProperty('message', 'User registered successfully');
    expect(response.body).toHaveProperty('user');
    expect(response.body.user).toHaveProperty('id');
    expect(response.body.user).toHaveProperty('username', newUser.username);
    expect(response.body.user).toHaveProperty('email', newUser.email);
    expect(response.body.user).not.toHaveProperty('password'); // Password should not be returned

    // Verify user exists in the database
    const userInDb = await userRepository.findOneBy({ email: newUser.email });
    expect(userInDb).toBeDefined();
    expect(userInDb.username).toBe(newUser.username);
    expect(userInDb.email).toBe(newUser.email);
    expect(userInDb.roles).toEqual(['user']); // Default role
  });

  it('should prevent registration with an existing email', async () => {
    const existingUser = {
      username: 'existinguser',
      email: 'admin@test.com', // Email from our seed data
      password: 'anotherPassword',
    };

    const response = await global.request
      .post('/api/auth/register')
      .send(existingUser)
      .expect(409); // Expect 409 Conflict

    expect(response.body).toHaveProperty('message', 'Email already registered');
  });

  it('should login an existing user and return a JWT token', async () => {
    const loginCredentials = {
      email: 'admin@test.com',
      password: 'password123',
    };

    const response = await global.request
      .post('/api/auth/login') // Assuming your login endpoint
      .send(loginCredentials)
      .expect(200); // Expect 200 OK

    expect(response.body).toHaveProperty('message', 'Logged in successfully');
    expect(response.body).toHaveProperty('token');
    expect(typeof response.body.token).toBe('string');

    // Verify the JWT token
    const decodedToken = jwt.verify(response.body.token, config.jwt.secret);
    expect(decodedToken).toHaveProperty('id');
    expect(decodedToken).toHaveProperty('email', loginCredentials.email);
    expect(decodedToken).toHaveProperty('roles', expect.arrayContaining(['admin', 'user']));
  });

  it('should return 401 for invalid login credentials', async () => {
    const invalidCredentials = {
      email: 'user@test.com',
      password: 'wrongpassword',
    };

    const response = await global.request
      .post('/api/auth/login')
      .send(invalidCredentials)
      .expect(401); // Expect 401 Unauthorized

    expect(response.body).toHaveProperty('message', 'Invalid credentials');
  });
});

Explanation:

  • global.request: Made available by tests/integration/setup.js, this supertest instance is configured to hit our Express/Fastify app.
  • Database Interaction: We directly interact with AppDataSource and userRepository to verify the state of the database after API calls.
  • Seeding/Clearing: The setup.js ensures that for each test run, the database is in a known, clean state with seed data, ensuring test isolation.
  • Assertions: We use expect with supertest’s chained assertions (expect(201), expect(response.body).toHaveProperty) to validate HTTP responses and body content.
  • JWT Verification: We use jsonwebtoken to verify the structure and content of the returned JWT token, using the config.jwt.secret from our test environment.

To run these integration tests:

npm run test:integration

4. End-to-End (E2E) Testing

For a backend API, E2E tests often involve a sequence of API calls that simulate a complete user workflow. We’ll use Supertest for this as well, as it’s perfectly capable of handling multi-step HTTP interactions. The key difference from integration tests is the scope – E2E tests typically cover a broader, more realistic flow.

Let’s create an E2E test that simulates a user registering, logging in, creating a product, and then fetching that product.

File: tests/e2e/setup.js The jest.config.js already points to tests/e2e/setup.js. This file will be similar to the integration setup, but it might involve more complex server startup/shutdown or external dependencies if you were testing with, for example, a message queue. For now, it can be identical to integration/setup.js.

// tests/e2e/setup.js
const { connectDB, disconnectDB } = require('../../src/config/database');
const { seedDatabase, clearDatabase } = require('../integration/database-helper'); // Re-use helper
const app = require('../../src/app');
const supertest = require('supertest');

let server;
global.request = supertest(app);

beforeAll(async () => {
  process.env.NODE_ENV = 'test';
  require('dotenv').config({ path: '.env.test' });

  await connectDB();
  console.log('Connected to test database for E2E.');

  await clearDatabase();
  await seedDatabase();
  console.log('Test database for E2E cleared and seeded.');

  server = app.listen(process.env.PORT || 3001, () => {
    console.log(`E2E test server running on port ${process.env.PORT || 3001}`);
  });
});

afterAll(async () => {
  await disconnectDB();
  console.log('Disconnected from test database for E2E.');

  await server.close();
  console.log('E2E test server closed.');
});

afterEach(async () => {
  await clearDatabase();
  await seedDatabase();
});

File: tests/e2e/user-product-flow.test.js

// tests/e2e/user-product-flow.test.js
describe('User Product Management Workflow - E2E Tests', () => {
  let userToken = '';
  let userId = '';
  let productId = '';

  const testUser = {
    username: 'e2euser',
    email: 'e2e@example.com',
    password: 'e2ePassword123',
  };

  const testProduct = {
    name: 'E2E Test Product',
    description: 'This is a product created during an E2E test.',
    price: 99.99,
    stock: 50,
  };

  it('should register a new user', async () => {
    const res = await global.request
      .post('/api/auth/register')
      .send(testUser)
      .expect(201);

    expect(res.body).toHaveProperty('message', 'User registered successfully');
    expect(res.body.user).toHaveProperty('id');
    userId = res.body.user.id;
  });

  it('should log in the newly registered user', async () => {
    const res = await global.request
      .post('/api/auth/login')
      .send({ email: testUser.email, password: testUser.password })
      .expect(200);

    expect(res.body).toHaveProperty('message', 'Logged in successfully');
    expect(res.body).toHaveProperty('token');
    userToken = res.body.token; // Store the token for subsequent requests
  });

  it('should allow the logged-in user to create a product', async () => {
    const res = await global.request
      .post('/api/products') // Assuming your product creation endpoint
      .set('Authorization', `Bearer ${userToken}`) // Use the stored token
      .send(testProduct)
      .expect(201);

    expect(res.body).toHaveProperty('message', 'Product created successfully');
    expect(res.body.product).toHaveProperty('id');
    expect(res.body.product).toHaveProperty('name', testProduct.name);
    expect(res.body.product).toHaveProperty('price', testProduct.price);
    productId = res.body.product.id; // Store the product ID
  });

  it('should allow the logged-in user to fetch the created product', async () => {
    const res = await global.request
      .get(`/api/products/${productId}`)
      .set('Authorization', `Bearer ${userToken}`)
      .expect(200);

    expect(res.body).toHaveProperty('product');
    expect(res.body.product).toHaveProperty('id', productId);
    expect(res.body.product).toHaveProperty('name', testProduct.name);
  });

  it('should allow the logged-in user to update the created product', async () => {
    const updatedProductData = {
      name: 'E2E Updated Product',
      price: 105.00,
    };

    const res = await global.request
      .put(`/api/products/${productId}`)
      .set('Authorization', `Bearer ${userToken}`)
      .send(updatedProductData)
      .expect(200);

    expect(res.body).toHaveProperty('message', 'Product updated successfully');
    expect(res.body.product).toHaveProperty('id', productId);
    expect(res.body.product).toHaveProperty('name', updatedProductData.name);
    expect(res.body.product).toHaveProperty('price', updatedProductData.price);
  });

  it('should prevent an unauthenticated user from accessing products', async () => {
    await global.request
      .get('/api/products')
      .expect(401); // Expect 401 Unauthorized
  });

  it('should allow the logged-in user to delete the created product', async () => {
    await global.request
      .delete(`/api/products/${productId}`)
      .set('Authorization', `Bearer ${userToken}`)
      .expect(200); // Expect 200 OK

    // Verify product is no longer fetchable
    await global.request
      .get(`/api/products/${productId}`)
      .set('Authorization', `Bearer ${userToken}`)
      .expect(404); // Expect 404 Not Found
  });
});

Explanation:

  • Sequential Tests: Each it block builds upon the previous one, simulating a real user journey. Variables like userToken and productId are passed between tests.
  • Full Flow Coverage: This test covers user registration, login, product creation, retrieval, update, and deletion, including an unauthenticated access attempt.
  • Real Server Interaction: These tests hit the actual running Express/Fastify application, including all middleware, routing, and database interactions, making them true E2E for the API.

To run these E2E tests:

npm run test:e2e

Production Considerations

  1. CI/CD Integration: Integrate your test suite into your Continuous Integration/Continuous Deployment (CI/CD) pipeline. Every pull request or code push should automatically trigger the test suite. If tests fail, the pipeline should block the merge or deployment.
  2. Test Data Management: For integration and E2E tests, managing test data is critical. Using a dedicated test database that is cleared and re-seeded for each test run (or even each test file) ensures isolation and reproducibility. For complex scenarios, consider using database transactions that can be rolled back after each test.
  3. Performance: While unit tests are fast, integration and E2E tests can be slow due to database interactions and server startup/shutdown. Optimize by:
    • Minimizing database operations or using in-memory databases where appropriate.
    • Running tests in parallel (Jest supports this).
    • Strategically choosing what to test at each level of the pyramid.
  4. Security: Never use production credentials or sensitive data in your test environment. Always use separate .env.test files and dummy data. Ensure your test environment is isolated and not publicly accessible.
  5. Test Coverage: Aim for high test coverage, but don’t blindly chase 100%. Focus on testing critical business logic, error paths, and edge cases. Tools like Jest’s coverage report help identify untested areas.

Code Review Checkpoint

At this point, you have implemented a robust testing strategy for your Node.js backend.

Files Created/Modified:

  • package.json: Added jest, supertest, @jest/globals, dotenv dev dependencies and new test scripts.
  • jest.config.js: Configured Jest for unit, integration, and E2E test projects.
  • .env.test: Created a dedicated environment file for tests.
  • src/utils/auth.js: (Example) Updated with logger and error handling.
  • src/server.js: Modified to export the app instance and conditionally start the server.
  • tests/unit/utils/auth.test.js: Contains unit tests for generateAuthToken and verifyAuthToken.
  • tests/integration/setup.js: Sets up the integration test environment (DB connection, server start, seeding).
  • tests/integration/database-helper.js: Utility functions for clearing and seeding the test database.
  • tests/integration/routes/auth.test.js: Integration tests for user registration and login API endpoints.
  • tests/e2e/setup.js: Sets up the E2E test environment (similar to integration setup).
  • tests/e2e/user-product-flow.test.js: E2E test simulating a full user workflow.

This setup provides a solid foundation for ensuring the quality of your application as it grows.

Common Issues & Solutions

  1. “Jest did not exit one second after the test run has completed.”
    • Issue: This typically means there are open handles (e.g., database connections, HTTP servers, timers) that Jest cannot close.
    • Solution: Ensure all connections (database, Redis, etc.) are explicitly closed in afterAll hooks. For HTTP servers, use server.close() as demonstrated in setup.js. If using jest.useFakeTimers(), ensure jest.useRealTimers() is called in afterAll.
  2. Tests Failing Due to Shared State (Test Isolation)
    • Issue: One test affects the outcome of another, leading to flaky tests. This is common in integration/E2E tests.
    • Solution: Implement robust beforeEach/afterEach or beforeAll/afterAll hooks to ensure a clean state for each test or test suite. This often involves clearing and re-seeding the database, or rolling back transactions. Using separate test databases for different test types (or even for parallel test runs) can also help.
  3. Mocking Complex Dependencies
    • Issue: When unit testing, mocking external modules or complex objects can be challenging.
    • Solution:
      • jest.mock('module-name'): For entire modules.
      • jest.spyOn(object, 'method').mockReturnValue(value): For specific methods on an object.
      • Factory Mocks: For modules that export constructors or functions that return objects, use jest.mock('module-name', () => ({ default: jest.fn(() => ({ method: jest.fn() })) })).
      • Keep dependencies small and use dependency injection to make components easier to mock.
  4. Asynchronous Test Handling:
    • Issue: Forgetting to await promises in tests, leading to tests finishing before async operations complete.
    • Solution: Always use async/await with expect statements that involve promises. Jest automatically waits for promises returned by it blocks. For beforeAll/afterAll hooks, ensure they are async if they perform async operations.

Testing & Verification

To verify all tests are working correctly, run the full test suite:

npm test

You should see output indicating that all unit, integration, and E2E tests have passed. The coverage report will also be generated, showing the percentage of your code covered by tests.

Expected Output (example):

PASS  tests/unit/utils/auth.test.js (8.123 s)
PASS  tests/integration/routes/auth.test.js (12.456 s)
PASS  tests/e2e/user-product-flow.test.js (15.789 s)

Test Suites: 3 passed, 3 total
Tests:       15 passed, 15 total
Snapshots:   0 total
Time:        36.368 s
Ran all test suites.

-----------------| Coverage for all files |-----------------
File                          | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
------------------------------|---------|----------|---------|---------|------------------
src/app.js                    |   100   |    100   |   100   |   100   |
src/config/database.js        |   100   |    100   |   100   |   100   |
src/config/index.js           |   100   |    100   |   100   |   100   |
src/controllers/auth.controller.js | 90.12 | 80.50  | 95.00 | 90.12 | 50,72
src/controllers/product.controller.js | 95.00 | 85.00  | 98.00 | 95.00 | 30
src/middlewares/auth.middleware.js | 85.00 | 70.00  | 80.00 | 85.00 | 15,22
src/models/User.js            |   100   |    100   |   100   |   100   |
src/services/auth.service.js  |   92.00 |    80.00 |   90.00 |   92.00 | 45
src/utils/auth.js             |   100   |    100   |   100   |   100   |
src/utils/logger.js           |   100   |    100   |   100   |   100   |
------------------------------|---------|----------|---------|---------|------------------
All files                     |   95.34 |    88.45 |   96.23 |   95.34 |
--------------------------------------------------------------

The output shows that all tests passed, and provides a coverage report, giving you insights into how much of your codebase is covered by your tests.

Summary & Next Steps

In this chapter, we have successfully implemented a comprehensive testing strategy for our Node.js backend application. We set up Jest and Supertest, configured separate test environments for unit, integration, and E2E tests, and wrote practical examples for each type. You now have the tools and knowledge to ensure the reliability and correctness of your application’s features from isolated functions to full user workflows.

This robust testing foundation is crucial for the long-term health and maintainability of our project. With confidence in our code’s behavior, we can now move forward to enhancing its usability and documentation.

In Chapter 11: API Documentation with OpenAPI (Swagger), we will focus on generating interactive and developer-friendly API documentation. Good documentation is essential for consumers of your API, whether they are frontend developers, mobile app developers, or other backend services. We’ll learn how to integrate OpenAPI (Swagger) to automatically document our endpoints, models, and authentication mechanisms, making our API easily discoverable and consumable.