
1. 从单元测试--这个标题说起先别急着写代码我一直觉得单元测试这四个字后面跟两个破折号比跟一句完整的定义更有意思。它暗示了一种状态单元测试这件事做了但没做完写了但没写透跑了但没跑对。很多团队对单元测试的态度就是这样卡在半路不上不下。没有引号的话很多人对单元测试的理解停留在给代码写测试这个层面。可一旦真正去写会发现一连串问题测试什么才算一个单元怎么让测试不依赖数据库和网络为什么JUnit和TestNG都叫测试框架用起来完全两个套路为什么Vue项目一配单元测试就报错而且每个人报的错还不一样这些问题如果不先想清楚测试代码写得越多后面维护成本越高。这篇东西就是围绕这些具体问题展开的。我会把单元测试从概念、标准、工具选型到Vue项目里的报错排查再到工程化落地按实际操作顺序过一遍。适合刚准备给项目引入单元测试的团队也适合那些已经写了几个测试但总觉得哪里不对的人。1.1 为什么标题敢留一个--单元测试的起点是未完成先说个反直觉的结论单元测试最忌讳的是一次写完。真正写单元测试的人往往会故意让测试先失败再让代码通过测试。这个红-绿-重构循环里红失败是起点不是错误。所以标题里那个--我理解成测试代码还没写完的状态而不是不知道说什么。这个心态很重要。很多新手拿到一段业务代码第一反应是我怎么测它而不是它应该有什么行为。前者是以代码实现为中心后者是以行为契约为中心。单元测试测的是行为不是实现。如果你发现测试代码写起来特别痛苦大概率不是测试的问题而是被测代码本身的设计有问题。一个方法干了太多事、隐式依赖全局状态、返回值靠System.out而不是return这些代码都很难测。难测本身就是代码坏味道的信号。所以未完成还有一层意思单元测试不是一个独立的交付物它是代码设计的镜子。你写完一个功能顺手写两个测试发现自己根本没法构造输入那就要回头改生产代码。这个反馈循环会逼着你把函数拆小、把依赖注入进来、把不可控的外部I/O隔离开。这个价值比测试本身跑过几个用例重要得多。1.2 单元测试到底在测什么我见过不少团队把单元测试写成了集成测试的简化版测一个Service方法还要连数据库、发HTTP请求、读配置文件。这不是单元测试这是给自己找麻烦。单元测试的核心约束是隔离。被测的单元只依赖内存中的对象所有外部协作对象都用测试替身stub、mock、fake替代。为什么必须隔离因为单元测试要解决的核心问题是当出现回归时你能在几分钟内定位到是哪一个类出了问题而不是面对一整条调用链无从下手。具体测什么可以分成几个层次纯逻辑层比如金额计算、状态流转、字符串处理、权限判断。这类代码没有I/O输入输出明确是单元测试收益最高的地方。对象协作层比如一个服务类调用了另一个服务类再到仓储类你需要用mock隔离掉下游只验证当前对象的行为。异常与边界空指针、集合为空、数值溢出、并发竞争。这些平时不会触发但一旦触发就是线上事故的场景是最值得写测试的。注意单元测试不负责测系统能不能跑起来那是冒烟测试和集成测试的事。也不负责测性能达不达标那是压测的事。边界划清楚测试的目标才明确。1.3 单元测试的单元到底切多小单元的大小没有绝对标准但有一个经验法则你的测试能不能不启动Spring容器、不启动Web服务器就跑起来。如果能你的单元基本合格如果需要启动容器、连数据库那就不是单元测试而是集成测试了。具体到粒度我习惯这样切分一个public方法对应一组测试方法而不是一个测试方法。一个方法可能有正常路径、边界值、异常分支这些分别写成一个test方法名字里带上场景。被测类只依赖抽象接口不依赖具体实现类这样mock才能插进去。一个测试方法里只验证一个行为。如果你在一个test方法里又断言了返回值、又验证了mock调用次数、又检查了异常类型说明这个测试的单元切大了拆开。切多小才合适一个参考标准是如果某段逻辑你会用if-else分支写超过三个分支或者嵌套超过两层就有必要把它拆成独立的方法并单独测试。分支复杂度越高越需要单元测试保护。2. 工具选型JUnit、TestNG与Vue测试框架的取舍单元测试的工具选型看着简单实际坑不少。后端Java圈JUnit和TestNG是主流前端Vue圈Jest和Vitest是主流。但很多人直接照抄网上的配置跑通就完事了完全没搞懂这些工具各自的强项和适用场景。选错框架后面写参数化测试、做并发测试、配覆盖率报告的时候会各种别扭。2.1 JUnit与TestNG的核心差异JUnit 5和TestNG都能做单元测试但设计理念不太一样。先说JUnit 5。它是目前Java生态的默认选择Spring Boot项目自带spring-boot-starter-test就包含JUnit 5。它的核心优势是跟现代Java配合得好JUnit Jupiter的扩展模型Extension让自定义扩展变得很干净比如TempDir临时目录、Timeout超时控制、ParameterizedTest参数化测试这些都是内置能力基本满足日常需求。我们项目里跑常规的业务单测JUnit 5是够用的。再说TestNG。这个框架名字里的NG是Next Generation的意思它一开始就想做比JUnit更强的测试框架。TestNG有几个独有能力是JUnit 5之前不具备的组groups可以把测试方法分组比如fastslowdatabase然后在testng.xml里灵活组合。我见过有些团队用这个特性区分冒烟测试和全量测试。依赖测试dependsOnMethods方法之间的执行顺序可控。这个特性要慎用因为单元测试本应互相独立但如果你确实有一段测试必须严格按顺序执行比如同一个类里的init和destory场景TestNG比JUnit 5好控制。并发执行TestNG可以用threadPoolSize和invocationCount直接做测试级并发这在压测某些线程安全问题上很好用。选择建议很简单没有历史包袱的Java项目直接用JUnit 5对测试分组、顺序、并发有硬性需求的考虑TestNG。如果你在维护老项目项目里已经是TestNG那也不急着迁移两个框架可以在不同module里共存没必要为统一而统一。2.2 Vue项目测试框架组合Jest Vue Test UtilsVue项目的单元测试选型很多人一搜教程就上Jest Vue Test Utils。这个组合确实经典但你要清楚它为什么是这套以及现在还有没有别的选择。Jest是Facebook出的测试框架特点是零配置、自带断言库、自带mock能力、自带覆盖率工具而且测试文件是并行执行的跑得很快。跟Vue Test Utils配合可以直接挂载一个Vue组件触发事件断言渲染结果。Vue 2时代这是唯一的主流方案。到了Vue 3 Vite时代一个更轻量的方案出现了Vitest。它跟Vite共享配置不需要像Jest那样搞一堆transform配置来兼容ES Module。如果你的项目已经是Vite驱动的Vue 3项目我个人建议优先考虑Vitest。它不是另一个Jest它底层用的就是Vite的依赖预打包和模块解析能力运行速度明显更快配置量也小一个量级。不过要注意Vue Test Utils对Vitest和Jest都支持。也就是说你换Vitest不意味着要换组件测试的API。组件挂载、find、trigger、setProps这些用法是完全一致的。换个测试运行器成本比想象中低。2.3 单元测试标准覆盖率不是唯一标准单元测试标准这个话题最常见的误区是把覆盖率当成KPI。我见过某个团队把行覆盖率要求定到90%结果测试代码里全是为了覆盖而覆盖的断言调用一下方法、不报错、就完事。这种测试除了让覆盖率数字好看对质量没有任何帮助。我理解的单元测试标准优先级是这样排的发现回归的能力。这条测试在代码重构后能直接指出哪里行为变了这是测试最核心的价值。失败提示的明确性。好的测试失败时断言信息能直接告诉你期望2但得到1而不是抛出一个NullPointerException让人去猜。执行速度。单测的执行速度应该以秒计。如果单测跑完要十分钟那就说明你把它做成集成测试了。稳定性。同样的代码跑一百次结果应该完全一致。如果有偶发失败那是最伤团队信心的。覆盖率这个指标我把它当成辅助参考而不是红线。新增代码的关键分支有没有测到比总量覆盖到多少更重要。如果一个方法有10个分支你测了9个漏掉的那个恰好是异常时的兜底逻辑那这个漏洞可能比覆盖50%但关键路径全测到的情况更危险。我会在后面单独说一遍覆盖率报告怎么看这里先记住一条覆盖率要跟分支覆盖结合起来看而且优先保证核心业务分支被覆盖。3. 手写一个单元测试的完整过程讲完工具和标准进入实战。这一节我分两条线走先写一个Java TestNG的用例再写一个Vue组件的测试用例。这两条线覆盖了后端和前端的常见场景也把前面说的如何构造被测对象如何注入依赖如何断言具体化。3.1 被测代码与测试代码的分工先说一个经常被忽略的问题测试代码放在哪里。Maven项目默认的约定是src/main/java放生产代码src/test/java放测试代码。很多人图方便把测试类放在生产目录旁边这是错的。测试代码和生产代码应该物理隔离这样打包的时候测试代码不会混进产物构建时也能单独跑test阶段。测试类的命名也有约定JUnit和TestNG统一用被测类名Test后缀比如被测类是UserService测试类就叫UserServiceTest。测试方法名不要用test开头这种啰嗦写法直接写行为比如shouldThrowExceptionWhenBalanceNotEnough一眼能看出这个测试在验证什么。中文注释可以写但方法名最好用英文避免某些编码环境出乱码。3.2 一个后端TestNG用例从零到跑通的过程假设我们要测一个账户扣款的方法。原始代码长这样public class AccountService { private final AccountRepository accountRepository; private final NotificationClient notificationClient; public AccountService(AccountRepository accountRepository, NotificationClient notificationClient) { this.accountRepository accountRepository; this.notificationClient notificationClient; } public void deduct(String accountId, BigDecimal amount) { Account account accountRepository.findById(accountId); if (account null) { throw new AccountNotFoundException(account not found: accountId); } if (account.getBalance().compareTo(amount) 0) { throw new InsufficientBalanceException(insufficient balance); } account.setBalance(account.getBalance().subtract(amount)); accountRepository.save(account); notificationClient.sendDeductNotification(accountId, amount); } }这个类的两个依赖都是接口很容易用mock替换。用TestNG的写法import org.testng.annotations.Test; import org.testng.annotations.BeforeMethod; import static org.mockito.Mockito.*; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThat; public class AccountServiceTest { private AccountRepository accountRepository; private NotificationClient notificationClient; private AccountService accountService; BeforeMethod public void setUp() { accountRepository mock(AccountRepository.class); notificationClient mock(NotificationClient.class); accountService new AccountService(accountRepository, notificationClient); } Test public void shouldDeductBalanceWhenAmountEnough() { Account account new Account(acc-001, new BigDecimal(100.00)); when(accountRepository.findById(acc-001)).thenReturn(account); accountService.deduct(acc-001, new BigDecimal(30.00)); assertThat(account.getBalance()).isEqualByComparingTo(70.00); verify(accountRepository).save(account); verify(notificationClient).sendDeductNotification(acc-001, new BigDecimal(30.00)); } Test(expectedExceptions InsufficientBalanceException.class) public void shouldThrowExceptionWhenBalanceNotEnough() { Account account new Account(acc-001, new BigDecimal(10.00)); when(accountRepository.findById(acc-001)).thenReturn(account); accountService.deduct(acc-001, new BigDecimal(30.00)); } Test public void shouldThrowExceptionWhenAccountNotFound() { when(accountRepository.findById(acc-001)).thenReturn(null); assertThatThrownBy(() - accountService.deduct(acc-001, new BigDecimal(30.00))) .isInstanceOf(AccountNotFoundException.class) .hasMessageContaining(acc-001); } }这个例子看着简单其实把单元测试的关键要素都放进来了用BeforeMethod统一初始化测试对象和mock用when...thenReturn构造场景用assertThat断言结果用verify验证协作调用用expectedExceptions或assertThatThrownBy断言异常。有个细节值得说第一个测试里我用isEqualByComparingTo而不是equals来比较BigDecimal。如果你用equalsBigDecimal(1.0)和BigDecimal(1.00)会判不等因为scale不同但业务上它们是同一个数值。用isEqualByComparingTo才能正确比较数值。这种看着对但实际错的坑就是单元测试里最容易踩的类型之一。3.3 一个前端Vue组件的单元测试怎么写前端单测和后端逻辑不太一样。后端测的是数据流转和异常分支前端组件测试侧重点在于组件在不同props和用户交互下能否正确渲染和触发事件。假设我们有这样一个Vue 3组件一个商品卡片显示名称和价格点击加入购物车按钮会触发事件template div classproduct-card h2{{ product.name }}/h2 p classprice{{ formatPrice(product.price) }}/p button clickhandleAdd加入购物车/button /div /template script setup import { computed } from vue; const props defineProps({ product: { type: Object, required: true } }); const emit defineEmits([add]); const formatPrice (price) ${price.toFixed(2)}; const handleAdd () { emit(add, props.product); }; /script搭配Vitest和Vue Test Utils测试这样写import { describe, it, expect, vi } from vitest; import { mount } from vue/test-utils; import ProductCard from ../ProductCard.vue; describe(ProductCard, () { it(正确渲染商品名称和格式化后的价格, () { const wrapper mount(ProductCard, { props: { product: { id: 1, name: 机械键盘, price: 399.5 } } }); expect(wrapper.find(h2).text()).toBe(机械键盘); expect(wrapper.find(.price).text()).toBe(399.50); }); it(点击加入购物车按钮后发出 add 事件, async () { const product { id: 2, name: 鼠标, price: 99 }; const wrapper mount(ProductCard, { props: { product } }); await wrapper.find(button).trigger(click); expect(wrapper.emitted(add)).toHaveLength(1); expect(wrapper.emitted(add)[0][0]).toEqual(product); }); });注意几点mount和shallowMount的区别shallowMount会替换掉子组件只渲染当前组件本身。如果被测组件有很多子组件shallowMount能减少干扰。但如果你的断言要检查子组件是否被正确渲染那就要用mount。我一般默认shallowMount需要测子组件交互再换成mount。trigger之后要awaitVue的DOM更新是异步的如果trigger之后直接断言可能拿到旧状态。await wrapper.find(button).trigger(click)就是为了等Vue完成一次tick。wrapper.emitted()这是Vue Test Utils提供的API用来断言组件发出的自定义事件。它返回一个以事件名为键的对象值是一个数组每个元素对应一次emit的参数列表。4. vue 单元测试报错场景拆解与排查Vue项目接入单元测试报错是常态。我见过最多的不是不会写测试而是环境都配不起来。在这一节里我把常见的报错分成几类每类给出根因和排查思路。这些坑我都踩过有些是配置问题有些是版本兼容问题有些是测试代码本身写错了。搞清楚它们的区别能给你省下大量查资料的时间。4.1 最常见的报错类型与根因我把Vue单元测试相关的报错归纳为五类大家可以对号入座报错特征根本原因解决思路Cannot find module vue测试运行器没有正确解析Vue的模块路径检查Vitest/Jest的resolve.alias是否指向vue.runtime.esm-bundler.js等正确入口document is not defined测试环境不是浏览器环境缺少DOM API引入jsdom或happy-dom环境Vitest配置environment: jsdomwindow.matchMedia is not a functionjsdom环境缺少某些浏览器API在测试setup文件中为matchMedia或ResizeObserver等API编写mock实现[Vue warn]: Failed to resolve component: xxx全局注册的组件没有在测试中注册使用global.components配置或global.plugins注册需要的插件SyntaxError: Cannot use import statement outside a moduleJest环境没有正确处理ES ModuleJest需要配置transform对.vue和.js文件做转译或改用Vitest4.2 从报错信息反推问题一个真实排查链路拿我前段时间在一个Vue 3项目里遇到的情况举例。项目用Vite要加单元测试我先装了vitest和vue/test-utils跑了一个极简测试文件报错是ReferenceError: document is not defined这个报错很典型。很多人第一反应是我哪里用到了document其实根本不是业务代码的问题而是Vitest默认的运行环境是nodenode环境里没有DOM。你要在Vitest的配置里指定jsdom环境。在vite.config.js里加import { defineConfig } from vite; import vue from vitejs/plugin-vue; export default defineConfig({ plugins: [vue()], test: { environment: jsdom, globals: true } });加了environment之后又报了另一个错TypeError: Cannot read properties of undefined (reading value)这里就要看堆栈了。它指向的是vue/test-utils内部混入的地方。这个一般是vitejs/plugin-vue和vue/test-utils的版本不匹配造成的。我在package.json里锁定版本后解决vitejs/plugin-vue要用4.x以上vue/test-utils要用2.x以上跟Vue 3.3以上版本才配套。排查链路走下来你会发现报错本身并不可怕关键是别报一个错就去改一个配置要顺着错误信息往上游找根因。凡是涉及You may need additional plugins/loadersCannot read properties of undefinedmodule not found这类错误先检查版本和模块解析配置不要用奇怪的黑科技绕过。4.3 解决测试环境与开发环境不一致的坑Vue项目跑单测最常见的隐性坑是开发环境好好的一进测试环境就报组件没有渲染出内容事件没有触发成功props不生效。这类问题往往不是测试代码错而是测试环境里缺少开发环境的一些全局组件、指令或者插件。举个例子你的main.js里用app.use(ElementPlus)注册了整个UI库组件模板里用了 。开发环境正常但测试环境mount组件时Vue Test Utils并没有自动加载ElementPlus于是组件fallthrough到未知元素断言button的文字就失败了。解决办法是在测试setup文件里做全局配置// tests/setup.js import { config } from vue/test-utils; config.global.plugins [ElementPlus];或者针对某个测试文件单独配置const wrapper mount(Component, { global: { plugins: [ElementPlus], directives: { loading: { mounted: () {} } } } });这里的关键认知是单测不是把整个应用启动起来再测而是组件 手动补齐依赖。你在测试里看到的组件是一个独立挂载的实例应用入口的全局配置它一概不知。所以最好把全局组件、指令、插件统一收敛到一个setup文件里管理而不是在每个测试文件里零散地写。还有一个细节如果你的路由组件测试涉及router-link或useRouter你得给测试传一个带router的全局plugin或者直接mock掉useRouter。这个实践里经常被忽略但一旦组件里用了路由测试就会一堆警告。5. 单元测试落地的工程化经验测试写出来了怎么让它持续发挥作用而不是写完就忘这一节讲工程化落地的事。我结合自己维护过多个中大型项目的经验把测试目录规划、覆盖率门槛、持续集成接线以及怎么让同事愿意写测试这四件事分别展开说。5.1 测试目录规划与命名规范测试代码不是写在某个文件里就完事它需要长期维护所以目录规划和命名规范很重要。我的经验是测试结构尽量跟生产代码结构镜像对齐而不是把所有测试堆在一个包目录里。一个典型的后端项目结构src/ main/java/com/example/project/ user/ UserController.java UserService.java UserRepository.java test/java/com/example/project/ user/ UserControllerTest.java UserServiceTest.java UserRepositoryTest.java镜像结构的好处是找测试的时候不用花心思被测类在哪个包测试就在哪个包。名字对应关系也简单UserService对应UserServiceTest。还有人问测试要不要分单元测试、集成测试两个目录我的建议是如果集成测试数量不多可以用命名后缀区分比如UserServiceIT代表集成测试UserServiceTest代表单元测试。如果集成测试很多建议用Maven的failsafe插件和独立的目录src/test/java/.../integration来隔离这样单测和集成测试可以分开跑单测频率高跑得快。5.2 覆盖率门槛怎么定才合理覆盖率一定要设门槛吗我的结论是要设但别一刀切。设得太低没意义设得太高会让团队把时间花在凑覆盖率上。我建议用增量覆盖率而不是全局覆盖率来做门槛约束。在CI里跑diff只统计本次变更涉及的代码行和分支覆盖。比如GitLab CI可以配合gcovr或者JaCoCo的变更覆盖率能力要求新增代码的行覆盖率不低于80%分支覆盖率不低于70%。这样既不会因为老代码坑多导致新需求被拖死又能保证新增逻辑有基本的测试保护。前端项目也类似。Vitest和Jest都有coverage阈值配置。不过前端经常遇到纯展示型组件和工具函数覆盖率差异会很大。我一般会区分module来配置阈值工具函数、状态管理逻辑要达到80%以上页面级组件可以放宽到30%-40%因为页面组件的测试成本和收益不成比例。5.3 单测与持续集成的结合方式单元测试不接CI价值就少了一大半。没有CI强制测试代码跑不跑完全靠自觉项目一忙测试就挂了没人管。接了CI之后测试变成每次合并的必经关卡才能发挥安全网的作用。我推荐的做法是CI流水线里单元测试和集成测试分开跑。单元测试放在合并请求MR阶段代码一push就触发要求10分钟内跑完集成测试放到每日构建或发布前阶段耗时允许更长。后端以GitLab CI为例一个简单的job配置unit-test: stage: test script: - mvn test artifacts: when: always reports: junit: target/surefire-reports/TEST-*.xml把JUnit XML报告上传到CI平台之后你就能在每个合并请求详情页看到测试用例的执行情况哪个方法挂了失败信息是什么一目了然。前端Vue项目同理跑vitest run --coverage然后把coverage/lcov.info上传到Codecov或SonarQube。可视化覆盖率趋势线有一个好处它能直观地告诉你这个项目的测试是越做越好还是在不断恶化。5.4 让人愿意维护测试的几条经验最后说点软性的东西。很多团队单测推行不下去不是技术问题是心态问题。测试代码写起来麻烦、跑起来总挂、挂了没人敢改久而久之大家就集体绕过它。我从实操中总结了几条经验第一允许测试代码比生产代码啰嗦。很多人写测试时有一种我要写得很优雅的执念。测试代码追求优雅是好事但不要以牺牲可读性为代价。测试里多写几行准备数据的代码不要用复杂的工具类封装因为测试的核心是让读者一眼看懂前提是什么、操作是什么、期望是什么。第二把修补坏测试的优先级提到功能开发之前。我见过很多团队CI变红之后不是去修测试而是把测试用例直接注释掉或加Disabled跳过。这个口子一开测试的保护作用就消失了。我的原则是凡是进入主干的测试必须保持全绿如果某个测试确实已经过时要明确地删除它而不是跳过它。第三用测试作为代码评审的入口。我code review的时候先看新增的测试再看生产代码。如果测试代码写得清楚那生产代码的实现意图就很明白。反过来如果测试写得含糊、断言一堆没意义的值那我就有理由怀疑生产代码的设计也含糊。把测试纳入review范围既能提高测试质量也能倒逼开发认真对待测试。第四别害怕重构测试代码。测试代码也是代码也需要持续维护。生产代码改了API测试代码就要跟着改。很多人因为测试代码改起来麻烦而抵触改生产代码这其实是因噎废食。真正健康的代码库生产代码和测试代码是一起演进、互相成就的。我在实际项目中最大的体会是单元测试的价值不在于写了多少行、覆盖率多高而在于你改代码的时候心里有没有底。你改了一个公共方法测试立刻告诉你下游哪些行为受影响这种安全感是任何代码审查都替代不了的。把这个价值感传递到团队里单测就不是上面压下来的任务而是你自己想用起来的工具。