Skip to content

测试

概述

测试对于构建可靠的 Node.js 应用程序至关重要。本章介绍单元测试、集成测试、端到端测试、模拟以及使用 Jest、Mocha 和 Supertest 等流行框架的测试最佳实践。

使用 Jest 进行单元测试

基础 Jest 配置

javascript
// package.json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  },
  "devDependencies": {
    "jest": "^29.0.0",
    "@types/jest": "^29.0.0"
  },
  "jest": {
    "testEnvironment": "node",
    "collectCoverageFrom": [
      "src/**/*.js",
      "!src/**/*.test.js"
    ]
  }
}

基础单元测试

javascript
// src/calculator.js
class Calculator {
  add(a, b) {
    if (typeof a !== 'number' || typeof b !== 'number') {
      throw new Error('Both arguments must be numbers');
    }
    return a + b;
  }

  subtract(a, b) {
    if (typeof a !== 'number' || typeof b !== 'number') {
      throw new Error('Both arguments must be numbers');
    }
    return a - b;
  }

  multiply(a, b) {
    if (typeof a !== 'number' || typeof b !== 'number') {
      throw new Error('Both arguments must be numbers');
    }
    return a * b;
  }

  divide(a, b) {
    if (typeof a !== 'number' || typeof b !== 'number') {
      throw new Error('Both arguments must be numbers');
    }
    if (b === 0) {
      throw new Error('Division by zero is not allowed');
    }
    return a / b;
  }
}

module.exports = Calculator;
javascript
// src/calculator.test.js
const Calculator = require('./calculator');

describe('Calculator', () => {
  let calculator;

  beforeEach(() => {
    calculator = new Calculator();
  });

  describe('add', () => {
    test('should add two positive numbers', () => {
      expect(calculator.add(2, 3)).toBe(5);
    });

    test('should add positive and negative numbers', () => {
      expect(calculator.add(5, -3)).toBe(2);
    });

    test('should add two negative numbers', () => {
      expect(calculator.add(-2, -3)).toBe(-5);
    });

    test('should throw error for non-number inputs', () => {
      expect(() => calculator.add('2', 3)).toThrow('Both arguments must be numbers');
      expect(() => calculator.add(2, '3')).toThrow('Both arguments must be numbers');
    });
  });

  describe('subtract', () => {
    test('should subtract two numbers', () => {
      expect(calculator.subtract(5, 3)).toBe(2);
    });

    test('should handle negative results', () => {
      expect(calculator.subtract(3, 5)).toBe(-2);
    });
  });

  describe('multiply', () => {
    test('should multiply two numbers', () => {
      expect(calculator.multiply(4, 3)).toBe(12);
    });

    test('should handle zero multiplication', () => {
      expect(calculator.multiply(5, 0)).toBe(0);
    });
  });

  describe('divide', () => {
    test('should divide two numbers', () => {
      expect(calculator.divide(10, 2)).toBe(5);
    });

    test('should throw error for division by zero', () => {
      expect(() => calculator.divide(10, 0)).toThrow('Division by zero is not allowed');
    });

    test('should handle decimal results', () => {
      expect(calculator.divide(10, 3)).toBeCloseTo(3.333, 3);
    });
  });
});

测试异步函数

javascript
// src/user-service.js
class UserService {
  constructor(database) {
    this.database = database;
  }

  async createUser(userData) {
    if (!userData.email) {
      throw new Error('Email is required');
    }

    const existingUser = await this.database.findByEmail(userData.email);
    if (existingUser) {
      throw new Error('User already exists');
    }

    const user = {
      id: Date.now(),
      ...userData,
      createdAt: new Date()
    };

    await this.database.save(user);
    return user;
  }

  async getUserById(id) {
    const user = await this.database.findById(id);
    if (!user) {
      throw new Error('User not found');
    }
    return user;
  }

  async updateUser(id, updateData) {
    const user = await this.database.findById(id);
    if (!user) {
      throw new Error('User not found');
    }

    const updatedUser = { ...user, ...updateData, updatedAt: new Date() };
    await this.database.update(id, updatedUser);
    return updatedUser;
  }

  async deleteUser(id) {
    const user = await this.database.findById(id);
    if (!user) {
      throw new Error('User not found');
    }

    await this.database.delete(id);
    return true;
  }
}

module.exports = UserService;
javascript
// src/user-service.test.js
const UserService = require('./user-service');

describe('UserService', () => {
  let userService;
  let mockDatabase;

  beforeEach(() => {
    mockDatabase = {
      findByEmail: jest.fn(),
      findById: jest.fn(),
      save: jest.fn(),
      update: jest.fn(),
      delete: jest.fn()
    };
    userService = new UserService(mockDatabase);
  });

  describe('createUser', () => {
    test('should create a new user successfully', async () => {
      const userData = { name: 'John Doe', email: 'john@example.com' };
      
      mockDatabase.findByEmail.mockResolvedValue(null);
      mockDatabase.save.mockResolvedValue();

      const result = await userService.createUser(userData);

      expect(result).toMatchObject({
        name: 'John Doe',
        email: 'john@example.com'
      });
      expect(result.id).toBeDefined();
      expect(result.createdAt).toBeInstanceOf(Date);
      expect(mockDatabase.findByEmail).toHaveBeenCalledWith('john@example.com');
      expect(mockDatabase.save).toHaveBeenCalledWith(result);
    });

    test('should throw error if email is missing', async () => {
      const userData = { name: 'John Doe' };

      await expect(userService.createUser(userData)).rejects.toThrow('Email is required');
      expect(mockDatabase.findByEmail).not.toHaveBeenCalled();
    });

    test('should throw error if user already exists', async () => {
      const userData = { name: 'John Doe', email: 'john@example.com' };
      const existingUser = { id: 1, email: 'john@example.com' };

      mockDatabase.findByEmail.mockResolvedValue(existingUser);

      await expect(userService.createUser(userData)).rejects.toThrow('User already exists');
      expect(mockDatabase.save).not.toHaveBeenCalled();
    });
  });

  describe('getUserById', () => {
    test('should return user when found', async () => {
      const user = { id: 1, name: 'John Doe', email: 'john@example.com' };
      mockDatabase.findById.mockResolvedValue(user);

      const result = await userService.getUserById(1);

      expect(result).toEqual(user);
      expect(mockDatabase.findById).toHaveBeenCalledWith(1);
    });

    test('should throw error when user not found', async () => {
      mockDatabase.findById.mockResolvedValue(null);

      await expect(userService.getUserById(999)).rejects.toThrow('User not found');
    });
  });

  describe('updateUser', () => {
    test('should update user successfully', async () => {
      const existingUser = { id: 1, name: 'John Doe', email: 'john@example.com' };
      const updateData = { name: 'John Smith' };

      mockDatabase.findById.mockResolvedValue(existingUser);
      mockDatabase.update.mockResolvedValue();

      const result = await userService.updateUser(1, updateData);

      expect(result.name).toBe('John Smith');
      expect(result.email).toBe('john@example.com');
      expect(result.updatedAt).toBeInstanceOf(Date);
      expect(mockDatabase.update).toHaveBeenCalledWith(1, result);
    });
  });
});

集成测试

使用 Supertest 进行 API 集成测试

javascript
// src/app.js
const express = require('express');
const UserService = require('./user-service');

function createApp(database) {
  const app = express();
  const userService = new UserService(database);

  app.use(express.json());

  app.post('/users', async (req, res) => {
    try {
      const user = await userService.createUser(req.body);
      res.status(201).json(user);
    } catch (error) {
      res.status(400).json({ error: error.message });
    }
  });

  app.get('/users/:id', async (req, res) => {
    try {
      const user = await userService.getUserById(parseInt(req.params.id));
      res.json(user);
    } catch (error) {
      res.status(404).json({ error: error.message });
    }
  });

  app.put('/users/:id', async (req, res) => {
    try {
      const user = await userService.updateUser(parseInt(req.params.id), req.body);
      res.json(user);
    } catch (error) {
      res.status(404).json({ error: error.message });
    }
  });

  app.delete('/users/:id', async (req, res) => {
    try {
      await userService.deleteUser(parseInt(req.params.id));
      res.status(204).send();
    } catch (error) {
      res.status(404).json({ error: error.message });
    }
  });

  return app;
}

module.exports = createApp;
javascript
// src/app.test.js
const request = require('supertest');
const createApp = require('./app');

describe('User API', () => {
  let app;
  let mockDatabase;

  beforeEach(() => {
    mockDatabase = {
      findByEmail: jest.fn(),
      findById: jest.fn(),
      save: jest.fn(),
      update: jest.fn(),
      delete: jest.fn()
    };
    app = createApp(mockDatabase);
  });

  describe('POST /users', () => {
    test('should create user successfully', async () => {
      const userData = { name: 'John Doe', email: 'john@example.com' };
      
      mockDatabase.findByEmail.mockResolvedValue(null);
      mockDatabase.save.mockResolvedValue();

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

      expect(response.body).toMatchObject(userData);
      expect(response.body.id).toBeDefined();
    });

    test('should return 400 for invalid data', async () => {
      const userData = { name: 'John Doe' }; // Missing email

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

      expect(response.body.error).toBe('Email is required');
    });

    test('should return 400 for duplicate email', async () => {
      const userData = { name: 'John Doe', email: 'john@example.com' };
      
      mockDatabase.findByEmail.mockResolvedValue({ id: 1, email: 'john@example.com' });

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

      expect(response.body.error).toBe('User already exists');
    });
  });

  describe('GET /users/:id', () => {
    test('should return user when found', async () => {
      const user = { id: 1, name: 'John Doe', email: 'john@example.com' };
      mockDatabase.findById.mockResolvedValue(user);

      const response = await request(app)
        .get('/users/1')
        .expect(200);

      expect(response.body).toEqual(user);
    });

    test('should return 404 when user not found', async () => {
      mockDatabase.findById.mockResolvedValue(null);

      const response = await request(app)
        .get('/users/999')
        .expect(404);

      expect(response.body.error).toBe('User not found');
    });
  });
});

模拟和测试替身

高级模拟技术

javascript
// src/email-service.js
const nodemailer = require('nodemailer');

class EmailService {
  constructor(config) {
    this.transporter = nodemailer.createTransporter(config);
  }

  async sendEmail(to, subject, body) {
    const mailOptions = {
      from: 'noreply@example.com',
      to,
      subject,
      html: body
    };

    const result = await this.transporter.sendMail(mailOptions);
    return result.messageId;
  }

  async sendWelcomeEmail(user) {
    const subject = 'Welcome to our platform!';
    const body = `<h1>Welcome ${user.name}!</h1><p>Thank you for joining us.</p>`;
    
    return this.sendEmail(user.email, subject, body);
  }
}

module.exports = EmailService;
javascript
// src/email-service.test.js
const EmailService = require('./email-service');
const nodemailer = require('nodemailer');

// Mock nodemailer
jest.mock('nodemailer');

describe('EmailService', () => {
  let emailService;
  let mockTransporter;

  beforeEach(() => {
    mockTransporter = {
      sendMail: jest.fn()
    };
    
    nodemailer.createTransporter.mockReturnValue(mockTransporter);
    
    emailService = new EmailService({
      host: 'smtp.example.com',
      port: 587,
      auth: { user: 'test', pass: 'test' }
    });
  });

  afterEach(() => {
    jest.clearAllMocks();
  });

  describe('sendEmail', () => {
    test('should send email successfully', async () => {
      const messageId = 'test-message-id';
      mockTransporter.sendMail.mockResolvedValue({ messageId });

      const result = await emailService.sendEmail(
        'test@example.com',
        'Test Subject',
        '<p>Test Body</p>'
      );

      expect(result).toBe(messageId);
      expect(mockTransporter.sendMail).toHaveBeenCalledWith({
        from: 'noreply@example.com',
        to: 'test@example.com',
        subject: 'Test Subject',
        html: '<p>Test Body</p>'
      });
    });

    test('should handle email sending errors', async () => {
      const error = new Error('SMTP connection failed');
      mockTransporter.sendMail.mockRejectedValue(error);

      await expect(emailService.sendEmail('test@example.com', 'Test', 'Body'))
        .rejects.toThrow('SMTP connection failed');
    });
  });

  describe('sendWelcomeEmail', () => {
    test('should send welcome email with correct content', async () => {
      const user = { name: 'John Doe', email: 'john@example.com' };
      const messageId = 'welcome-message-id';
      
      mockTransporter.sendMail.mockResolvedValue({ messageId });

      const result = await emailService.sendWelcomeEmail(user);

      expect(result).toBe(messageId);
      expect(mockTransporter.sendMail).toHaveBeenCalledWith({
        from: 'noreply@example.com',
        to: 'john@example.com',
        subject: 'Welcome to our platform!',
        html: '<h1>Welcome John Doe!</h1><p>Thank you for joining us.</p>'
      });
    });
  });
});

测试工具和辅助函数

测试数据库设置

javascript
// tests/test-helpers.js
const { MongoMemoryServer } = require('mongodb-memory-server');
const mongoose = require('mongoose');

class TestDatabase {
  constructor() {
    this.mongoServer = null;
  }

  async connect() {
    this.mongoServer = await MongoMemoryServer.create();
    const mongoUri = this.mongoServer.getUri();
    
    await mongoose.connect(mongoUri, {
      useNewUrlParser: true,
      useUnifiedTopology: true
    });
  }

  async disconnect() {
    await mongoose.disconnect();
    if (this.mongoServer) {
      await this.mongoServer.stop();
    }
  }

  async clearDatabase() {
    const collections = mongoose.connection.collections;
    
    for (const key in collections) {
      await collections[key].deleteMany({});
    }
  }
}

// Test data factories
class UserFactory {
  static create(overrides = {}) {
    return {
      name: 'John Doe',
      email: 'john@example.com',
      age: 30,
      ...overrides
    };
  }

  static createMany(count, overrides = {}) {
    return Array.from({ length: count }, (_, i) => 
      this.create({ 
        ...overrides, 
        email: `user${i}@example.com`,
        name: `User ${i}`
      })
    );
  }
}

// Custom matchers
expect.extend({
  toBeValidEmail(received) {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    const pass = emailRegex.test(received);
    
    if (pass) {
      return {
        message: () => `expected ${received} not to be a valid email`,
        pass: true
      };
    } else {
      return {
        message: () => `expected ${received} to be a valid email`,
        pass: false
      };
    }
  },

  toBeWithinRange(received, min, max) {
    const pass = received >= min && received <= max;
    
    if (pass) {
      return {
        message: () => `expected ${received} not to be within range ${min} - ${max}`,
        pass: true
      };
    } else {
      return {
        message: () => `expected ${received} to be within range ${min} - ${max}`,
        pass: false
      };
    }
  }
});

module.exports = {
  TestDatabase,
  UserFactory
};

测试配置

javascript
// jest.config.js
module.exports = {
  testEnvironment: 'node',
  setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
  testMatch: [
    '**/__tests__/**/*.js',
    '**/?(*.)+(spec|test).js'
  ],
  collectCoverageFrom: [
    'src/**/*.js',
    '!src/**/*.test.js',
    '!src/index.js'
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  },
  testTimeout: 10000,
  verbose: true
};
javascript
// tests/setup.js
const { TestDatabase } = require('./test-helpers');

let testDb;

beforeAll(async () => {
  testDb = new TestDatabase();
  await testDb.connect();
});

afterAll(async () => {
  if (testDb) {
    await testDb.disconnect();
  }
});

beforeEach(async () => {
  if (testDb) {
    await testDb.clearDatabase();
  }
});

// Global test timeout
jest.setTimeout(10000);

// Suppress console.log in tests unless explicitly needed
global.console = {
  ...console,
  log: jest.fn(),
  debug: jest.fn(),
  info: jest.fn(),
  warn: jest.fn(),
  error: jest.fn()
};

下一步

在下一章中,我们将探索 Node.js 应用程序的部署策略和生产注意事项。

关键要点

  • 单元测试在隔离状态下验证单个组件
  • 集成测试验证组件交互
  • 模拟将测试单元与依赖项隔离
  • 测试工厂简化测试数据创建
  • 覆盖率指标有助于识别未测试的代码
  • 适当的测试设置确保可靠和快速的测试
  • 自定义匹配器提高测试可读性
  • 异步测试需要适当的 Promise 处理

本站内容仅供学习和研究使用。