Sequelize 测试最佳实践 - 单元测试与集成测试
发布时间:2024-04-05
作者:一介布衣
标签:Sequelize, 测试, 单元测试, 集成测试, Jest
前言
今天咱们来聊聊 Sequelize 项目的测试。说实话,数据库相关的测试一直是个难点,既要保证测试的独立性,又要确保测试环境的一致性。
我记得刚开始写测试的时候,经常遇到各种问题:测试之间相互影响、数据库状态不一致、测试运行缓慢等等。后来慢慢摸索出一套测试方法,既能保证测试质量,又能提高开发效率。
今天我就把这些测试经验和最佳实践分享给大家。
测试环境搭建
1. 测试依赖安装
bash
# 安装测试框架和相关工具
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
:
javascript
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. 测试数据库配置
javascript
// 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. 测试环境初始化
javascript
// 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. 基础模型测试
javascript
// 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. 关联关系测试
javascript
// 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. 用户服务测试
javascript
// 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 外部依赖
javascript
// 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 集成测试
javascript
// 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. 事务测试
javascript
// 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. 创建测试数据工厂
javascript
// 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. 使用工厂简化测试
javascript
// 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. 查询性能测试
javascript
// 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. 测试覆盖率配置
javascript
// 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. 测试质量检查
javascript
// 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 配置
yaml
# .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 集成配置
掌握了这些知识,你就能够:
- 构建完整的测试体系
- 保证代码质量和稳定性
- 提高开发效率和信心
- 实现自动化测试流程
测试是软件开发中不可或缺的一环,好的测试能让你的代码更加健壮和可维护!
相关文章推荐:
有问题欢迎留言讨论,我会及时回复大家!