跳到主要内容

Sequelize 测试最佳实践 - 单元测试与集成测试

· 阅读需 12 分钟
一介布衣
全栈开发者

发布时间:2024-04-05
作者:一介布衣
标签:Sequelize, 测试, 单元测试, 集成测试, Jest

前言

今天咱们来聊聊 Sequelize 项目的测试。说实话,数据库相关的测试一直是个难点,既要保证测试的独立性,又要确保测试环境的一致性。

我记得刚开始写测试的时候,经常遇到各种问题:测试之间相互影响、数据库状态不一致、测试运行缓慢等等。后来慢慢摸索出一套测试方法,既能保证测试质量,又能提高开发效率。

今天我就把这些测试经验和最佳实践分享给大家。

测试环境搭建

1. 测试依赖安装

# 安装测试框架和相关工具
npm install --save-dev jest supertest
npm install --save-dev @types/jest @types/supertest # TypeScript 项目

# 数据库相关
npm install --save-dev sqlite3 # 测试用的内存数据库
npm install --save-dev sequelize-test-helpers # 测试辅助工具

2. Jest 配置

创建 jest.config.js

module.exports = {
testEnvironment: 'node',
roots: ['<rootDir>/src', '<rootDir>/tests'],
testMatch: [
'**/__tests__/**/*.test.js',
'**/?(*.)+(spec|test).js'
],
collectCoverageFrom: [
'src/**/*.js',
'!src/**/*.test.js',
'!src/migrations/**',
'!src/seeders/**'
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
testTimeout: 30000,

// 数据库测试配置
globalSetup: '<rootDir>/tests/globalSetup.js',
globalTeardown: '<rootDir>/tests/globalTeardown.js'
};

3. 测试数据库配置

// tests/config/database.js
const { Sequelize } = require('sequelize');

// 使用内存 SQLite 数据库进行测试
const sequelize = new Sequelize({
dialect: 'sqlite',
storage: ':memory:',
logging: false, // 关闭日志输出

// 或者使用独立的测试数据库
// dialect: 'mysql',
// host: 'localhost',
// database: 'myapp_test',
// username: 'root',
// password: 'password'
});

module.exports = sequelize;

4. 测试环境初始化

// tests/setup.js
const sequelize = require('./config/database');

// 每个测试文件执行前的设置
beforeAll(async () => {
// 同步数据库结构
await sequelize.sync({ force: true });
});

// 每个测试用例执行前清理数据
beforeEach(async () => {
// 清理所有表数据
await sequelize.truncate({ cascade: true, restartIdentity: true });
});

// 测试结束后关闭连接
afterAll(async () => {
await sequelize.close();
});

模型单元测试

1. 基础模型测试

// tests/models/User.test.js
const { User } = require('../../src/models');
const sequelize = require('../config/database');

describe('User Model', () => {
beforeAll(async () => {
await sequelize.sync({ force: true });
});

afterEach(async () => {
await User.destroy({ where: {}, force: true });
});

describe('创建用户', () => {
it('应该成功创建有效用户', async () => {
const userData = {
username: 'testuser',
email: 'test@example.com',
password: 'password123'
};

const user = await User.create(userData);

expect(user.id).toBeDefined();
expect(user.username).toBe(userData.username);
expect(user.email).toBe(userData.email);
expect(user.password).not.toBe(userData.password); // 密码应该被加密
expect(user.createdAt).toBeInstanceOf(Date);
expect(user.updatedAt).toBeInstanceOf(Date);
});

it('应该验证必填字段', async () => {
const invalidData = {
username: 'testuser'
// 缺少 email 和 password
};

await expect(User.create(invalidData)).rejects.toThrow();
});

it('应该验证邮箱格式', async () => {
const invalidData = {
username: 'testuser',
email: 'invalid-email',
password: 'password123'
};

await expect(User.create(invalidData)).rejects.toThrow(/email/i);
});

it('应该验证用户名唯一性', async () => {
const userData = {
username: 'testuser',
email: 'test1@example.com',
password: 'password123'
};

await User.create(userData);

const duplicateData = {
username: 'testuser', // 重复用户名
email: 'test2@example.com',
password: 'password123'
};

await expect(User.create(duplicateData)).rejects.toThrow();
});
});

describe('实例方法', () => {
let user;

beforeEach(async () => {
user = await User.create({
username: 'testuser',
email: 'test@example.com',
password: 'password123',
firstName: 'John',
lastName: 'Doe'
});
});

it('getFullName 应该返回完整姓名', () => {
expect(user.getFullName()).toBe('John Doe');
});

it('validatePassword 应该验证密码', async () => {
const isValid = await user.validatePassword('password123');
expect(isValid).toBe(true);

const isInvalid = await user.validatePassword('wrongpassword');
expect(isInvalid).toBe(false);
});

it('toSafeObject 应该排除敏感信息', () => {
const safeUser = user.toSafeObject();

expect(safeUser.password).toBeUndefined();
expect(safeUser.username).toBe('testuser');
expect(safeUser.email).toBe('test@example.com');
});
});

describe('静态方法', () => {
beforeEach(async () => {
await User.create({
username: 'testuser',
email: 'test@example.com',
password: 'password123'
});
});

it('findByEmail 应该根据邮箱查找用户', async () => {
const user = await User.findByEmail('test@example.com');

expect(user).toBeTruthy();
expect(user.email).toBe('test@example.com');
});

it('findByEmail 找不到用户时应该返回 null', async () => {
const user = await User.findByEmail('nonexistent@example.com');

expect(user).toBeNull();
});
});

describe('钩子函数', () => {
it('创建用户时应该加密密码', async () => {
const userData = {
username: 'testuser',
email: 'test@example.com',
password: 'password123'
};

const user = await User.create(userData);

expect(user.password).not.toBe('password123');
expect(user.password.length).toBeGreaterThan(20); // bcrypt 哈希长度
});

it('更新密码时应该重新加密', async () => {
const user = await User.create({
username: 'testuser',
email: 'test@example.com',
password: 'password123'
});

const originalPassword = user.password;

await user.update({ password: 'newpassword' });

expect(user.password).not.toBe('newpassword');
expect(user.password).not.toBe(originalPassword);
});
});
});

2. 关联关系测试

// tests/models/associations.test.js
const { User, Post, Comment } = require('../../src/models');

describe('模型关联关系', () => {
let user, post;

beforeEach(async () => {
user = await User.create({
username: 'testuser',
email: 'test@example.com',
password: 'password123'
});

post = await Post.create({
title: '测试文章',
content: '这是测试内容',
userId: user.id
});
});

describe('User-Post 关联', () => {
it('用户应该能获取自己的文章', async () => {
const userWithPosts = await User.findByPk(user.id, {
include: ['posts']
});

expect(userWithPosts.posts).toHaveLength(1);
expect(userWithPosts.posts[0].title).toBe('测试文章');
});

it('文章应该能获取作者信息', async () => {
const postWithAuthor = await Post.findByPk(post.id, {
include: ['author']
});

expect(postWithAuthor.author.username).toBe('testuser');
});

it('应该能通过关联方法创建文章', async () => {
const newPost = await user.createPost({
title: '新文章',
content: '新内容'
});

expect(newPost.userId).toBe(user.id);
expect(newPost.title).toBe('新文章');
});
});

describe('级联删除', () => {
it('删除用户时应该删除相关文章', async () => {
await user.destroy();

const remainingPosts = await Post.findAll({
where: { userId: user.id }
});

expect(remainingPosts).toHaveLength(0);
});
});
});

服务层测试

1. 用户服务测试

// tests/services/UserService.test.js
const UserService = require('../../src/services/UserService');
const { User } = require('../../src/models');

describe('UserService', () => {
describe('createUser', () => {
it('应该成功创建用户', async () => {
const userData = {
username: 'testuser',
email: 'test@example.com',
password: 'password123'
};

const result = await UserService.createUser(userData);

expect(result.success).toBe(true);
expect(result.user.username).toBe(userData.username);
expect(result.user.password).toBeUndefined(); // 不应该返回密码
});

it('用户名重复时应该返回错误', async () => {
const userData = {
username: 'testuser',
email: 'test1@example.com',
password: 'password123'
};

await UserService.createUser(userData);

const duplicateData = {
username: 'testuser',
email: 'test2@example.com',
password: 'password123'
};

const result = await UserService.createUser(duplicateData);

expect(result.success).toBe(false);
expect(result.message).toMatch(/用户名已存在/i);
});
});

describe('authenticateUser', () => {
let user;

beforeEach(async () => {
user = await User.create({
username: 'testuser',
email: 'test@example.com',
password: 'password123'
});
});

it('正确密码应该认证成功', async () => {
const result = await UserService.authenticateUser('test@example.com', 'password123');

expect(result.success).toBe(true);
expect(result.user.id).toBe(user.id);
});

it('错误密码应该认证失败', async () => {
const result = await UserService.authenticateUser('test@example.com', 'wrongpassword');

expect(result.success).toBe(false);
expect(result.message).toMatch(/密码错误/i);
});

it('不存在的用户应该认证失败', async () => {
const result = await UserService.authenticateUser('nonexistent@example.com', 'password123');

expect(result.success).toBe(false);
expect(result.message).toMatch(/用户不存在/i);
});
});
});

2. Mock 外部依赖

// tests/services/EmailService.test.js
const EmailService = require('../../src/services/EmailService');
const UserService = require('../../src/services/UserService');

// Mock 邮件发送服务
jest.mock('../../src/utils/emailSender', () => ({
sendEmail: jest.fn()
}));

const emailSender = require('../../src/utils/emailSender');

describe('EmailService', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('应该发送欢迎邮件', async () => {
emailSender.sendEmail.mockResolvedValue({ success: true });

const result = await EmailService.sendWelcomeEmail('test@example.com', 'TestUser');

expect(emailSender.sendEmail).toHaveBeenCalledWith({
to: 'test@example.com',
subject: '欢迎注册',
template: 'welcome',
data: { username: 'TestUser' }
});

expect(result.success).toBe(true);
});

it('邮件发送失败时应该处理错误', async () => {
emailSender.sendEmail.mockRejectedValue(new Error('发送失败'));

const result = await EmailService.sendWelcomeEmail('test@example.com', 'TestUser');

expect(result.success).toBe(false);
expect(result.error).toMatch(/发送失败/);
});
});

集成测试

1. API 集成测试

// tests/integration/auth.test.js
const request = require('supertest');
const app = require('../../src/app');
const { User } = require('../../src/models');

describe('认证 API', () => {
describe('POST /api/auth/register', () => {
it('应该成功注册新用户', async () => {
const userData = {
username: 'testuser',
email: 'test@example.com',
password: 'password123'
};

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

expect(response.body.success).toBe(true);
expect(response.body.data.username).toBe(userData.username);
expect(response.body.data.password).toBeUndefined();

// 验证数据库中确实创建了用户
const user = await User.findOne({ where: { email: userData.email } });
expect(user).toBeTruthy();
});

it('无效数据应该返回 400 错误', async () => {
const invalidData = {
username: 'ab', // 太短
email: 'invalid-email',
password: '123' // 太短
};

const response = await request(app)
.post('/api/auth/register')
.send(invalidData)
.expect(400);

expect(response.body.success).toBe(false);
expect(response.body.errors).toBeDefined();
});
});

describe('POST /api/auth/login', () => {
let user;

beforeEach(async () => {
user = await User.create({
username: 'testuser',
email: 'test@example.com',
password: 'password123'
});
});

it('正确凭据应该登录成功', async () => {
const loginData = {
email: 'test@example.com',
password: 'password123'
};

const response = await request(app)
.post('/api/auth/login')
.send(loginData)
.expect(200);

expect(response.body.success).toBe(true);
expect(response.body.data.token).toBeDefined();
expect(response.body.data.user.id).toBe(user.id);
});

it('错误密码应该返回 401 错误', async () => {
const loginData = {
email: 'test@example.com',
password: 'wrongpassword'
};

const response = await request(app)
.post('/api/auth/login')
.send(loginData)
.expect(401);

expect(response.body.success).toBe(false);
expect(response.body.message).toMatch(/密码错误/i);
});
});
});

2. 事务测试

// tests/integration/transaction.test.js
const { User, Order, OrderItem, Product } = require('../../src/models');
const OrderService = require('../../src/services/OrderService');

describe('订单事务测试', () => {
let user, product;

beforeEach(async () => {
user = await User.create({
username: 'testuser',
email: 'test@example.com',
password: 'password123'
});

product = await Product.create({
name: '测试商品',
price: 100.00,
stock: 10
});
});

it('成功创建订单应该扣减库存', async () => {
const orderData = {
userId: user.id,
items: [{
productId: product.id,
quantity: 2,
price: product.price
}]
};

const result = await OrderService.createOrder(orderData);

expect(result.success).toBe(true);

// 验证订单创建
const order = await Order.findByPk(result.order.id, {
include: ['items']
});
expect(order.items).toHaveLength(1);

// 验证库存扣减
await product.reload();
expect(product.stock).toBe(8);
});

it('库存不足时应该回滚事务', async () => {
const orderData = {
userId: user.id,
items: [{
productId: product.id,
quantity: 15, // 超过库存
price: product.price
}]
};

const result = await OrderService.createOrder(orderData);

expect(result.success).toBe(false);
expect(result.message).toMatch(/库存不足/i);

// 验证没有创建订单
const orderCount = await Order.count({ where: { userId: user.id } });
expect(orderCount).toBe(0);

// 验证库存没有变化
await product.reload();
expect(product.stock).toBe(10);
});
});

测试数据工厂

1. 创建测试数据工厂

// tests/factories/index.js
const { User, Post, Product } = require('../../src/models');
const { faker } = require('@faker-js/faker');

class Factory {
static async createUser(overrides = {}) {
const defaultData = {
username: faker.internet.userName(),
email: faker.internet.email(),
password: 'password123',
firstName: faker.person.firstName(),
lastName: faker.person.lastName()
};

return await User.create({ ...defaultData, ...overrides });
}

static async createPost(overrides = {}) {
let userId = overrides.userId;

if (!userId) {
const user = await this.createUser();
userId = user.id;
}

const defaultData = {
title: faker.lorem.sentence(),
content: faker.lorem.paragraphs(3),
userId
};

return await Post.create({ ...defaultData, ...overrides });
}

static async createProduct(overrides = {}) {
const defaultData = {
name: faker.commerce.productName(),
description: faker.commerce.productDescription(),
price: parseFloat(faker.commerce.price()),
stock: faker.number.int({ min: 0, max: 100 })
};

return await Product.create({ ...defaultData, ...overrides });
}

// 批量创建
static async createUsers(count, overrides = {}) {
const users = [];
for (let i = 0; i < count; i++) {
users.push(await this.createUser(overrides));
}
return users;
}
}

module.exports = Factory;

2. 使用工厂简化测试

// tests/services/PostService.test.js
const PostService = require('../../src/services/PostService');
const Factory = require('../factories');

describe('PostService', () => {
describe('getUserPosts', () => {
it('应该返回用户的文章列表', async () => {
const user = await Factory.createUser();

// 为用户创建多篇文章
await Factory.createPost({ userId: user.id, title: '文章1' });
await Factory.createPost({ userId: user.id, title: '文章2' });

// 创建其他用户的文章(不应该返回)
const otherUser = await Factory.createUser();
await Factory.createPost({ userId: otherUser.id, title: '其他文章' });

const result = await PostService.getUserPosts(user.id);

expect(result.success).toBe(true);
expect(result.posts).toHaveLength(2);
expect(result.posts.every(post => post.userId === user.id)).toBe(true);
});
});
});

性能测试

1. 查询性能测试

// tests/performance/query.test.js
const { User, Post } = require('../../src/models');
const Factory = require('../factories');

describe('查询性能测试', () => {
beforeAll(async () => {
// 创建大量测试数据
const users = await Factory.createUsers(100);

for (const user of users) {
await Factory.createPost({ userId: user.id });
await Factory.createPost({ userId: user.id });
}
});

it('用户列表查询应该在合理时间内完成', async () => {
const startTime = Date.now();

const users = await User.findAll({
include: ['posts'],
limit: 50
});

const endTime = Date.now();
const duration = endTime - startTime;

expect(users).toHaveLength(50);
expect(duration).toBeLessThan(1000); // 应该在1秒内完成
});

it('分页查询性能测试', async () => {
const startTime = Date.now();

const result = await User.findAndCountAll({
limit: 20,
offset: 40,
order: [['createdAt', 'DESC']]
});

const endTime = Date.now();
const duration = endTime - startTime;

expect(result.rows).toHaveLength(20);
expect(duration).toBeLessThan(500); // 应该在500ms内完成
});
});

测试覆盖率和质量

1. 测试覆盖率配置

// package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:ci": "jest --coverage --watchAll=false"
},
"jest": {
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
}
}
}

2. 测试质量检查

// tests/utils/testHelpers.js
class TestHelpers {
// 验证响应格式
static validateApiResponse(response, expectedFields = []) {
expect(response.body).toHaveProperty('success');

if (response.body.success) {
expect(response.body).toHaveProperty('data');

expectedFields.forEach(field => {
expect(response.body.data).toHaveProperty(field);
});
} else {
expect(response.body).toHaveProperty('message');
}
}

// 验证分页响应
static validatePaginationResponse(response) {
this.validateApiResponse(response, ['items', 'pagination']);

const { pagination } = response.body.data;
expect(pagination).toHaveProperty('total');
expect(pagination).toHaveProperty('page');
expect(pagination).toHaveProperty('limit');
expect(pagination).toHaveProperty('pages');
}

// 等待异步操作完成
static async waitFor(condition, timeout = 5000) {
const startTime = Date.now();

while (Date.now() - startTime < timeout) {
if (await condition()) {
return true;
}
await new Promise(resolve => setTimeout(resolve, 100));
}

throw new Error('等待条件超时');
}
}

module.exports = TestHelpers;

CI/CD 集成

1. GitHub Actions 配置

# .github/workflows/test.yml
name: Tests

on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]

jobs:
test:
runs-on: ubuntu-latest

services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: test_db
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3

steps:
- uses: actions/checkout@v3

- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Run tests
run: npm run test:ci
env:
NODE_ENV: test
DB_HOST: localhost
DB_PORT: 3306
DB_NAME: test_db
DB_USER: root
DB_PASS: password

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3

总结

今天我们深入学习了 Sequelize 项目的测试最佳实践:

  • ✅ 测试环境的搭建和配置
  • ✅ 模型单元测试的编写方法
  • ✅ 服务层测试和 Mock 技巧
  • ✅ API 集成测试的实现
  • ✅ 测试数据工厂的使用
  • ✅ 性能测试和质量保证
  • ✅ CI/CD 集成配置

掌握了这些知识,你就能够:

  • 构建完整的测试体系
  • 保证代码质量和稳定性
  • 提高开发效率和信心
  • 实现自动化测试流程

测试是软件开发中不可或缺的一环,好的测试能让你的代码更加健壮和可维护!


相关文章推荐:

有问题欢迎留言讨论,我会及时回复大家!