如何划定单元测试与集成测试的边界
· 小村 豪 · 测试, 单元测试, 集成测试, 测试设计, Windows开发, C# / .NET
在测试设计的话题里,每次都让人隐隐觉得棘手的,就是应该把多少内容塞进单元测试,又该从哪里开始提升到集成测试。
这里危险的是下面两种极端:
- 因为想跑得快,什么都写成单元测试
- 因为想更接近真实情况,什么都写成集成测试
前者容易堆满 mock,忽略了正式环境中会出问题的关键点;后者则容易变成又慢又容易失败的一堆测试。 在实务中,需要关注的坐标轴其实更清晰一些。
- 想确认的是自己的逻辑,还是与外部的连接
- 用内存中的 fake 替换之后,意义会不会丢失
- 数据库 / 文件 / HTTP / DI / 配置 / 框架 / 操作系统的行为是不是重点
- 是否想高速回归大量的输入模式
只要看清这 4 点,单元测试与集成测试的边界就相当容易划定了。
本文以 2026 年 3 月时点可参考的 Microsoft Learn 与 Martin Fowler 的公开信息为前提,从实务角度整理单元测试与集成测试的边界。123
1. 先说结论
说得比较粗略,但是在实务中很好用的表达方式是这样的。
- 纯逻辑归单元测试
- 连接・配线・转换・环境差异归集成测试
- 两种都能验证时,优先选单元测试
- 集成测试不要求大而全,而要把边界收窄
一句话来说,单元测试是「判断的测试」,集成测试是「连接的测试」。
金额计算、状态迁移、输入校验、审批条件、异常分类这类无需外部资源就能完结意义的内容,偏向单元测试会更快、更不容易出问题,也能更充分地回归输入模式。 另一方面,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 本体的判断,而 routing、model binding、filters 这类框架侧的相互作用则交给集成测试处理。详情可参考 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 比测试主体还长
- 看不出想确认什么
大致是以下两种情况之一:
- 这个类的职责过多
- 本应由集成测试确认的配线,被硬塞进了单元测试
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 的判断,routing 或 model binding、filters 交给集成测试一侧确认。
即使不是 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 | 启动确认、主要流程、重大故障的复发防止 |
从感觉上说,用数量堆厚的是单元测试,用边界的密度堆厚的是集成测试。
推荐的推进方式是这样的。
- 先列出应用的边界
- 把逻辑整理成可以与外部世界切断的形式
- 针对每个边界,放置「至少一条 happy path」与「具有代表性的 failure path」
- 整体的全流程确认,数量收窄
- 出现 bug 时,把测试加到能以最低成本复现该 bug 的层级上
最后这第 5 点很重要。
- 如果是规则的错误,就补充单元测试
- 如果是 SQL / binding / 配置 / 权限 / 注册的错误,就补充集成测试
- 如果是包含启动或发布的故障,就补充 smoke 或 E2E
按这样的方式增加测试,测试的职责就不容易摇摆。
8. 犹豫时最后要看的 5 个问题
最后,把犹豫时用来检查的内容汇总成 5 个问题。
- 用内存中的 fake 替换之后,想确认的意义是否还存在
- 如果还存在,偏向单元测试。
- 出问题时怀疑的对象,是不是逻辑,而是连接或配置
- 如果是,偏向集成测试。
- 数据库 / 文件 / serializer / DI / route / model binding / 操作系统 / 权限 / 位数 / 线程 是不是主题
- 如果是,偏向集成测试。
- 是否想高速回归大量的输入模式
- 如果是,偏向单元测试。
- 那个测试失败时,能否立刻看出该修哪里
- 如果看不出,说明测试的层级混在了一起。
用这 5 个问题梳理一遍,就能避免「感觉比较接近真实所以是集成测试」「感觉比较快所以是单元测试」这类粗略的判断方式。
9. 总结
单元测试与集成测试的边界,与代码的存放位置无关,按想减少哪种不确定性来决定,是最实用的方式。
要点归结为下面这 5 点。
- 单元测试是判断的测试
- 集成测试是连接的测试
- 分支的穷举交给单元测试
- 格式、配线、环境、时间归集成测试
- 整体的全流程确认,用少量的 smoke / E2E 把关
最应该避免的,是下面这 3 件事。
- 用 mock 就自以为证明了与真实对象的连接
- 试图在集成测试中跑遍所有分支
- 把单元测试与集成测试的职责混在一起
如果犹豫,请先看清楚:该故障是「判断」出了问题,还是「连接」出了问题。 仅凭这一个问题,多数情况就能整理清楚。
10. 相关文章
11. 参考资料
-
Microsoft Learn, Integration tests in ASP.NET Core ↩
-
Microsoft Learn, Unit testing best practices for .NET ↩
-
Martin Fowler, The Practical Test Pyramid ↩
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
用 Windows Sandbox 加快应用验证的方法
本文整理如何利用 Windows Sandbox 提升管理员权限问题排查、干净环境复现、权限不足与资源不足复现的效率,并说明 .wsb 与 CLI 的分工使用方式。
用 PerfView 与 dotnet-trace 定位“变慢”的原因 ── .NET 性能排查实务入门
当业务应用出现“变慢”“CPU 占满”“偶尔卡死”时,该用哪个工具看什么?本文梳理 PerfView 与 dotnet-trace 的分工、CPU 采样的解读方法(inclusive/exclusive)、用 ThreadTime 排查阻塞时间,以及与 EventSourc...
Windows 事件日志・ETW 入门 ── 让业务应用程序的日志接入操作系统标准机制
Windows 业务应用程序的日志,是不是只靠文件日志来解决?事件日志与 ETW,是运维人员和操作系统标准工具所能看到的另一层记录。本文整理三种手段的分工方式、.NET 中的写入方法、通过 EventSource 进行的 ETW 埋点、收集与排查的实务做法,以及来源未注册、...
在 C# 中安全调用 Win32 API —— P/Invoke 实务指南(DllImport / LibraryImport / CsWin32)
本文整理在 C# 中通过 P/Invoke 调用 Win32 API 或原生 DLL 时的实务要点:DllImport 与 LibraryImport 的区别、用 CsWin32 自动生成签名、字符串封送的陷阱、用 SafeHandle 管理句柄、SetLastError ...
业务应用的日期时间与时区 ── 从 DateTime 的陷阱到 UTC 存储原则、测试设计
服务器迁移后时间整整差了 9 个小时、只有海外分支机构的日期会变成前一天——本文从 DateTime 的 Kind 与隐式转换梳理日期时间事故的根本原因。内容涵盖与 DateTimeOffset 的取舍、UTC 存储与 ISO 8601 原则、TimeZoneInfo 与夏...
常见问题
汇总了咨询这一主题时常见的问题。
- 单元测试与集成测试该怎么区分使用?
- 一句话来说,单元测试是「判断的测试」,集成测试是「连接的测试」。金额计算、状态迁移、输入校验、异常分类这类无需外部资源就能完结意义的内容,应偏向单元测试;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 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。