如何划定单元测试与集成测试的边界

· · 测试, 单元测试, 集成测试, 测试设计, Windows开发, C# / .NET

在测试设计的话题里,每次都让人隐隐觉得棘手的,就是应该把多少内容塞进单元测试,又该从哪里开始提升到集成测试。

这里危险的是下面两种极端:

  • 因为想跑得快,什么都写成单元测试
  • 因为想更接近真实情况,什么都写成集成测试

前者容易堆满 mock,忽略了正式环境中会出问题的关键点;后者则容易变成又慢又容易失败的一堆测试。 在实务中,需要关注的坐标轴其实更清晰一些。

  • 想确认的是自己的逻辑,还是与外部的连接
  • 用内存中的 fake 替换之后,意义会不会丢失
  • 数据库 / 文件 / HTTP / DI / 配置 / 框架 / 操作系统的行为是不是重点
  • 是否想高速回归大量的输入模式

只要看清这 4 点,单元测试与集成测试的边界就相当容易划定了。

本文以 2026 年 3 月时点可参考的 Microsoft Learn 与 Martin Fowler 的公开信息为前提,从实务角度整理单元测试与集成测试的边界。123

1. 先说结论

说得比较粗略,但是在实务中很好用的表达方式是这样的。

  1. 纯逻辑归单元测试
  2. 连接・配线・转换・环境差异归集成测试
  3. 两种都能验证时,优先选单元测试
  4. 集成测试不要求大而全,而要把边界收窄

一句话来说,单元测试是「判断的测试」,集成测试是「连接的测试」

金额计算、状态迁移、输入校验、审批条件、异常分类这类无需外部资源就能完结意义的内容,偏向单元测试会更快、更不容易出问题,也能更充分地回归输入模式。 另一方面,SQL 的执行、JSON / CSV 的序列化、路由、模型绑定、DI 注册、文件锁、权限、COM 注册、32bit / 64bit、STA / MTA 这类「一旦连接起来就会背叛你」的内容,放到集成测试一侧会更安全。

Microsoft Learn 的 Integration tests in ASP.NET Core 中也整理道:集成测试应聚焦于重要的基础设施场景,如果单元测试就能解决,应优先选择单元测试。

2. 本文所说的单元测试与集成测试

在这里,我们把术语这样区分使用。

层级 确认什么 典型构成
单元测试 隔离出的单一职责是否正确 使用 fake / mock / stub,切断外部资源
集成测试 多个组件之间的连接,以及包含基础设施与框架的行为 真实数据库、真实文件、真实 serializer、真实 host、真实 pipeline 等
E2E / 功能测试 应用整体的用户流程 已部署的应用、多个服务、真实浏览器或真实进程

在 .NET 的单元测试整理中,好的单元测试被说明为 fast / isolated / repeatable,不依赖文件系统或数据库这类外部因素。详情可参考 Unit testing best practices for .NET,内容相当清晰易懂。

另外,集成测试并不只是指「一定要用别的进程或别的服务器」的重量级测试。 即便是在同一进程内,只要连接了多个真实组件,确认框架或基础设施的真实行为,那就偏向集成测试

比如在对 ASP.NET Core 的 controller action 做单元测试时,官方也给出了这样的划分:把对象收窄到 action 本体的判断,而 routingmodel bindingfilters 这类框架侧的相互作用则交给集成测试处理。详情可参考 Unit test controller logic in ASP.NET Core,会更容易理清思路。

3. 一张图看懂的判断表

首先放一张在实务中最好用的表。

想确认的内容 主要采用的测试 补充
金额计算、折扣、状态迁移、输入校验 单元测试 想充分回归输入模式
异常分类、错误信息的选择、是否重试的判断 单元测试 无需真实 I/O 就能完结意义
Repository 的 SQL / ORM 转换、transaction 集成测试 重点是真实数据库或真实 provider 的行为
JSON / XML / CSV 的 serialize / deserialize 集成测试 wire format 的偏差用 fake 很难发现
路由、模型绑定、过滤器、middleware 集成测试 确认与框架的连接
WPF / WinForms 的 ViewModel 或 Presenter 的状态迁移 单元测试 不启动 UI 也有意义
实际的 Binding、Dispatcher、control lifecycle、message loop 集成测试 或 UI 测试 主题是框架与线程的行为
文件路径、权限、锁、共享文件夹、换行符、字符编码 集成测试 需要操作系统与文件系统的真实行为
COM 注册、32bit / 64bit、STA / MTA、DLL 加载 集成测试 主题是环境差异与进程边界
应用整体的启动、主要用例的全流程确认 E2E / smoke 数量可以很少

看的技巧是,哪种测试离「正式环境会出问题的原因」最近。 按照想要减少的不确定性来决定,而不是按代码的存放位置来决定,会更不容易摇摆。

4. 单元测试应该持有的内容

适合单元测试的,是去掉外部世界之后依然有意义的职责

比如下面这些。

  • 业务规则
  • 分支
  • 状态迁移
  • 输入校验
  • 错误分类
  • 重试方针的决定
  • ViewModel / Presenter 的状态变化
  • 转换逻辑本身

尤其是,组合数量越多,偏向单元测试的价值就越高

比如:

  • 有 / 无优惠券
  • 有 / 无库存
  • 首次下单 / 再次下单
  • 管理员 / 普通用户
  • 正常值 / 边界值 / 非法值

像这样,分支条件越多,用集成测试全部跑一遍就会越重。 这种情况用单元测试细致地拆分会更合理。

另外,在单元测试中,让外部因素可控也很重要。

  • 注入当前时间
  • 让 GUID 或随机数可以替换
  • 不用 sleep 等待
  • 不接触真实数据库或真实文件
  • 不访问真实网络

只要遵守这些,测试就会相当稳定。

4.1. 单元测试中 mock 用得太多时

在写单元测试时,如果出现:

  • 需要 7 个 mock
  • setup 很长
  • arrange 比测试主体还长
  • 看不出想确认什么

大致是以下两种情况之一:

  1. 这个类的职责过多
  2. 本应由集成测试确认的配线,被硬塞进了单元测试

mock 是用来切断外部世界的工具,而不是用来证明与真实对象的连接正确的工具。 搞混这一点,很容易出现「全部 green,却在正式环境挂掉」的情况。

5. 应提升到集成测试的 4 个边界

应该提升到集成测试的地方,大致可以整理成格式、配线、环境、时间这 4 个边界。

5.1. 格式的边界

这里所说的格式,包括下面这些内容。

  • JSON / XML / CSV
  • 数据库的 schema 与 mapping
  • nullable / precision / timezone
  • enum 或日期的序列化
  • 字符编码或 BOM
  • 换行符

Martin Fowler 也把包含 serialize / deserialize 的边界列为集成测试的候选对象。详情可参考 The Practical Test Pyramid

比如:

  • DTO 转成 JSON 后字段名不对
  • CSV 的引号或换行被破坏
  • decimal 被四舍五入
  • 数据库中 DateTimeOffset 的处理出现偏差
  • null 与空字符串的处理与预期不同

这类问题,只靠单元测试很容易被漏掉。

5.2. 配线的边界

属于配线边界的,比如下面这些部分。

  • DI 注册
  • 配置的 bind
  • 路由
  • 模型绑定
  • 过滤器
  • middleware
  • host 的启动
  • 事件配线
  • WPF 的 Binding 或 command 连接

这里的主题不是「自己的函数是否正确」,而是多个真实组件是否正确连接

在 ASP.NET Core 中,官方的划分是:controller action 的单元测试收窄到 action 的判断,routingmodel bindingfilters 交给集成测试一侧确认。 即使不是 Web,思路也是一样的:桌面应用中,ViewModel 的状态迁移归单元测试,而实际的 XAML Binding 或包含 Dispatcher 的行为则偏向集成测试。

5.3. 环境的边界

在 Windows 开发中,这里相当重要。

  • 文件权限
  • 共享文件夹
  • 文件锁
  • 从临时文件的 rename
  • 管理员权限
  • 服务启动权限
  • COM 注册
  • 32bit / 64bit
  • STA / MTA
  • DLL 的加载来源

这里的主角是操作系统或运行环境的条件本身。 内存中的 fake 会让意义大幅丢失,因此用集成测试把关会更安全。

尤其是在包含既有 Windows 软件或 COM / ActiveX 的构成中,往往会先在注册、位数、线程模型、权限上摔跟头,而不是逻辑本身。 这类失败属于集成测试(而不是单元测试)能够拾取的领域,因为它包含了环境因素。

5.4. 时间的边界

另一个容易被忽略的,是时间与并发。

  • timeout
  • cancellation
  • retry 的实际行为
  • timer 驱动
  • 后台处理的停止
  • race condition
  • shutdown 时的结束顺序

这里重要的是,把判断与实际行为分开

比如:

  • 最多重试几次
  • 哪些异常算作重试对象

这些用单元测试就足够了。 另一方面:

  • timeout 是否真的生效
  • cancellation 是否会传播
  • timer 与异步处理碰撞时是否会出问题
  • 结束时句柄或任务是否干净地关闭

这些则偏向集成测试。

6. 常见的判断失误

6.1. 把 Repository mock 掉就以为万事大吉

即使把 Repository 相关全部用 mock 通过,

  • SQL 是否正确
  • transaction 是否生效
  • 是否与 schema 一致
  • mapping 是否有偏差
  • 字符编码或 precision 是否被破坏

这些都无法确认。

Repository 更多时候不是逻辑的测试对象,而是边界的连接点。 这种情况下,与单元测试相比,提高集成测试的比重会更符合实际情况。

6.2. 在 Controller / Endpoint 的单元测试中试图连框架也一起确认

controller action 的单元测试想看的,大致是:

  • 条件分支
  • 返回值的选择
  • 依赖服务的调用分支

这些内容。

另一方面:

  • route 是否命中
  • model binding 是否通过
  • filter 是否生效
  • 经过 middleware 之后结果如何

这些属于集成测试一侧。 把这两者混在一起,就很难看出到底是什么坏了。

6.3. 在集成测试中穷举输入模式

集成测试因为更接近真实情况,难免会变慢。 所以,分支的穷举交给单元测试,边界的代表性用例交给集成测试,这样分工会更划算。

Microsoft Learn 关于集成测试的说明中也提到,针对数据库或文件系统,不应该用集成测试跑遍所有模式,而应该收窄到 read / write / update / delete 这类具有代表性的场景

6.4. 从 CI 直接调用外部服务的正式环境

这一点最好避免。

集成测试确实要重视「真实感」,但这不代表每次都必须调用正式的 SaaS 或正式 API。 Fowler 也建议,外部服务可以在本地搭建、放置 fake,或者使用专用的 test instance。

在实务中,下面这些组合比较容易处理。

  • 本地数据库
  • 临时目录
  • test host
  • 专用的 test environment
  • 契约固定的 fake service

7. 实务中推荐的结构

比例没有绝对的正确答案。 不过,相当通用的是下面这 3 层。

主力 放置什么
核心层 单元测试厚厚覆盖 业务规则、状态迁移、输入校验、错误分类
边界层 放置范围较窄的集成测试 数据库、文件、HTTP、serializer、DI、配置、COM、权限
整体层 少量的 smoke / E2E 启动确认、主要流程、重大故障的复发防止

从感觉上说,用数量堆厚的是单元测试,用边界的密度堆厚的是集成测试

推荐的推进方式是这样的。

  1. 先列出应用的边界
  2. 把逻辑整理成可以与外部世界切断的形式
  3. 针对每个边界,放置「至少一条 happy path」与「具有代表性的 failure path」
  4. 整体的全流程确认,数量收窄
  5. 出现 bug 时,把测试加到能以最低成本复现该 bug 的层级

最后这第 5 点很重要。

  • 如果是规则的错误,就补充单元测试
  • 如果是 SQL / binding / 配置 / 权限 / 注册的错误,就补充集成测试
  • 如果是包含启动或发布的故障,就补充 smoke 或 E2E

按这样的方式增加测试,测试的职责就不容易摇摆。

8. 犹豫时最后要看的 5 个问题

最后,把犹豫时用来检查的内容汇总成 5 个问题。

  1. 用内存中的 fake 替换之后,想确认的意义是否还存在
    • 如果还存在,偏向单元测试。
  2. 出问题时怀疑的对象,是不是逻辑,而是连接或配置
    • 如果是,偏向集成测试。
  3. 数据库 / 文件 / serializer / DI / route / model binding / 操作系统 / 权限 / 位数 / 线程 是不是主题
    • 如果是,偏向集成测试。
  4. 是否想高速回归大量的输入模式
    • 如果是,偏向单元测试。
  5. 那个测试失败时,能否立刻看出该修哪里
    • 如果看不出,说明测试的层级混在了一起。

用这 5 个问题梳理一遍,就能避免「感觉比较接近真实所以是集成测试」「感觉比较快所以是单元测试」这类粗略的判断方式。

9. 总结

单元测试与集成测试的边界,与代码的存放位置无关,按想减少哪种不确定性来决定,是最实用的方式。

要点归结为下面这 5 点。

  • 单元测试是判断的测试
  • 集成测试是连接的测试
  • 分支的穷举交给单元测试
  • 格式、配线、环境、时间归集成测试
  • 整体的全流程确认,用少量的 smoke / E2E 把关

最应该避免的,是下面这 3 件事。

  • 用 mock 就自以为证明了与真实对象的连接
  • 试图在集成测试中跑遍所有分支
  • 把单元测试与集成测试的职责混在一起

如果犹豫,请先看清楚:该故障是「判断」出了问题,还是「连接」出了问题。 仅凭这一个问题,多数情况就能整理清楚。

10. 相关文章

11. 参考资料

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

常见问题

汇总了咨询这一主题时常见的问题。

单元测试与集成测试该怎么区分使用?
一句话来说,单元测试是「判断的测试」,集成测试是「连接的测试」。金额计算、状态迁移、输入校验、异常分类这类无需外部资源就能完结意义的内容,应偏向单元测试;SQL 的执行、JSON/CSV 的序列化、路由、DI 注册、文件锁、权限、COM 注册、32bit/64bit 这类「一旦连接起来就会背叛你」的内容,则应放到集成测试一侧。如果两种测试都能验证,优先选单元测试。
哪些内容应该提升到集成测试?
大致可以整理成格式、配线、环境、时间这 4 个边界。格式包括 JSON/CSV 或数据库的 mapping、字符编码等;配线包括 DI 注册、路由、模型绑定等实际组件的连接;环境包括文件权限、COM 注册、32bit/64bit、STA/MTA 等操作系统的实际行为;时间包括 timeout、cancellation、race condition 等。这些内容用内存中的 fake 会丢失意义,因此用集成测试来把关会更安全。
单元测试中 mock 用得太多会有什么问题?
如果出现需要 7 个 mock、setup 很长、看不出想确认什么这样的状态,通常是以下两种情况之一:这个类的职责过多,或者是本应由集成测试确认的配线被硬塞进了单元测试。mock 是用来切断外部世界的工具,而不是用来证明与真实对象连接正确的工具。搞混这一点,很容易出现「全部 green,却在正式环境挂掉」的情况。
测试应该以什么样的结构和比例来持有?
通用性较高的是三层结构。核心层用单元测试厚厚地覆盖业务规则和状态迁移;边界层针对数据库、文件、serializer、DI 等设置范围较窄的集成测试;整体层用少量的启动确认或主要流程的 smoke / E2E 来把关。分支的穷举交给单元测试来回归,集成测试则针对每个边界,至少收敛到一条 happy path 与具有代表性的 failure path。出现 bug 时,把测试加到能以最低成本复现该 bug 的层级上。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表