单元测试的逻辑
有,而且很重要。单元测试虽然看起来是"每个类自己写",但其实有一套很稳定的通用逻辑。
最常见的套路就是这四步:
准备输入
准备依赖的假行为
执行被测试代码
断言结果和交互
很多地方会把它叫做 Arrange / Act / Assert,也就是:
•
Arrange:准备数据、准备 mock
•
Act:调用被测试方法
•
Assert:检查结果
拿你这个测试举例:
TencentMailClientFacade facade = mock(TencentMailClientFacade.class);
EmailNotificationSender sender = new EmailNotificationSender(facade);
这是在准备测试环境。
NotificationRequest request = new NotificationRequest();
...
when(facade.sendMail(request)).thenReturn("mail-msg-1");
这是在准备输入和依赖行为。
NotificationResponse response = sender.send(request);
这是执行被测方法。
verify(facade).sendMail(request);
assertEquals(...);
这是断言。
除了这个总套路,单元测试还有几个通用原则:
- 只测一个明确行为 不要一个测试里既测成功、又测失败、又测边界。
一个测试最好只回答一个问题。
比如:
•
shouldReturnSentResponseWhenMailFacadeSucceeds
•
shouldGenerateMessageIdWhenFacadeReturnsNull
•
shouldPropagateExceptionWhenFacadeFails
这样失败时你一眼就知道哪类行为坏了。 - 依赖要可控 单元测试里,数据库、Redis、MQ、HTTP、SMTP 这类外部依赖一般都 mock。
不然测试会:
•
慢
•
不稳定
•
受环境影响
•
很难定位问题 - 断言两类东西 单元测试通常断言两种内容:
•
状态结果
◦
返回值对不对
◦
字段对不对
◦
抛没抛异常
•
交互行为
◦
有没有调用依赖
◦
调用了几次
◦
参数是不是对的
你现在这个测试两种都断言了:
•
assertEquals(...) 是状态断言
•
verify(facade)... 是交互断言 - 覆盖正常路径、异常路径、边界路径 一个成熟的单元测试通常不会只测 happy path。
一般会补三类:
•
正常路径
◦
依赖成功,返回正常结果
•
异常路径
◦
依赖抛异常时,你的代码怎么处理
•
边界路径
◦
空值、默认值、特殊值、超长值等
比如你这个 EmailNotificationSender,更完整一点至少会有:
•
发送成功时返回正确响应
•
facade 返回 null 时自动生成 messageId
•
facade 抛异常时异常向上抛出 - 测"自己的逻辑",不要测框架 单元测试的重点不是测 Spring、Mockito、JDK,而是测你自己写的逻辑。
比如:
•
Spring 能不能注入 bean,这不是单元测试重点
•
SMTP 能不能连通,这也不是单元测试重点
•
你对结果的封装逻辑对不对,这才是重点 - 可重复、可快速执行 好的单元测试应该:
•
本地随时能跑
•
多次运行结果一致
•
不依赖外网
•
几秒内结束
这也是为什么 mock 很常见。
所以你可以把"单元测试的通用逻辑"总结成一句模板:
给定一组输入和一组可控依赖,
执行被测方法,
断言返回结果、异常和依赖交互是否符合预期。
如果再进一步落到你项目里,很多 service / facade / util 类的单元测试都可以套这个模版:
// Arrange
准备 mock
准备输入
预设依赖返回
// Act
调用被测方法
// Assert
断言返回值
断言异常
断言依赖调用
如果你愿意,我可以下一步直接拿你这个通知模块,给你列一个"单元测试清单",告诉你:
•
哪些类适合写单元测试
•
每个类至少该覆盖哪几种 case
•
哪些不该写成单元测试而应该写成集成测试