第3章 本地数据存储集成测试:IndexedDB读写验证、存储限额测试、数据迁移一致性检查
IndexedDB 是离线优先应用的核心本地存储技术,提供非关系型、基于事务的异步数据库。集成测试必须覆盖数据写入/读取的正确性、存储限额的边界行为以及数据库版本升级时的数据迁移一致性。本章从原理出发,给出完整的测试流程与代码实现,并针对常见陷阱和性能优化提供可操作建议。
3.1 原理:IndexedDB 事务模型与同源限制
IndexedDB 的操作始终在一个显式或隐式的事务(transaction)中执行,事务在操作完成后自动提交。数据库版本通过整数标识,版本变更触发 onupgradeneeded 事件,在该事件中可创建或删除对象仓库(object store)和索引。所有操作受同源策略约束,不同源或 file:// 协议下的脚本无法共享数据库。
测试前提: 每个测试用例必须从全新数据库开始,或显式删除测试数据库,避免脏数据干扰。推荐在
setup 阶段调用 indexedDB.deleteDatabase(dbName) 并等待成功回调。
3.2 实现流程
- 初始化测试环境:在
beforeAll中删除旧数据库,打开新连接并创建对象仓库与索引。 - 读写验证:向对象仓库插入预设数据,通过主键或索引读取,比对内容完全一致。
- 存储限额测试:持续写入大对象(例如 1MB 字符串)直到写入失败,验证错误类型以及能否读出已成功写入的数据。
- 数据迁移一致性检查:定义两个相邻版本号,在升级事件中修改仓库结构;先写入旧版本数据,再以新版本打开,验证数据在结构变化后仍完整可读。
- 清理:关闭连接并删除数据库。
3.3 核心代码:读写验证测试
// 测试辅助:打开数据库
function openDB(name, version, upgrade) {
return new Promise((resolve, reject) => {
const req = indexedDB.open(name, version);
req.onupgradeneeded = upgrade;
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
// 写入单条记录
function addRecord(db, storeName, record) {
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
const req = store.add(record);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
// 通过主键读取
function getRecord(db, storeName, key) {
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const req = store.get(key);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
// 测试示例
test('IndexedDB 读写验证', async () => {
const dbName = 'test_db_' + Date.now();
const db = await openDB(dbName, 1, (e) => {
const db = e.target.result;
const store = db.createObjectStore('users', { keyPath: 'id' });
store.createIndex('byName', 'name', { unique: false });
});
await addRecord(db, 'users', { id: 1, name: 'Alice', age: 30 });
const result = await getRecord(db, 'users', 1);
expect(result).toEqual({ id: 1, name: 'Alice', age: 30 });
db.close();
indexedDB.deleteDatabase(dbName);
});
3.4 存储限额测试
IndexedDB 的存储限额由浏览器管理,通常每个源有几十 MB 到几百 MB。集成测试应模拟存储满的场景,验证写入拒绝行为及已有数据可读。
测试策略: 使用
navigator.storage.estimate() 获取当前占用与限额,先写入一个已知大小的大对象,逐次翻倍写入直到报错。注意不同浏览器的配额释放时机可能不同。
async function fillStorageUntilFailure(db, storeName, chunkSize) {
const chunks = [];
let error = null;
while (!error) {
const data = { id: chunks.length, payload: 'x'.repeat(chunkSize) };
try {
await addRecord(db, storeName, data);
chunks.push(data);
} catch (e) {
error = e;
}
}
return { insertedCount: chunks.length, error };
}
test('存储超出限额时应抛出 DOMException', async () => {
// 准备一个极小容量的模拟?实际上只能借助真实浏览器环境
// 此处仅验证错误类型
const { error } = await fillStorageUntilFailure(db, 'blobs', 1024 * 1024);
expect(error.name).toBe('QuotaExceededError');
});
注意: 限额测试无法在 headless 无存储限制的环境中运行,推荐在真实的浏览器实例(如 Playwright/Chrome headless with Storage 模拟)中执行。也可以利用 Chrome DevTools Protocol 的
Storage.quota 接口临时压低配额。
3.5 数据迁移一致性检查
当应用更新导致数据库版本提升时,onupgradeneeded 中的迁移逻辑必须保证已有数据不丢失且语义正确。测试分三步:
- 创建数据库 v1,插入若干条记录;
- 关闭连接,以 v2 打开,在升级事件中执行结构修改(例如添加索引、重命名字段);
- 重新打开 v2 连接(不再触发升级),读取所有记录,验证内容和结构变更生效。
test('数据迁移一致性', async () => {
const dbName = 'migrate_test';
// 写入 v1 数据
await openDB(dbName, 1, (e) => {
const db = e.target.result;
db.createObjectStore('notes', { keyPath: 'id' });
});
let db1 = await openDB(dbName, 1); // 不触发升级
await addRecord(db1, 'notes', { id: 1, title: 'Old note' });
db1.close();
// 升级到 v2:添加索引并修改数据
const db2 = await openDB(dbName, 2, (e) => {
const db = e.target.result;
const store = e.currentTarget.transaction.objectStore('notes');
store.createIndex('byTitle', 'title', { unique: false });
// 例如将所有标题改为大写(模拟迁移逻辑)
store.openCursor().onsuccess = (ev) => {
const cursor = ev.target.result;
if (cursor) {
const data = cursor.value;
data.title = data.title.toUpperCase();
cursor.update(data);
cursor.continue();
}
};
});
db2.close();
// 读取 v2 数据验证
const db3 = await openDB(dbName, 2);
const result = await getRecord(db3, 'notes', 1);
expect(result.title).toBe('OLD NOTE');
db3.close();
indexedDB.deleteDatabase(dbName);
});
3.6 调试技巧
| 场景 | 推荐方法 |
|---|---|
| 观察数据库状态 | Chrome DevTools → Application → IndexedDB,可手动浏览、编辑、删除记录 |
| 追踪事务生命周期 | 在事务的 oncomplete / onerror 中添加断点 |
| 诊断配额问题 | 运行 navigator.storage.estimate() 并在控制台查看 usage 和 quota |
| 模拟慢速/失败存储 | 使用 Chrome DevTools 的 Network → Offline 或 Storage → Clear storage 后重新加载 |
3.7 常见错误
VersionError:尝试使用比当前数据库版本更低的版本号打开。TransactionInactiveError:在事务已经提交或关闭后继续操作。常见于异步回调中未正确保持事务引用。ConstraintError:插入重复主键或违反唯一索引约束。DataCloneError:尝试存储无法被结构化克隆的对象(如函数、Symbol、DOM 节点)。QuotaExceededError:超出存储限额,处理方式见 3.4 节。
避坑指南: 每个测试用例使用独立的数据库名称(如追加时间戳),避免并行测试冲突。在
afterEach 中始终关闭连接并 deleteDatabase。
3.8 性能与稳定性优化
| 优化点 | 做法 | 收益 |
|---|---|---|
| 批量写入 | 将多条记录放在同一个读写事务中,避免频繁开启/提交 | 减少锁等待与磁盘 I/O |
| 游标分页 | 使用 IDBCursorWithValue 配合 continue(key) 实现增量读取 | 避免一次性加载巨量数据导致内存溢出 |
| 连接复用 | 整个测试套件共用同一个数据库连接,仅在需要升级时重新打开 | 减少 open 开销和版本争用 |
| 异常回滚 | 事务中发生错误后,放弃该事务并重新尝试,避免脏数据残留 | 保证数据原子性 |
集成测试流程图
下图描绘了一次完整的 IndexedDB 集成测试生命周期,包含核心验证点。