【MongoDB实战】8.3 简易商品管理系统-测试与优化 完善版

内容分享8小时前发布
0 0 0

《MongoDB实战入门》第8章 实战项目1:简易商品管理系统

8.3 简易商品管理系统-测试与优化 完善版

前置说明

本章实战基于 Node.js + Mongoose 操作MongoDB(需MongoDB 4.0+,事务依赖副本集),先完成基础环境准备:

安装依赖:
npm install mongoose mocha chai --save-dev

启动MongoDB副本集(事务必备):参考MongoDB官方文档搭建单节点/多节点副本集,命名为
rs0

基础数据模型(
models/product.js
):定义商品核心字段


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

// 商品Schema定义
const productSchema = new mongoose.Schema({
  name: { type: String, required: true, trim: true }, // 商品名称
  category: { type: String, required: true, trim: true }, // 商品分类
  price: { type: Number, required: true, min: 0 }, // 商品价格
  stock: { type: Number, required: true, min: 0, default: 0 }, // 库存(非负)
  createTime: { type: Date, default: Date.now }, // 创建时间
  updateTime: { type: Date, default: Date.now } // 更新时间
});

// 预更新钩子:自动更新updateTime(适配高版本Mongoose,移除next参数)
productSchema.pre('save', async function() {
  this.updateTime = Date.now();
});

// 导出模型
const Product = mongoose.model('Product', productSchema);
module.exports = Product;

MongoDB公共连接配置(
utils/db.js
,统一管理连接逻辑):



// utils/db.js
const mongoose = require('mongoose');

// 数据库连接配置(统一配置,避免重复编码)
const DB_URI = 'mongodb://localhost:27017,localhost:27018,localhost:27019/product_manage?replicaSet=rs0';

async function connectDB() {
  try {
    // 高版本Mongoose无需useNewUrlParser等废弃选项
    await mongoose.connect(DB_URI);
    console.log('MongoDB副本集连接成功');
  } catch (err) {
    console.error('MongoDB连接失败:', err);
    process.exit(1);
  }
}

// 统一断开连接方法
async function disconnectDB() {
  try {
    await mongoose.disconnect();
    console.log('MongoDB连接已断开');
  } catch (err) {
    console.error('MongoDB断开连接失败:', err);
  }
}

module.exports = { connectDB, disconnectDB };

8.3.1 功能测试:接口调用验证核心功能

测试目标

验证商品「新增、查询、更新库存、删除」核心接口的正确性,采用
Mocha+Chai
编写单元测试。

测试代码(
test/product.test.js

const chai = require('chai');
const expect = chai.expect;
const mongoose = require('mongoose');
const Product = require('../models/product');
const { connectDB, disconnectDB } = require('../utils/db');

// 全局前置:仅连接数据库,不全局清空数据
before(async () => {
  await connectDB();
});

// 调整:仅清理测试用例生成的临时数据(不清理核心的10条商品数据)
// 临时数据标识:名称包含「测试商品」「待删除商品」
beforeEach(async () => {
  await Product.deleteMany({ 
    name: { $in: ['测试商品', '待删除商品'] } 
  });
});

// 新增:每个用例结束后,仅清理当前用例的临时数据(进一步保证数据保留)
afterEach(async () => {
  await Product.deleteMany({ 
    name: { $in: ['测试商品', '待删除商品'] } 
  });
});

// 全局后置:仅断开数据库,不清理核心数据
after(async () => {
  await disconnectDB();
});

// 测试套件:商品CRUD功能
describe('商品管理系统核心功能测试', () => {
  // 用例1:新增商品 - 批量生成10条核心数据(执行后保留)
  it('新增商品 - 成功批量创建10条符合规则的商品(保留数据)', async () => {
    // 构造10条核心商品数据(名称无「测试」标识,避免被清理)
    const productData = [
      { name: '华为Mate60 Pro', category: '手机', price: 6999, stock: 1000 },
      { name: 'iPhone 15', category: '手机', price: 7999, stock: 800 },
      { name: '小米手环8', category: '智能穿戴', price: 199, stock: 5000 },
      { name: '华为Pura70', category: '手机', price: 5999, stock: 1200 },
      { name: 'iPad Pro 2024', category: '平板', price: 8999, stock: 300 },
      { name: '小米14', category: '手机', price: 4999, stock: 1500 },
      { name: 'AirPods Pro 2', category: '音频设备', price: 1799, stock: 2000 },
      { name: '荣耀Magic6', category: '手机', price: 4799, stock: 900 },
      { name: '华为Watch GT 4', category: '智能穿戴', price: 1499, stock: 1800 },
      { name: '联想拯救者Y9000P', category: '笔记本', price: 9999, stock: 400 }
    ];
    // 批量插入10条核心数据
    const savedProducts = await Product.insertMany(productData);

    // 断言验证:确保批量插入成功
    expect(savedProducts).to.be.an('array');
    expect(savedProducts.length).to.equal(10);
    savedProducts.forEach((product, index) => {
      expect(product._id).to.exist;
      expect(product.name).to.equal(productData[index].name);
      expect(product.category).to.equal(productData[index].category);
      expect(product.stock).to.equal(productData[index].stock);
      expect(product.createTime).to.be.a('Date');
    });
  });

  // 用例2:按分类查询商品(基于核心数据查询,不干扰保留数据)
  it('查询商品 - 按分类精准查询返回匹配结果', async () => {
    // 执行分类查询(直接查询核心数据中的「手机」分类)
    const phoneProducts = await Product.find({ category: '手机' });

    // 断言验证(核心数据中「手机」分类共5条,对应上方productData)
    expect(phoneProducts.length).to.equal(5);
    expect(phoneProducts.every(p => p.category === '手机')).to.be.true;
  });

  // 用例3:更新商品库存(操作临时测试商品,不影响核心数据)
  it('更新库存 - 成功修改商品库存数值', async () => {
    // 预置临时测试数据(名称含「测试商品」,会被afterEach清理)
    const product = new Product({
      name: '测试商品',
      category: '测试分类',
      price: 99,
      stock: 100
    });
    await product.save();

    // 执行库存更新
    const updatedProduct = await Product.findByIdAndUpdate(
      product._id,
      { stock: 80 },
      { new: true }
    );

    // 断言验证
    expect(updatedProduct.stock).to.equal(80);
  });

  // 用例4:删除商品(操作临时测试商品,不影响核心数据)
  it('删除商品 - 成功移除指定商品', async () => {
    // 预置临时测试数据(名称含「待删除商品」,会被afterEach清理)
    const product = new Product({
      name: '待删除商品',
      category: '测试分类',
      price: 19.9,
      stock: 50
    });
    await product.save();

    // 执行删除
    await Product.findByIdAndDelete(product._id);

    // 验证删除结果
    const deletedProduct = await Product.findById(product._id);
    expect(deletedProduct).to.be.null;
  });
});

【MongoDB实战】8.3 简易商品管理系统-测试与优化 完善版

执行测试


package.json
中添加测试脚本:



"scripts": {
  "test:crud": "mocha test/product.test.js --timeout 10000",
  "test:index": "node scripts/testIndex.js",
  "test:transaction": "node services/productService.js",
  "test:all": "npm run test:crud && npm run test:index && npm run test:transaction"
}

运行命令说明:

单独测试CRUD功能:
npm run test:crud
单独测试索引效果:
npm run test:index
单独测试事务控制:
npm run test:transaction
整体测试所有功能:
npm run test:all
(按顺序执行,前序失败则终止)
【MongoDB实战】8.3 简易商品管理系统-测试与优化 完善版


8.3.2 性能优化:为高频查询字段添加索引

高频查询场景分析
查询场景 核心字段 索引类型
按分类精准查询
category
单字段升序索引
商品名称模糊搜索
name
文本索引
价格区间筛选
price
单字段升序索引
分类+价格组合筛选
category+price
复合索引
索引创建代码(
scripts/createIndex.js

// scripts/createIndex.js
const mongoose = require('mongoose');
const Product = require('../models/product');
// 改为模块对象导入,避免高版本Node.js解构兼容性问题
const db = require('../utils/db');
const connectDB = db.connectDB;
const disconnectDB = db.disconnectDB;

async function createProductIndexes() {
  await connectDB(); // 复用公共连接方法

  try {
    // 1. 分类单字段索引(精准查询)
    await Product.collection.createIndex(
      { category: 1 },
      { name: 'idx_category', background: true } // background:后台创建,不阻塞业务
    );

    // 2. 名称文本索引(模糊搜索)
    await Product.collection.createIndex(
      { name: 'text' },
      { name: 'idx_name_text', background: true }
    );

    // 3. 价格单字段索引(区间查询)
    await Product.collection.createIndex(
      { price: 1 },
      { name: 'idx_price', background: true }
    );

    // 4. 分类+价格复合索引(组合筛选)
    await Product.collection.createIndex(
      { category: 1, price: 1 },
      { name: 'idx_category_price', background: true }
    );

    console.log('索引创建成功!');

    // 查看已创建的索引
    const indexes = await Product.collection.indexes();
    console.log('当前索引列表:', indexes.map(idx => idx.name));

  } catch (err) {
    console.error('索引创建失败:', err);
  } finally {
    await disconnectDB(); // 复用公共断开方法
  }
}

// 执行索引创建(可通过npm脚本调用)
createProductIndexes();
优化后的索引测试代码(
scripts/testIndex.js

核心优化点:复用公共连接配置、增加数据清理逻辑、优化结果输出可读性



// scripts/testIndex.js
const mongoose = require('mongoose');
const Product = require('../models/product');
// 直接导入db模块,避免解构赋值的兼容性问题
const db = require('../utils/db');

// 移除独立的clearTestData函数,改为在连接上下文内执行清理逻辑
async function testIndexEffect() {
  try {
    // 1. 先连接数据库,确保连接成功后再执行后续操作
    await db.connectDB();
    console.log('数据库连接成功,开始执行测试数据清理');

    // 2. 在连接上下文内清理测试数据(避免连接断开后执行操作)
    await Product.deleteMany({ name: /^商品d+$/ });
    console.log('历史测试数据清理完成');

    // 3. 插入10000条测试数据(模拟生产环境)
    const bulkData = [];
    for (let i = 0; i < 10000; i++) {
      bulkData.push({
        name: `商品${i}`,
        category: `分类${i % 100}`,
        price: Math.random() * 1000,
        stock: Math.floor(Math.random() * 10000)
      });
    }
    await Product.insertMany(bulkData);
    console.log('10000条测试数据插入完成');

    // 4. 测试分类查询的执行计划(高频查询场景)
    const query = Product.find({ category: '分类50' });
    const explainResult = await query.explain('executionStats');

    // 5. 格式化输出关键指标(便于验证索引效果)
    console.log('
===== 索引效果验证结果 =====');
    // 增加层级判断,避免inputStage为undefined时报错
    const winningPlan = explainResult.queryPlanner?.winningPlan;
    const inputStage = winningPlan?.inputStage;
    const isUseIndex = inputStage?.stage === 'IXSCAN';
    console.log('是否使用索引:', isUseIndex ? '是(IXSCAN)' : inputStage ? '否(全表扫描COLLSCAN)' : '查询计划异常,未获取到执行阶段');
    console.log('扫描文档数:', explainResult.executionStats?.totalDocsExamined || 0);
    console.log('返回文档数:', explainResult.executionStats?.nReturned || 0);
    console.log('执行耗时(ms):', explainResult.executionStats?.executionTimeMillis || 0);
    console.log('============================
');

    // 6. 测试完成后再次清理测试数据
    await Product.deleteMany({ name: /^商品d+$/ });
    console.log('测试数据清理完成');

  } catch (err) {
    console.error('索引测试失败:', err);
  } finally {
    // 7. 无论成功失败,最终都断开数据库连接
    await db.disconnectDB();
  }
}

// 执行索引测试(可通过npm run test:index调用)
testIndexEffect();
索引优化注意事项

执行顺序:必须先运行
node scripts/createIndex.js
创建索引,再运行
npm run test:index
验证效果;

避免过度索引:索引会增加写入(插入/更新/删除)开销,仅为高频查询字段创建;

复合索引遵循「最左前缀原则」:
idx_category_price
支持
category

category+price
查询,但不支持单独
price
查询;

后台创建索引(
background: true
):避免阻塞生产环境的读写操作。


8.3.3 数据一致性保证:库存更新添加事务控制

业务痛点

电商场景中,
多用户并发扣减库存易导致「超卖」(库存为负),需通过MongoDB事务保证库存操作的原子性

优化后的事务测试代码(
services/productService.js

核心优化点:复用公共连接、增加参数校验、优化并发测试逻辑、增强结果可读性



// services/productService.js
const mongoose = require('mongoose');
const Product = require('../models/product');
// 改为模块对象导入,避免高版本Node.js解构兼容性问题
const db = require('../utils/db');
const connectDB = db.connectDB;
const disconnectDB = db.disconnectDB;

/**
 * 扣减商品库存(事务控制)
 * @param {String} productId 商品ID
 * @param {Number} deductNum 扣减数量(正整数)
 * @returns {Object} 操作结果
 */
async function deductStock(productId, deductNum) {
  // 新增参数校验:避免无效请求
  if (!mongoose.Types.ObjectId.isValid(productId)) {
    return { success: false, error: '商品ID格式错误' };
  }
  if (!Number.isInteger(deductNum) || deductNum <= 0) {
    return { success: false, error: '扣减数量必须是正整数' };
  }

  // 1. 开启会话(事务依赖会话)
  const session = await mongoose.startSession();
  session.startTransaction(); // 启动事务

  try {
    // 2. 查询商品(绑定会话)
    const product = await Product.findById(productId).session(session);
    if (!product) throw new Error('商品不存在');
    if (product.stock < deductNum) throw new Error('库存不足');

    // 3. 扣减库存(绑定会话)
    product.stock -= deductNum;
    await product.save({ session });

    // 4. 提交事务
    await session.commitTransaction();
    return { success: true, productId, deductNum, remainingStock: product.stock };

  } catch (err) {
    // 5. 事务回滚(异常时)
    await session.abortTransaction();
    return { success: false, productId, deductNum, error: err.message };

  } finally {
    // 6. 结束会话(释放资源)
    session.endSession();
  }
}

// 模拟并发扣减测试(核心测试逻辑)
async function testConcurrentDeduct() {
  await connectDB();

  let testProductId;
  try {
    // 1. 清空历史测试数据
    await Product.deleteMany({ name: '并发测试商品' });
    console.log('历史并发测试数据清理完成');

    // 2. 预置测试商品(库存10)
    const testProduct = new Product({
      name: '并发测试商品',
      category: '测试分类',
      price: 99,
      stock: 10
    });
    const savedProduct = await testProduct.save();
    testProductId = savedProduct._id.toString();
    console.log(`预置测试商品成功:ID=${testProductId},初始库存=10`);

    // 3. 模拟15个并发请求(每个扣减1个库存,预期10成功、5失败)
    const promises = [];
    for (let i = 0; i < 15; i++) {
      // 延迟少量时间,更真实模拟并发(避免请求完全同步)
      const delay = Math.random() * 50;
      promises.push(new Promise(resolve => {
        setTimeout(async () => {
          const result = await deductStock(testProductId, 1);
          resolve(result);
        }, delay);
      }));
    }

    // 4. 执行并发请求并统计结果
    const results = await Promise.all(promises);
    const successResults = results.filter(r => r.success);
    const failResults = results.filter(r => !r.success);

    // 5. 查询最终库存
    const finalProduct = await Product.findById(testProductId);
    const finalStock = finalProduct ? finalProduct.stock : -1;

    // 6. 格式化输出测试结果
    console.log('
===== 并发库存扣减测试结果 =====');
    console.log(`总请求数:${results.length} | 成功数:${successResults.length} | 失败数:${failResults.length}`);
    console.log(`失败原因统计:${failResults.map(r => r.error).join('、')}`);
    console.log(`初始库存:10 | 最终库存:${finalStock}`);
    console.log(`测试结论:${finalStock === 0 && successResults.length === 10 ? '通过(无超卖,事务生效)' : '失败(存在超卖风险)'}`);
    console.log('============================
');

  } catch (err) {
    console.error('并发测试整体失败:', err);
  } finally {
    // 清理测试数据
    if (testProductId) {
      await Product.deleteMany({ _id: testProductId });
    }
    await disconnectDB();
  }
}

// 执行并发事务测试(可通过npm run test:transaction调用)
testConcurrentDeduct();
事务核心注意事项

事务依赖MongoDB副本集:单节点MongoDB不支持事务,需确保
rs0
副本集正常运行;

会话(Session)生命周期:开启→启动事务→执行操作→提交/回滚→结束会话,不可遗漏;

事务操作必须绑定同一个Session:查询、更新、保存等操作需指定
{ session }

事务尽量短小:减少锁持有时间,避免影响并发性能;

兜底方案:结合「乐观锁」(添加
version
字段),更新时验证版本号,进一步防止并发问题


8.3.4 整体测试运行最佳实践

1. 环境检查(前置必做)

验证MongoDB副本集状态:执行
mongo --host localhost:27017
,进入后输入
rs.status()
,确保
setName: "rs0"

members
状态正常;

验证依赖安装:确保
node_modules
存在,无缺失依赖(可重新执行
npm install
);

验证配置正确性:检查
utils/db.js
中的
DB_URI
,确保与本地副本集地址一致。

2. 整体测试步骤(按顺序执行)

创建索引:
node scripts/createIndex.js
(仅需执行一次,后续索引存在时可跳过);

运行核心CRUD测试:
npm run test:crud
(验证基础功能正常);

运行索引效果测试:
npm run test:index
(验证索引生效,无全表扫描);

运行事务并发测试:
npm run test:transaction
(验证无超卖,事务生效);

一键运行所有测试:
npm run test:all
(适合完整验证,按顺序执行上述3个测试脚本)。

3. 常见问题排查

索引测试显示「全表扫描」:检查索引是否创建成功(执行
node scripts/createIndex.js
重新创建);事务测试失败提示「no primary found in replica set」:副本集未正常启动,重新启动副本集;并发测试出现超卖:检查事务是否绑定会话,确保
findById

save
都添加了
{ session }
;测试脚本超时:调整
package.json

--timeout
参数(如改为
20000
,单位ms)。


8.3 核心总结

功能测试:通过单元测试覆盖核心CRUD接口,利用
Mocha+Chai
标准化测试流程,确保功能符合业务预期;

性能优化:针对高频查询字段创建索引,通过
explain()
分析执行计划验证索引效果,平衡查询与写入性能;

数据一致性:利用MongoDB事务保证库存操作的原子性,结合并发测试验证无超卖问题,依赖副本集确保事务可用;

工程化优化:提取公共数据库连接配置,简化运行命令,增加数据清理和参数校验,提升测试的稳定性和可读性。

以上代码可直接运行(需适配你的MongoDB副本集地址),完整覆盖简易商品管理系统的测试、优化、一致性保障核心需求。

© 版权声明

相关文章

暂无评论

none
暂无评论...