从一次线上事故说起
上周三下午,公司客服突然炸锅,用户接连反馈下单失败。排查半天,发现是支付回调的一个判空逻辑写反了。问题代码就一行,但没覆盖到测试用例。最后翻出提交记录,发现是新来的同事改完功能后,本地跑通就直接提了 PR。这件事让我又重新翻了一遍我们团队的源码测试方法。
单元测试不是形式主义
很多人觉得写单元测试浪费时间,尤其是赶项目的时候。但实际经验告诉我,越是急的项目,越要写测试。比如我们有个订单状态机,十几种状态流转,靠人工点测根本记不住所有路径。后来我把每种 transition 都写成单元测试,每次改动一跑,立刻知道有没有副作用。
以 Java + JUnit 为例,一个简单的服务类测试长这样:
public class OrderServiceTest {
private OrderService service;
@BeforeEach
void setUp() {
service = new OrderService();
}
@Test
void shouldReturnTrueWhenOrderIsPayable() {
Order order = new Order();
order.setStatus("created");
order.setAmount(100L);
boolean result = service.isPayable(order);
assertTrue(result);
}
}
覆盖率不是万能,但没它是真不行
我们用 Jacoco 统计测试覆盖率,要求核心模块不低于 75%。刚开始大家为了凑数字,写一堆 useless 的 assert。后来调整策略,只关注关键路径和边界条件。比如金额为 0、状态为空、用户未登录这些 case,必须覆盖。
集成测试抓隐藏更深的问题
单元测试过不代表就能上线。数据库连接、外部接口超时、缓存失效这些问题,得靠集成测试暴露。我们有个定时任务模块,本地单测全绿,上了预发环境却频繁报错。一查是 Redis 连接池配置没加载,这种问题不走完整链路根本发现不了。
Spring Boot 项目里,可以用 @SpringBootTest 启动最小上下文:
@SpringBootTest(classes = TaskSchedulerConfig.class)
class TaskExecutionIT {
@Autowired
private TaskExecutor executor;
@Test
void shouldProcessTaskSuccessfully() throws Exception {
Task task = new Task("send-email", "user@demo.com");
executor.submit(task);
Thread.sleep(2000); // 等待异步执行
assertThat(emailService.hasSentTo("user@demo.com")).isTrue();
}
}
别忘了手动模拟异常场景
正常流程跑通容易,异常恢复才是难点。我们会在测试环境手动断开数据库、杀掉服务进程、修改系统时间,看看程序能不能自动恢复或正确降级。这类操作没法完全自动化,但每月至少做一次,防止“只在理想环境运行”的代码上线。
PR 前先过本地钩子
现在我们每个项目都配了 Git Hook,提交前自动跑一遍相关测试。用 husky + lint-staged 搭配实现,改了哪个文件,就跑对应的 test 文件。虽然偶尔会被人骂“提交还要等半分钟”,但几次拦截高危提交后,没人再抱怨了。
看日志不如看测试输出
以前查问题第一反应是翻日志。现在我更喜欢先看测试输出。比如某个接口返回慢,我会先把它的集成测试拉出来跑一遍,如果测试也慢,说明可能是依赖服务问题;如果测试快,那大概率是线上环境配置不对。测试成了最轻量的诊断工具。