
说实话在学校的时候“写测试这三个字对我来说约等于写实验报告”——随便assert(112)一下截图交差收工。直到实习第二周mentor 丢给我一句话“这个物联网项目测试体系从零搭你来。”我当时愣住了。不是不会写Test而是从零搭一套体系这个活儿跟写几个测试类完全是两个物种。就好比你以为自己会炒蛋了结果老板说你来管整个餐厅的出餐流程。这篇文章记的就是我从人傻了到居然真的搭出来了这整段经历。接手打开 pom.xml 的那一刻第一天我先摸清家底。打开项目的pom.xml第一眼就看到了这行skipTeststrue/skipTests默认跳过测试。我心想行吧至少很诚实——“我们不跑测试”写在脸上了。然后我扫了一遍src/test/java目录发现里面零零散散有一些测试文件但画风很魔幻JUnit 4 和 JUnit 5 混着用有的文件里写着org.junit.Test有的写着org.junit.jupiter.api.Test就像一条街上既有用人民币的也有用美元的大家各花各的没人觉得有问题。更离谱的是测试依赖的作用域——JUnit 和 Mockito 被声明成了compile作用域。这意味着什么这些测试库会被打进最终的生产包里。用户下载的 jar 里躺着一堆跟业务毫无关系的测试工具类。我整理了一份清单把现状摊在纸上我看到的什么感受skipTeststrue默认跳过项目直接宣布测试与我无关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验证结果三段之间用空行隔开。TestDisplayName(根据合法ID查询用户返回对应用户信息)voidgetById_ValidUserId_ReturnsUser(){// Arrange SysUserexpectedUsernewSysUser();expectedUser.setUserId(1L);expectedUser.setUserName(zhangsan);when(userMapper.selectById(1L)).thenReturn(expectedUser);// Act SysUseractualUseruserService.getById(1L);// Assert assertThat(actualUser).isNotNull();assertThat(actualUser.getUserId()).isEqualTo(1L);verify(userMapper).selectById(1L);}这个模式看着简单但写多了你会发现一旦哪段混进去了不该有的东西——比如在 Assert 里偷偷构造了新数据或者一个方法里塞了两个 Act——测试就变得很难调试。AAA 不只是格式它是一种每个测试只验证一件事的纪律。第四Mock 的边界。这条规则只有一句话只 Mock 直接依赖不要穿透。// ✅ 正确Service 直接依赖 Mapper只 Mock MapperMockprivateSysUserMapperuserMapper;// ❌ 错误穿透到 Mapper 内部的 DataSourceMockprivateDataSourcedataSource;MockprivateSqlSessionsqlSession;mentor 当时问我你知道 Mock 太多会怎样吗我摇头。他说“你的测试就不再是’单元测试’了它变成了’测试你自己写的 Mock 框架能不能正常工作’。”基础设施改造把路修好再开车规范写完之后我开始动手改基础设施。这部分没有什么花哨的技术但每一步都是从 0 到 1。改造项改之前改之后skipTeststrue默认不跑删掉默认执行测试依赖作用域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 绑到线程上下文里BeforeEachvoidsetUp(){mockMvcMockMvcBuilders.standaloneSetup(loginController).build();// 手动绑定 Shiro 上下文mockSubjectmock(Subject.class);SecurityManagermockSMmock(SecurityManager.class);ThreadContext.bind(mockSM);ThreadContext.bind(mockSubject);}AfterEachvoidtearDown(){// 必须清理否则测试之间会互相污染ThreadContext.unbindSubject();ThreadContext.unbindSecurityManager();}AfterEach的清理是关键。我第一次写的时候忘了 tearDown结果测试 A 绑定的 Mock Subject 泄漏到了测试 BB 跑出来的结果完全不对。排查了半小时才意识到是测试隔离的问题。搞定 Shiro 之后我用 MockMvc 给登录接口写了一套完整的场景矩阵场景怎么触发预期返回登录成功授权码密码均正确code: 0密码错误Mock 抛IncorrectCredentialsExceptioncode: 301, msg: 用户名或密码错误账号不存在Mock 抛UnknownAccountExceptioncode: 301, msg: 用户不存在账号禁用Mock 抛DisabledAccountExceptioncode: 301, msg: 账号已被禁用授权码无效verifyLicense()返回 falsecode: 301, msg: 非授权用户重复提交verifyIdempotent()返回 truecode: 301, msg: 请勿重复提交密码超限Mock 抛ExcessiveAttemptsExceptioncode: 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算是交差。但在企业里测试是一套体系从规范到基础设施到代码到文档是一个完整的闭环。我以前觉得写规范很虚不如写代码实在。但这次经历让我明白规范的价值不在于规范本身而在于它让可维护变成了一件可操作的事。没有规范的时候每个人按自己的习惯写测试半年后项目里就是七八种风格并存新人根本不知道从何下手。还有一件事让我挺有成就感的我做的这些事情不是帮项目写了几个测试而是让项目拥有了持续写测试的能力。前者是鱼后者是渔。说实话刚接到任务的时候我是真的人傻了。但两周后回头看人傻了其实是好事——正因为傻了才会认真对待才会一步一步把地基打牢。如果也有同学即将面临类似的实习任务我想说别慌先摸底再规范再动手。顺序对了事情就顺了。