Skip to content

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 集成配置

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

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

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


相关文章推荐:

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