Jest Node.js 测试 #

Node.js 测试概述 #

Node.js 后端测试主要关注 API 端点、数据库操作、业务逻辑和中间件等模块的正确性。

text
┌─────────────────────────────────────────────────────────────┐
│                    Node.js 测试层次                          │
├─────────────────────────────────────────────────────────────┤
│  1. 单元测试 - 函数和模块                                    │
│  2. 集成测试 - API 端点                                      │
│  3. 数据库测试 - 数据库操作                                  │
│  4. 中间件测试 - Express/Koa 中间件                          │
│  5. E2E 测试 - 完整流程                                      │
└─────────────────────────────────────────────────────────────┘

环境配置 #

安装依赖 #

bash
npm install --save-dev jest supertest @types/jest @types/supertest

Jest 配置 #

javascript
// jest.config.js
module.exports = {
  testEnvironment: 'node',
  testMatch: ['**/*.test.js'],
  collectCoverageFrom: [
    'src/**/*.js',
    '!src/**/*.test.js',
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
    },
  },
};

单元测试 #

工具函数测试 #

javascript
// utils/math.js
function add(a, b) {
  return a + b;
}

function subtract(a, b) {
  return a - b;
}

function multiply(a, b) {
  return a * b;
}

function divide(a, b) {
  if (b === 0) {
    throw new Error('Division by zero');
  }
  return a / b;
}

module.exports = { add, subtract, multiply, divide };

// utils/math.test.js
const { add, subtract, multiply, divide } = require('./math');

describe('Math utilities', () => {
  test('adds two numbers', () => {
    expect(add(1, 2)).toBe(3);
  });

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

  test('multiplies two numbers', () => {
    expect(multiply(2, 3)).toBe(6);
  });

  test('divides two numbers', () => {
    expect(divide(6, 2)).toBe(3);
  });

  test('throws error when dividing by zero', () => {
    expect(() => divide(1, 0)).toThrow('Division by zero');
  });
});

字符串工具测试 #

javascript
// utils/string.js
function capitalize(str) {
  if (typeof str !== 'string') {
    throw new TypeError('Expected a string');
  }
  return str.charAt(0).toUpperCase() + str.slice(1);
}

function truncate(str, length = 50) {
  if (str.length <= length) return str;
  return str.slice(0, length) + '...';
}

function isValidEmail(email) {
  const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return regex.test(email);
}

module.exports = { capitalize, truncate, isValidEmail };

// utils/string.test.js
const { capitalize, truncate, isValidEmail } = require('./string');

describe('String utilities', () => {
  describe('capitalize', () => {
    test('capitalizes first letter', () => {
      expect(capitalize('hello')).toBe('Hello');
    });

    test('handles empty string', () => {
      expect(capitalize('')).toBe('');
    });

    test('throws error for non-string', () => {
      expect(() => capitalize(123)).toThrow(TypeError);
    });
  });

  describe('truncate', () => {
    test('truncates long string', () => {
      const long = 'a'.repeat(100);
      expect(truncate(long, 50)).toBe('a'.repeat(50) + '...');
    });

    test('does not truncate short string', () => {
      expect(truncate('hello', 10)).toBe('hello');
    });

    test('uses default length', () => {
      const long = 'a'.repeat(60);
      expect(truncate(long)).toBe('a'.repeat(50) + '...');
    });
  });

  describe('isValidEmail', () => {
    test('returns true for valid email', () => {
      expect(isValidEmail('test@example.com')).toBe(true);
    });

    test('returns false for invalid email', () => {
      expect(isValidEmail('invalid')).toBe(false);
      expect(isValidEmail('test@')).toBe(false);
      expect(isValidEmail('@example.com')).toBe(false);
    });
  });
});

Express API 测试 #

基础 API 测试 #

javascript
// app.js
const express = require('express');
const app = express();

app.use(express.json());

let users = [];

app.get('/api/users', (req, res) => {
  res.json(users);
});

app.get('/api/users/:id', (req, res) => {
  const user = users.find(u => u.id === parseInt(req.params.id));
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }
  res.json(user);
});

app.post('/api/users', (req, res) => {
  const { name, email } = req.body;
  if (!name || !email) {
    return res.status(400).json({ error: 'Name and email are required' });
  }
  const user = { id: users.length + 1, name, email };
  users.push(user);
  res.status(201).json(user);
});

app.put('/api/users/:id', (req, res) => {
  const user = users.find(u => u.id === parseInt(req.params.id));
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }
  Object.assign(user, req.body);
  res.json(user);
});

app.delete('/api/users/:id', (req, res) => {
  const index = users.findIndex(u => u.id === parseInt(req.params.id));
  if (index === -1) {
    return res.status(404).json({ error: 'User not found' });
  }
  users.splice(index, 1);
  res.status(204).send();
});

module.exports = app;

// app.test.js
const request = require('supertest');
const app = require('./app');

describe('User API', () => {
  beforeEach(() => {
    // 重置数据
  });

  describe('GET /api/users', () => {
    test('returns empty array initially', async () => {
      const response = await request(app).get('/api/users');
      expect(response.status).toBe(200);
      expect(response.body).toEqual([]);
    });
  });

  describe('POST /api/users', () => {
    test('creates a new user', async () => {
      const response = await request(app)
        .post('/api/users')
        .send({ name: 'John', email: 'john@example.com' });

      expect(response.status).toBe(201);
      expect(response.body).toMatchObject({
        name: 'John',
        email: 'john@example.com',
      });
    });

    test('returns 400 for missing fields', async () => {
      const response = await request(app)
        .post('/api/users')
        .send({ name: 'John' });

      expect(response.status).toBe(400);
      expect(response.body.error).toBe('Name and email are required');
    });
  });

  describe('GET /api/users/:id', () => {
    test('returns user by id', async () => {
      // 先创建用户
      const createResponse = await request(app)
        .post('/api/users')
        .send({ name: 'John', email: 'john@example.com' });

      const response = await request(app).get(`/api/users/${createResponse.body.id}`);
      expect(response.status).toBe(200);
      expect(response.body.name).toBe('John');
    });

    test('returns 404 for non-existent user', async () => {
      const response = await request(app).get('/api/users/999');
      expect(response.status).toBe(404);
    });
  });

  describe('PUT /api/users/:id', () => {
    test('updates user', async () => {
      const createResponse = await request(app)
        .post('/api/users')
        .send({ name: 'John', email: 'john@example.com' });

      const response = await request(app)
        .put(`/api/users/${createResponse.body.id}`)
        .send({ name: 'Jane' });

      expect(response.status).toBe(200);
      expect(response.body.name).toBe('Jane');
    });
  });

  describe('DELETE /api/users/:id', () => {
    test('deletes user', async () => {
      const createResponse = await request(app)
        .post('/api/users')
        .send({ name: 'John', email: 'john@example.com' });

      const response = await request(app).delete(`/api/users/${createResponse.body.id}`);
      expect(response.status).toBe(204);
    });
  });
});

数据库测试 #

MongoDB 测试 #

javascript
// models/User.js
const mongoose = require('mongoose');

const userSchema = new mongoose.Schema({
  name: { type: String, required: true },
  email: { type: String, required: true, unique: true },
  createdAt: { type: Date, default: Date.now },
});

module.exports = mongoose.model('User', userSchema);

// models/User.test.js
const mongoose = require('mongoose');
const User = require('./User');

describe('User model', () => {
  beforeAll(async () => {
    await mongoose.connect('mongodb://localhost:27017/test');
  });

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

  beforeEach(async () => {
    await User.deleteMany({});
  });

  test('creates user successfully', async () => {
    const user = await User.create({
      name: 'John',
      email: 'john@example.com',
    });
    expect(user.name).toBe('John');
    expect(user.email).toBe('john@example.com');
  });

  test('requires name and email', async () => {
    const user = new User({});
    await expect(user.save()).rejects.toThrow();
  });

  test('enforces unique email', async () => {
    await User.create({ name: 'John', email: 'john@example.com' });
    await expect(
      User.create({ name: 'Jane', email: 'john@example.com' })
    ).rejects.toThrow();
  });
});

使用 mongodb-memory-server #

javascript
// jest.setup.js
const { MongoMemoryServer } = require('mongodb-memory-server');
const mongoose = require('mongoose');

let mongoServer;

beforeAll(async () => {
  mongoServer = await MongoMemoryServer.create();
  await mongoose.connect(mongoServer.getUri());
});

afterAll(async () => {
  await mongoose.disconnect();
  await mongoServer.stop();
});

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

中间件测试 #

认证中间件 #

javascript
// middleware/auth.js
function authMiddleware(req, res, next) {
  const token = req.headers.authorization;

  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
  }

  if (!token.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Invalid token format' });
  }

  try {
    const decoded = verifyToken(token.slice(7));
    req.user = decoded;
    next();
  } catch (error) {
    return res.status(401).json({ error: 'Invalid token' });
  }
}

module.exports = authMiddleware;

// middleware/auth.test.js
const request = require('supertest');
const express = require('express');
const authMiddleware = require('./auth');

const app = express();
app.use(express.json());
app.get('/protected', authMiddleware, (req, res) => {
  res.json({ user: req.user });
});

describe('Auth middleware', () => {
  test('returns 401 without token', async () => {
    const response = await request(app).get('/protected');
    expect(response.status).toBe(401);
    expect(response.body.error).toBe('No token provided');
  });

  test('returns 401 for invalid token format', async () => {
    const response = await request(app)
      .get('/protected')
      .set('Authorization', 'InvalidToken');
    expect(response.status).toBe(401);
    expect(response.body.error).toBe('Invalid token format');
  });
});

错误处理中间件 #

javascript
// middleware/errorHandler.js
function errorHandler(err, req, res, next) {
  console.error(err.stack);

  if (err.name === 'ValidationError') {
    return res.status(400).json({ error: err.message });
  }

  if (err.name === 'UnauthorizedError') {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  res.status(500).json({ error: 'Internal server error' });
}

module.exports = errorHandler;

// middleware/errorHandler.test.js
const request = require('supertest');
const express = require('express');
const errorHandler = require('./errorHandler');

const app = express();
app.get('/validation-error', (req, res, next) => {
  const err = new Error('Validation failed');
  err.name = 'ValidationError';
  next(err);
});
app.get('/unauthorized', (req, res, next) => {
  const err = new Error('Unauthorized');
  err.name = 'UnauthorizedError';
  next(err);
});
app.get('/server-error', (req, res, next) => {
  next(new Error('Something went wrong'));
});
app.use(errorHandler);

describe('Error handler middleware', () => {
  test('handles validation error', async () => {
    const response = await request(app).get('/validation-error');
    expect(response.status).toBe(400);
  });

  test('handles unauthorized error', async () => {
    const response = await request(app).get('/unauthorized');
    expect(response.status).toBe(401);
  });

  test('handles generic error', async () => {
    const response = await request(app).get('/server-error');
    expect(response.status).toBe(500);
  });
});

服务层测试 #

javascript
// services/userService.js
class UserService {
  constructor(userRepository) {
    this.userRepository = userRepository;
  }

  async createUser(userData) {
    if (!userData.name || !userData.email) {
      throw new Error('Name and email are required');
    }
    return this.userRepository.create(userData);
  }

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

  async updateUser(id, userData) {
    const user = await this.getUser(id);
    Object.assign(user, userData);
    return this.userRepository.update(user);
  }

  async deleteUser(id) {
    await this.getUser(id);
    return this.userRepository.delete(id);
  }
}

module.exports = UserService;

// services/userService.test.js
const UserService = require('./userService');

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

  beforeEach(() => {
    mockRepository = {
      create: jest.fn(),
      findById: jest.fn(),
      update: jest.fn(),
      delete: jest.fn(),
    };
    userService = new UserService(mockRepository);
  });

  describe('createUser', () => {
    test('creates user with valid data', async () => {
      mockRepository.create.mockResolvedValue({ id: 1, name: 'John' });

      const result = await userService.createUser({ name: 'John', email: 'john@example.com' });

      expect(mockRepository.create).toHaveBeenCalledWith({ name: 'John', email: 'john@example.com' });
      expect(result).toEqual({ id: 1, name: 'John' });
    });

    test('throws error for missing fields', async () => {
      await expect(userService.createUser({ name: 'John' })).rejects.toThrow('Name and email are required');
    });
  });

  describe('getUser', () => {
    test('returns user by id', async () => {
      mockRepository.findById.mockResolvedValue({ id: 1, name: 'John' });

      const result = await userService.getUser(1);

      expect(mockRepository.findById).toHaveBeenCalledWith(1);
      expect(result).toEqual({ id: 1, name: 'John' });
    });

    test('throws error for non-existent user', async () => {
      mockRepository.findById.mockResolvedValue(null);

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

最佳实践 #

1. 测试隔离 #

javascript
beforeEach(() => {
  // 重置状态
  jest.clearAllMocks();
});

afterEach(() => {
  // 清理资源
});

2. 使用测试数据库 #

javascript
beforeAll(async () => {
  await connectTestDatabase();
});

afterAll(async () => {
  await disconnectTestDatabase();
});

3. Mock 外部依赖 #

javascript
jest.mock('axios');
jest.mock('nodemailer');

下一步 #

现在你已经掌握了 Node.js 后端测试,接下来学习 最佳实践 总结测试经验!

最后更新:2026-03-28