《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)
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;
});
});

执行测试
在中添加测试脚本:
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

8.3.2 性能优化:为高频查询字段添加索引
高频查询场景分析
| 查询场景 | 核心字段 | 索引类型 |
|---|---|---|
| 按分类精准查询 | |
单字段升序索引 |
| 商品名称模糊搜索 | |
文本索引 |
| 价格区间筛选 | |
单字段升序索引 |
| 分类+价格组合筛选 | |
复合索引 |
索引创建代码(
scripts/createIndex.js)
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
核心优化点:复用公共连接配置、增加数据清理逻辑、优化结果输出可读性
// 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
核心优化点:复用公共连接、增加参数校验、优化并发测试逻辑、增强结果可读性
// 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
一键运行所有测试:(适合完整验证,按顺序执行上述3个测试脚本)。
npm run test:all
3. 常见问题排查
索引测试显示「全表扫描」:检查索引是否创建成功(执行重新创建);事务测试失败提示「no primary found in replica set」:副本集未正常启动,重新启动副本集;并发测试出现超卖:检查事务是否绑定会话,确保
node scripts/createIndex.js和
findById都添加了
save;测试脚本超时:调整
{ session }中
package.json参数(如改为
--timeout,单位ms)。
20000
8.3 核心总结
功能测试:通过单元测试覆盖核心CRUD接口,利用标准化测试流程,确保功能符合业务预期;
Mocha+Chai
性能优化:针对高频查询字段创建索引,通过分析执行计划验证索引效果,平衡查询与写入性能;
explain()
数据一致性:利用MongoDB事务保证库存操作的原子性,结合并发测试验证无超卖问题,依赖副本集确保事务可用;
工程化优化:提取公共数据库连接配置,简化运行命令,增加数据清理和参数校验,提升测试的稳定性和可读性。
以上代码可直接运行(需适配你的MongoDB副本集地址),完整覆盖简易商品管理系统的测试、优化、一致性保障核心需求。





