说实话,在学校的时候,“写测试"这三个字对我来说约等于"写实验报告”——随便 assert(1+1==2) 一下,截图交差,收工。

直到实习第二周,mentor 丢给我一句话:“这个物联网项目,测试体系从零搭,你来。”

我当时愣住了。

不是不会写 @Test,而是"从零搭一套体系"这个活儿,跟"写几个测试类"完全是两个物种。就好比你以为自己会炒蛋了,结果老板说"你来管整个餐厅的出餐流程"。

这篇文章,记的就是我从"人傻了"到"居然真的搭出来了"这整段经历。


接手:打开 pom.xml 的那一刻

第一天,我先摸清家底。

打开项目的 pom.xml,第一眼就看到了这行:

<skipTests>true</skipTests>

默认跳过测试。

我心想,行吧,至少很诚实——“我们不跑测试”,写在脸上了。

然后我扫了一遍 src/test/java 目录,发现里面零零散散有一些测试文件,但画风很魔幻:JUnit 4 和 JUnit 5 混着用,有的文件里写着 org.junit.Test,有的写着 org.junit.jupiter.api.Test,就像一条街上既有用人民币的也有用美元的,大家各花各的,没人觉得有问题。

更离谱的是测试依赖的作用域——JUnit 和 Mockito 被声明成了 compile 作用域。这意味着什么?这些测试库会被打进最终的生产包里。用户下载的 jar 里,躺着一堆跟业务毫无关系的测试工具类。

我整理了一份清单,把现状摊在纸上:

我看到的 什么感受
skipTests=true 默认跳过 项目直接宣布"测试与我无关"
JUnit 4 + JUnit 5 混用 像左脚踏右脚,走路全靠缘分
测试依赖 compile 作用域 测试代码污染生产包
没有 @Tag 分类 单测、集成测试、接口测试一锅炖
没有覆盖率工具 盲区有多大?不知道,也不敢问
39 个测试文件,6 个根本不是测试 混进了奇怪的东西

我把这份清单发给 mentor,他回了个表情:👍

然后说:“所以你第一件要写的事,不是测试代码,是规范。”


规范先行:测试的"交通规则"

在学校写代码,我向来是想到哪写到哪。但 mentor 说了一句话让我印象很深:

“规范不是束缚,是让后面来的人不用猜。你写完之后,任何一个新实习生打开文件,30 秒内知道该怎么写下一个测试——这就叫规范到位了。”

于是我开始写第一份文档:《单元测试编写规范》。

这份文档不是我从脑子里空想出来的,而是我先把项目里已有的 39 个测试文件全读了一遍,把踩过的坑、不一致的地方、能复用的模式全部提炼出来,再结合 JUnit 5 和 Mockito 的最佳实践,整理成一套统一标准。

几个核心要点:

第一,测试分类。 我定义了两个自定义注解 @UnitTest@IntegrationTest,替代裸写的 @Tag("xxx")。为什么?因为裸写 Tag 太容易出错了——你写 @Tag("uni"),他写 @Tag("Unit"),CI 一跑,谁都筛不出来。自定义注解把这件事变成了编译期约束:

// 项目预定义的注解,不允许自由发挥
@UnitTest                    // 纯单测,Mockito 隔离,秒级跑完
@IntegrationTest             // 集成测试,需要 DB/Redis/MQ

第二,命名规范。 这个我纠结了很久。一开始我想用英文驼峰,比如 testGetUserByIdReturnsUser(),但 mentor 说:“你写给自己看的吗?半年后你再看这个名字,知道它测的是什么边界条件吗?”

最后定下来的格式是 被测方法名_输入条件_预期结果,三段式,用下划线隔开:

// 一眼看出:查用户、传null、抛异常
getUserById_NullUserId_ThrowsIllegalArgumentException

// 反面教材——你猜它测的啥?
testGetUser()                // ❌ 看了跟没看一样
test1()                      // ❌ 直接想删

而且每个方法必须加 @DisplayName,用中文写清楚场景。英文命名是给编译器看的,中文描述是给人看的,两个都不能少。

第三,AAA 模式。 每个测试方法强制三段式——Arrange(准备数据)、Act(执行被测方法)、Assert(验证结果),三段之间用空行隔开。

@Test
@DisplayName("根据合法ID查询用户,返回对应用户信息")
void getById_ValidUserId_ReturnsUser() {
    // ===== Arrange =====
    SysUser expectedUser = new SysUser();
    expectedUser.setUserId(1L);
    expectedUser.setUserName("zhangsan");
    when(userMapper.selectById(1L)).thenReturn(expectedUser);

    // ===== Act =====
    SysUser actualUser = userService.getById(1L);

    // ===== Assert =====
    assertThat(actualUser).isNotNull();
    assertThat(actualUser.getUserId()).isEqualTo(1L);
    verify(userMapper).selectById(1L);
}

这个模式看着简单,但写多了你会发现,一旦哪段混进去了不该有的东西——比如在 Assert 里偷偷构造了新数据,或者一个方法里塞了两个 Act——测试就变得很难调试。AAA 不只是格式,它是一种"每个测试只验证一件事"的纪律。

第四,Mock 的边界。 这条规则只有一句话:只 Mock 直接依赖,不要穿透。

// ✅ 正确:Service 直接依赖 Mapper,只 Mock Mapper
@Mock
private SysUserMapper userMapper;

// ❌ 错误:穿透到 Mapper 内部的 DataSource
@Mock
private DataSource dataSource;
@Mock
private SqlSession sqlSession;

mentor 当时问我:"你知道 Mock 太多会怎样吗?"我摇头。他说:“你的测试就不再是’单元测试’了,它变成了’测试你自己写的 Mock 框架能不能正常工作’。”


基础设施改造:把路修好再开车

规范写完之后,我开始动手改基础设施。这部分没有什么花哨的技术,但每一步都是"从 0 到 1"。

改造项 改之前 改之后
skipTests true,默认不跑 删掉,默认执行
测试依赖作用域 compile,污染生产包 test,只在测试阶段引入
测试框架 JUnit 4/5 混用 统一 JUnit 5
执行命令 mvn test 啥也不跑 mvn test 跑单测,mvn verify 跑集成
覆盖率 完全没有 接入 Jacoco,生成 HTML 报告

这里有个小插曲。改 pom.xml 依赖作用域的时候,我不小心把 spring-boot-starter-test 的作用域改成了 test,结果跑测试的时候一堆类找不到。排查了半小时才发现,原来有个测试类被放在了 src/main/java 下面……

这种坑,文档里不会写,但踩一次就记住了。


接口测试:MockMvc 的 standalone 模式

基础设施搞定之后,重头戏来了——写接口测试。

项目用的是 Apache Shiro 做权限控制,Controller 里面到处是 ShiroUtils.getUserId()@RequiresPermissions。一开始我想用 @SpringBootTest 启动完整容器来测,结果发现启动一次要十几秒,而且还要连数据库。

mentor 建议我用 standalone 模式。

“standalone 的核心思想就一句话:我只关心 Controller 这一层的逻辑对不对,Service 层的行为用 Mock 替掉。不启动 Spring 容器,不连数据库,跑一个测试只要零点几秒。”

我理解了。standalone 模式就像做手术——把 Controller 单独拿出来,周围用 Mock 搭一个假的运行环境,只验证"这个 Controller 收到这个请求、调了这个 Service、应该返回什么"。

但 Shiro 怎么办?Controller 里调了 SecurityUtils.getSubject(),standalone 模式下没有 Shiro 环境,直接 NPE。

这个问题我卡了挺久。后来在 Spring Test 的文档里翻到了 ThreadContext 的用法——手动把 Mock 的 Subject 和 SecurityManager 绑到线程上下文里:

@BeforeEach
void setUp() {
    mockMvc = MockMvcBuilders.standaloneSetup(loginController).build();
    
    // 手动绑定 Shiro 上下文
    mockSubject = mock(Subject.class);
    SecurityManager mockSM = mock(SecurityManager.class);
    ThreadContext.bind(mockSM);
    ThreadContext.bind(mockSubject);
}

@AfterEach
void tearDown() {
    // 必须清理,否则测试之间会互相污染
    ThreadContext.unbindSubject();
    ThreadContext.unbindSecurityManager();
}

@AfterEach 的清理是关键。我第一次写的时候忘了 tearDown,结果测试 A 绑定的 Mock Subject 泄漏到了测试 B,B 跑出来的结果完全不对。排查了半小时才意识到是测试隔离的问题。

搞定 Shiro 之后,我用 MockMvc 给登录接口写了一套完整的场景矩阵:

场景 怎么触发 预期返回
登录成功 授权码+密码均正确 code: 0
密码错误 Mock 抛 IncorrectCredentialsException code: 301, msg: 用户名或密码错误
账号不存在 Mock 抛 UnknownAccountException code: 301, msg: 用户不存在
账号禁用 Mock 抛 DisabledAccountException code: 301, msg: 账号已被禁用
授权码无效 verifyLicense() 返回 false code: 301, msg: 非授权用户
重复提交 verifyIdempotent() 返回 true code: 301, msg: 请勿重复提交
密码超限 Mock 抛 ExcessiveAttemptsException code: 301, msg: 密码重试次数过多

7 个场景,覆盖了正常路径和各种异常分支。写完跑一遍,全绿。

说实话,那一刻还是有点爽的。


摸底盘点:39 个文件的"考古"

测试规范写完、接口测试跑通之后,我做了一件可能有点"强迫症"的事——把项目里全部 39 个测试文件逐个读了一遍,整理了一份完整的测试清单。

为什么要做这件事?因为你不知道项目里有什么,就不知道该补什么。

这份清单让我发现了一些有意思的事情:

JUnit 5 的测试类有 30 个,JUnit 4 的还剩 3 个,迁移率已经到 90% 了。但所有测试类都没有用 @Tag 注解,意味着 CI 根本没法按类型筛选执行。@DisplayName 也只有 15 个类加了,剩下 18 个类的测试方法名就是 testXxx() 这种。

还有 6 个文件根本不是测试——有代码生成器、MQTT 演示程序、手动运行的 Main 方法,全混在 src/test 目录里。

我把这些发现整理成一张表,标注了每个文件的状态和需要改动的项,交给了 mentor。他看了一眼说:“这个清单本身,比你写的 100 行测试代码更有价值。”

我当时不太理解,后来才想明白——代码可以慢慢写,但"知道项目现在在哪"这件事,是所有后续工作的前提。


最终交付:两周后项目变成了什么样

两周时间,说长不长,说短不短。最终交付的东西,我自己回头看,也觉得挺不可思议的。

基础设施层面:pom.xml 修好了,Jacoco 接入了,mvn test 能跑了,CI 可以按 Tag 筛选执行了。

测试代码层面:大约 75 个测试用例,分布在 common 工具类、framework 框架层、system 系统模块、接口测试等各个层级。

测试类型 数量 干什么的
迁移+整理的存量测试 ~32 个 把原来散落的测试归位
common 工具类单测 ~15 个 字节工具、CSV 解析、加解密等
framework 单测 ~5 个 Shiro 密码校验、Realm 认证
system Service 单测 ~10 个 用户服务、配置参数等
接口测试演示 2-3 个 登录接口、设备管理接口
其他模块 ~10 个 通信模块、Socket 协议等

文档层面:5 份文档,从"怎么搭环境"到"怎么写单测"到"怎么写接口测试",一条龙。

文档 给谁看的
《测试环境搭建指南》 新人入职第一步
《单测编写规范》 日常写测试的"交通法规"
《接口测试编写模板》 Controller 测试照抄
《黑盒用例设计文档》 手动验证的功能清单
《黑盒测试报告》 第一轮全量点击的问题记录

mentor 在验收的时候说了一句话:“你做的不只是写测试,是给这个项目建了一套’测试基础设施’。后面不管谁来,按这套规范写就行。”


几个我踩过的坑,分享给你

写到这里,想单独拎几个坑出来说。因为这些都是文档里不会写、但实际做的时候会结结实实摔跟头的地方。

坑一:Java 8 的版本锁定。 项目基于 JDK 1.8,但 Mockito 5.x 和 JUnit 5.11+ 都要求 Java 11+。如果直接去 Maven 仓库拉最新版,编译直接炸。我最后锁定的版本是 JUnit 5.6.3 + Mockito 3.6.28 + AssertJ 3.18.1,都是 Java 8 兼容的最后稳定版。

坑二:standalone 模式测不了权限注解。 @RequiresPermissions 需要 ShiroFilter 才能生效,而 standalone 模式不加载 Filter 链。所以如果你要测权限拦截,要么用 @SpringBootTest 集成测试,要么在 standalone 里手动配置 ShiroFilterFactoryBean——但后者很麻烦,不如直接归到集成测试里。

坑三:String.repeat() 在 Java 8 里不存在。 我想用 "a".repeat(10) 生成测试字符串,结果编译报错。String.repeat() 是 Java 11 才加的。替代方案是 new String(new char[10]) 然后 Arrays.fill()

坑四:Mock 链不完整导致"测了个寂寞"。 有一次我写了 when(service.query()).thenReturn(result),但实际被测方法里调的是 service.query(param),参数对不上,Mock 没命中,返回了 null。测试居然还通过了——因为我断言的是 assertNotNull(result),而 Controller 层把 null 包成了 AjaxResult.success(null) 返回……所以 Mock 的参数匹配一定要仔细。


最后说两句

回头看这两周,最大的感触是——在学校里,"测试"是一个附属品,代码写完了,随手补两个 assert,算是交差。但在企业里,测试是一套体系,从规范到基础设施到代码到文档,是一个完整的闭环。

我以前觉得"写规范"很虚,不如写代码实在。但这次经历让我明白,规范的价值不在于规范本身,而在于它让"可维护"变成了一件可操作的事。没有规范的时候,每个人按自己的习惯写测试,半年后项目里就是七八种风格并存,新人根本不知道从何下手。

还有一件事让我挺有成就感的:我做的这些事情,不是"帮项目写了几个测试",而是"让项目拥有了持续写测试的能力"。前者是鱼,后者是渔。

说实话,刚接到任务的时候我是真的人傻了。但两周后回头看,"人傻了"其实是好事——正因为傻了,才会认真对待,才会一步一步把地基打牢。

如果也有同学即将面临类似的实习任务,我想说:别慌,先摸底,再规范,再动手。顺序对了,事情就顺了。

更多推荐