鸿蒙应用开发实战【35】— DAO 层错误处理规范与单元测试

本文是「号码助手全栈开发系列」第 35 篇,持续更新中…
开源社区:https://openharmonycrossplatform.csdn.net


前言

DAO 层处于 UI 和数据库之间,是所有数据请求的必经之路。一个未捕获的数据库异常会直接传递到 UI 层,导致应用崩溃或白屏。因此,完善的错误处理充分的单元测试是 DAO 层质量的两个支柱。本篇将系统性地讲解错误处理规范和基于 Hypium 框架的 DAO 层单元测试策略。

本篇涵盖:错误类型分类(业务异常 vs 系统异常)、try-catch-finally 规范、DAO 层异常转换、ResultSet 泄漏防护、Hypium 单元测试框架入门、DAO 测试的 Mock 策略、测试用例编写模式。

在这里插入图片描述


一、错误类型分类

DAO 层的错误可以分为两大类:

1.1 业务异常

由业务规则约束引起的,应通过返回值或自定义错误传递:

场景 处理方式
查询的记录不存在 返回 null 或空数组
重复插入(唯一约束冲突) 抛出语义化的 DuplicateError
引用的 card_id 不存在 抛出 ForeignKeyViolationError
状态转换不合法 返回跳过信息

1.2 异常处理策略对比

维度 业务异常 系统异常
来源 业务规则约束 基础设施故障
处理方式 返回值/null/自定义错误 记日志 + 向上抛出
给用户提示 具体操作建议 通用错误页
示例 记录不存在、重复插入 数据库损坏、磁盘满
是否影响其他功能 可能全局影响

由基础设施故障引起的,应记录日志并向上层抛出:

场景 处理方式
数据库文件损坏 捕获后记录日志,抛出到 UI 层显示错误页
磁盘空间不足 抛出 DatabaseError,提示用户清理空间
权限不足 安全级别配置错误,开发期应发现
并发写入冲突 RDB 的 SQLite 引擎自动处理锁竞争

二、错误处理规范

2.1 基本 try-catch-finally 模式

所有数据库操作都应该遵循这个模式:

async getById(id: number): Promise<CardEntity | null> {
  let rs: relationalStore.ResultSet | null = null;
  try {
    let predicates = new relationalStore.RdbPredicates('card');
    predicates.equalTo('id', id);

    rs = await this.store.query(predicates);
    if (rs.rowCount === 0) return null;

    rs.goToFirstRow();
    return parseCard(rs);
  } catch (error) {
    console.error(`CardDao.getById failed: id=${id}`, error);
    throw new DatabaseError('查询卡号失败', error);
  } finally {
    if (rs) {
      rs.close();
    }
  }
}

三条铁律:

  1. ResultSet 必须在 finally 中关闭 — 无论查询成功还是异常,都要释放底层资源
  2. 捕获后不要吞异常 — 记日志后重新抛出,让 UI 层决定如何展示
  3. 异常转换 — 将通用 Error 转换为语义化的 DatabaseError

2.2 自定义错误类型

// DAO 层自定义错误基类
export class DatabaseError extends Error {
  constructor(
    message: string,
    public readonly cause?: unknown
  ) {
    super(message);
    this.name = 'DatabaseError';
  }
}

// 外键冲突
export class ForeignKeyViolationError extends DatabaseError {
  constructor(
    message: string,
    public readonly entityType: string,
    public readonly entityId?: number
  ) {
    super(message);
    this.name = 'ForeignKeyViolationError';
  }
}

// 重复错误
export class DuplicateError extends DatabaseError {
  constructor(
    message: string,
    public readonly field: string,
    public readonly value: string
  ) {
    super(message);
    this.name = 'DuplicateError';
  }
}

2.3 DAO 方法的异常策略

返回值类型 异常策略 示例
Promise<T | null> 正常返回 null getById
Promise<T[]> 正常返回空数组 [] listAll
Promise<number> 正常返回 0 delete / update
Promise<void> 失败抛出异常 batchImport

原则: 查询型方法(get/list)尽可能用返回值而非异常表示"无数据",命令型方法(insert/update/delete)在失败时抛出异常。


三、事务错误处理

3.1 事务的标准模式

async performSwitch(oldBindingId: number, newCardId: number): Promise<void> {
  try {
    this.store.beginTransaction();

    // 业务操作
    await this.updateStatus(oldBindingId, '待换绑');
    await this.insert({ app_name: '微信', card_id: newCardId, status: '使用中' });

    this.store.commit();
  } catch (error) {
    // 异常时回滚
    try {
      this.store.rollback();
    } catch (rollbackError) {
      console.error('Rollback also failed', rollbackError);
    }
    throw new DatabaseError('换绑操作失败', error);
  }
}

⚠️ rollback 也需 try-catch:如果 commit 失败时数据库连接异常,rollback 也可能抛出错误。需要双重 try-catch 确保不会因为 rollback 异常而丢失原始错误信息。

3.2 避免事务嵌套

// ❌ 错误:DAO 内开启了事务,又被外部事务包裹
// 这会导致嵌套事务,RDB 的 SQLite 默认不支持嵌套

// ✅ 正确:让调用方控制事务边界,DAO 只做单表操作
// 如果需要跨表事务,在 Service 层统一管理

四、ResultSet 泄漏防护

ResultSet 泄漏是最容易犯的 DAO 层错误,下面是几条防护策略:

4.1 统一查询方法

// 所有 DAO 都使用统一的私有查询方法,确保 close
private async queryList(predicates: relationalStore.RdbPredicates): Promise<CardEntity[]> {
  let rs = await this.store.query(predicates);
  try {
    let list: CardEntity[] = [];
    while (rs.goToNextRow()) {
      list.push(parseCard(rs));
    }
    return list;
  } finally {
    rs.close();
  }
}

4.2 快速返回时注意 close

// ❌ 危险:提前 return 跳过了 close
async getById(id: number): Promise<CardEntity | null> {
  let rs = await this.store.query(predicates);
  if (rs.rowCount === 0) return null;  // 没有 close!
  rs.goToFirstRow();
  const entity = parseCard(rs);
  rs.close();
  return entity;
}

// ✅ 安全:try-finally 包裹
async getById(id: number): Promise<CardEntity | null> {
  let rs = await this.store.query(predicates);
  try {
    if (rs.rowCount === 0) return null;
    rs.goToFirstRow();
    return parseCard(rs);
  } finally {
    rs.close();
  }
}

五、基于 Hypium 的单元测试

Hypium 是 HarmonyOS 的单元测试框架,支持 ArkTS 测试用例。

5.1 测试配置

entry/src/test/ 目录下创建测试文件:

// CardDao.test.ets
import { describe, it, expect } from '@ohos/hypium';
import { relationalStore } from '@kit.ArkData';
import { CardDao } from '../src/main/ets/dao/CardDao';
import { CardEntity, CardColor } from '../src/main/ets/entity/CardEntity';

export default function cardDaoTest() {
  describe('CardDaoTest', () => {
    // 测试前初始化数据库
    let store: relationalStore.RdbStore;

    beforeAll(async () => {
      // 使用内存数据库
      store = await relationalStore.getRdbStore(globalThis.context, {
        name: 'test.db',
        securityLevel: relationalStore.SecurityLevel.S1,  // 测试用 S1
      }, async () => {
        await store.executeSql(`
          CREATE TABLE IF NOT EXISTS card (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            label TEXT NOT NULL,
            phone TEXT NOT NULL,
            operator TEXT,
            color TEXT DEFAULT '#4F7CFF',
            sort_order INTEGER DEFAULT 0,
            created_at TEXT DEFAULT (datetime('now', 'localtime')),
            updated_at TEXT DEFAULT (datetime('now', 'localtime'))
          )
        `);
      });
    });

    // 每个测试前清理数据
    afterEach(async () => {
      await store.executeSql('DELETE FROM card');
    });

    // 销毁数据库
    afterAll(async () => {
      await relationalStore.deleteRdbStore(globalThis.context, 'test.db');
    });
  });
}

5.2 测试用例编写

// 测试插入
it('insert_should_return_row_id', async () => {
  const dao = new CardDao(store);

  const id = await dao.insert({
    label: '测试卡',
    phone: '13800138000',
    operator: '中国移动',
    color: CardColor.BLUE,
  });

  expect(id).not().null();
  expect(id).assertEqual(1);  // 首次插入,id=1
});

// 测试查询
it('getById_should_return_entity', async () => {
  const dao = new CardDao(store);

  await dao.insert({ label: '测试卡', phone: '13800138000' });
  const entity = await dao.getById(1);

  expect(entity).not().null();
  expect(entity!.label).assertEqual('测试卡');
  expect(entity!.phone).assertEqual('13800138000');
});

// 测试不存在
it('getById_should_return_null_when_not_found', async () => {
  const dao = new CardDao(store);
  const entity = await dao.getById(999);
  expect(entity).assertNull();
});

// 测试删除
it('delete_should_remove_entity', async () => {
  const dao = new CardDao(store);

  await dao.insert({ label: '测试卡', phone: '13800138000' });
  const deleted = await dao.delete(1);

  expect(deleted).assertEqual(1);
  const entity = await dao.getById(1);
  expect(entity).assertNull();
});

// 测试批量删除
it('deleteMany_should_remove_multiple', async () => {
  const dao = new CardDao(store);

  await dao.insert({ label: '卡1', phone: '111' });
  await dao.insert({ label: '卡2', phone: '222' });
  await dao.insert({ label: '卡3', phone: '333' });

  const deleted = await dao.deleteMany([1, 3]);
  expect(deleted).assertEqual(2);

  const all = await dao.listAll();
  expect(all.length).assertEqual(1);
  expect(all[0].label).assertEqual('卡2');
});

5.3 测试事务回滚

it('transaction_should_rollback_on_error', async () => {
  const dao = new CardDao(store);

  try {
    store.beginTransaction();
    await dao.insert({ label: '卡1', phone: '111' });
    await dao.insert({ label: '卡2', phone: '222' });

    // 模拟异常
    throw new Error('模拟错误');

    // 下面的 commit 不会执行
    store.commit();
  } catch (error) {
    store.rollback();
  }

  // 验证:事务回滚后,两条插入都应该不存在
  const all = await dao.listAll();
  expect(all.length).assertEqual(0);
});

5.5 测试覆盖清单

测试类别 用例示例 验证要点
正常流程 insert / getById / update / delete 基本 CRUD 正确性
边界条件 空数组、空关键词、不存在的 ID DAO 层容错能力
异常流程 事务回滚、重复插入 错误处理正确性
数据一致性 导出→清空→导入→验证 导入导出完整性

5.4 测试边界条件

// 空数组批量操作
it('deleteMany_empty_ids_should_return_0', async () => {
  const dao = new CardDao(store);
  const deleted = await dao.deleteMany([]);
  expect(deleted).assertEqual(0);
});

// 搜索空关键词
it('search_empty_keyword_should_return_all', async () => {
  const dao = new CardDao(store);
  await dao.insert({ label: '卡1', phone: '111' });
  await dao.insert({ label: '卡2', phone: '222' });

  const results = await dao.search('');
  expect(results.length).assertEqual(2);
});

六、测试运行

# 在 DevEco Studio 中运行测试
# 或通过命令行
hvigorw runTest --module entry --testType hypium

Hypium 测试报告会显示每个用例的执行结果和耗时:

CardDaoTest
  ✓ insert_should_return_row_id (12ms)
  ✓ getById_should_return_entity (8ms)
  ✓ getById_should_return_null_when_not_found (5ms)
  ✓ delete_should_remove_entity (6ms)
  ✓ deleteMany_should_remove_multiple (10ms)
  ✓ transaction_should_rollback_on_error (4ms)
  ✓ deleteMany_empty_ids_should_return_0 (3ms)

7 passed (48ms)

小结

本篇完成了 DAO 层错误处理和单元测试的完整指南:

模块 关键实践
错误分类 业务异常(返回值) vs 系统异常(抛异常)
try-catch-finally ResultSet 在 finally 中 close
事务错误 双重 try-catch 保护 commit + rollback
自定义错误 DatabaseError / ForeignKeyViolationError
Hypium 测试 内存数据库 + beforeAll/afterEach
测试用例 insert / query / delete / batch / rollback
边界测试 空数组、空关键词、不存在的 ID

至此,DAO 层进阶系列(31-35)全部完成!接下来将进入数据库优化系列(36-40),深入查询优化与高级功能。


如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


相关资源:

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐