java微服务测试
微服务测试的思考与实践
最近几年,微服务架构越来越火爆,逐渐被企业所采用。随着软件架构的变化,对应的软件测试策略需要作何调整呢?本文将介绍微服务架构下的测试策略,并结合分享在业务和架构演变过程中,一个历经九年的项目测试策略的演进。
关于微服务
微服务架构是一种架构模式,它提倡将单一应用程序划分成一组小的服务,每个服务运行在其独立的进程中,服务间采用轻量级通信机制互相沟通(通常是基于HTTP协议的RESTful API)。每个服务都围绕着具体的业务进行构建,并且能够被独立部署到生产环境、预生产环境。
从微服务的概念可以看出它有如下好处:
- 每个服务可以独立开发
- 处理的单元粒度更细
- 单个服务支持独立部署和发布
- 更有利于业务的扩展
同时,独立开发导致技术上的分离,HTTP通信加上Queue的机制增加了问题诊断的复杂度,对系统的功能、性能和安全方面的质量保障带来了很大的挑战。另外,服务间的复杂依赖关系带来了很多的不确定性,要实现独立部署,对运维也提出了更高的要求。微服务架构的系统要特别关注这几个方面:
- 服务间的依赖、连通性
- 服务的容错、可用性
- 数据的最终一致性
- 独立部署
- 不确定性
测试策略的选择
谈到微服务的测试策略,很容易就想到了老马网站上Toby Clemson的文章《Microservices Testing》,该文推荐的微服务框架下的测试策略是这样的:

这个策略模型强调测试分层以及每一层的恰当覆盖,整体符合金字塔结构。它是最优的吗?
有人对此提出了质疑...认为策略模型应该是蜂巢形状的(请参考文章):

这个模型重点关注服务间的集成测试,两端的单元测试和UI层E2E测试较少。
也有同事提出微服务下的测试结构应该是钻石形状的,服务间的集成依然是重点,单元测试较少,而顶层增加了安全和性能等非功能测试。

好像都有道理,到底选择什么样的策略模型好呢?不禁陷入了困境…...怎么办?不妨先来听听我们项目的故事吧!
项目的故事
测试策略的演进
还是那个蓝鲸项目,不知不觉进入了第九个年头。在这九年里,随着业务的不断发展,系统架构也进行了多次演进和调整。相应的,测试策略也发生了有意思的演进变化。

最初单一用户系统、单体架构的时候,严格按照测试金字塔来组织各层的自动化测试。随着功能的扩展,大量mock的单元测试给重构带来了很大的不便。
企业系统开始开发的时候,我们调整了策略,减少单元测试的编写,增加UI层E2E测试的覆盖,测试结构由原来的金字塔演变成上面梯形下面倒三角的形式。
后来,架构调整,开始服务化。此时,大量的E2E测试渐渐暴露出问题:
- CI上的测试执行时间越来越长,而且定位问题的能力很弱,测试一旦失败需要很长时间修复,测试人员好几天也拿不到可以测试的版本,反馈周期过长;
- 由于服务化带来的不稳定因素增加,E2E测试没法很好的覆盖到需要的场景,测试人员就算拿到可测的版本也总有各种缺陷发生。
因此,项目引入契约测试,停止编写新的E2E测试,将测试下移,分别用API测试和契约测试取代。
随着功能的不断增加,虽然E2E测试的量并不增加,但是其不稳定性、维护难、定位难的问题有增无减,此时已经很难由自动化测试来保证产品的质量。为了平衡成本和收益,项目考虑去掉大部分E2E测试,只保留少量的Smoke测试,将更多的测试下移。
同时,技术雷达上新的技术“生产环境下的QA”出现,项目也开始关心生产环境,并且在QA测试阶段结合微服务的特点进行对应的探索式测试。
应对微服务的挑战
前文提到过微服务带来的挑战,下面来看项目是如何应对这些挑战的。
- 服务间的依赖、连通性
微服务架构下,独立开发的服务要整合起来最具挑战,如何保证服务间的依赖关系和连通性非常关键。前面已经讲过E2E集成测试有很大的挑战,并不适合,而消费端驱动的契约测试是个不错的选择。项目正是利用契约测试去保证服务间的连通性,取代一部分E2E集成测试。
- 服务的容错、可用性
在系统负荷达到一定程度或者某个服务出现故障的时候,微服务架构有两种技术来确保系统的可用性:服务的熔断和降级。服务的熔断是指当某个服务出现故障时,为了保证系统整体的可用性,会关闭掉出现故障的服务;服务的降级则是当系统整体负荷过载的时候,考虑关闭某些外围服务来保证系统的整体可用性。
对应的测试包括:
- 熔断:从性能角度,当系统负载达到某个熔断状态的时候,服务是否能正确熔断;同时,从功能角度验证熔断后系统的行为是否跟预期相符;
- 降级:从业务的角度,要能区分出核心业务和外围业务,在需要降级的时候不能影响核心业务;当某个服务降级后,从功能角度验证系统行为是否跟预期相符。
- 数据的最终一致性

数据一致性是微服务特别需要关注的。举个例子,电商平台某个订单支付成功以后,需要更新积分和订单状态,当订单服务或者积分服务其中有一个出现故障的时候,就会导致最终的数据不一致性。
测试这种情况,从业务的角度分析哪些服务会导致数据不一致性,制造对应的异常情况去测试数据的最终一致性。
- 独立部署
微服务的独立部署需要有CI、CD的支持,跟DevOps实践分不开。同时,更为关键的是需要契约测试来验证独立部署后服务行为的正确性。项目在这方面的工作,请参考王健的文章:你的微服务敢独立交付吗?
- 不确定性
微服务架构使得系统复杂度增加不少,很多的事情发生都是不可预测的,只能在其发生以后找到产生的原因。因此,也就没法在预生产环境通过测试去发现在真实生产环境才会发生的issue,我们需要把目光转移到生产环境,利用生产环境的不确定性、微服务的不可预测性来构建反脆弱的系统。
项目在这方面主要采用的技术是生产环境下的QA,请参考文章:生产环境下的QA
项目测试策略
从前面介绍的演进过程可以看到,项目测试策略在不同阶段结合参考了不同的策略模型:金字塔->近似钻石(除非功能测试外,类似于钻石模型)->蜂巢。后期全面服务化的时候,我们认为蜂巢模型是比较适合的。
当然,光有符合这个策略模型的自动化测试是远远不够的,我们项目还采用了针对微服务特点的探索式测试,保持持续交付节奏,践行DevOps实践,结合生产环境下的QA等技术把关注点右移到生产环境。
现在,项目整体测试策略演变成下图的形式:

- 项目采用的是敏捷迭代开发和持续交付的模式,每四周一个发布周期。
- 在开发过程中实现的自动化测试是分层实现的:底层少量的单元测试,中间量最多的是API测试(类似于老马推荐的策略模型里的组件测试),上面有一部分契约测试和少量的Smoke测试来保证服务间的契约和集成。除此之外,QA有手动的探索式测试,其中包括针对微服务特点进行的一些测试。整个测试结构是类似于蜂巢模型的。
- 采用生产环境下的QA技术,利用生产环境,进行error监控、用户行为分析、用户反馈收集,从而来影响和指导预生产环境的开发和测试工作。
- 利用DevOps实践,做到高效的部署和监控,跟生产环境下的QA结合,形成良性的环路,保证项目的正常交付。
测试策略再思考
项目上多次测试策略的调整,看似很简单,其实每次调整并不是一个轻松的过程,都是平衡利弊、综合考虑多个因素才做出的决定。
分析整个调整过程,最后突然发现:当我们面对多个策略模型不知道如何选择的时候,其实我们陷入了一个太过于关注测试结构的误区,忘记了最初的目标是什么。
影响测试策略的因素
跳出误区,回到原点,重新思考测试策略的目标。影响策略的最关键因素是业务价值、质量要求、痛点。

- 业务价值
带来更大的业务价值、帮企业赢得更多的利润,是软件系统的目标;软件测试是软件系统成功的保障之一,业务价值也是测试策略的终极目标。所有测试活动都要围绕这个目标开展,考虑业务优先级,有效规避业务风险。
- 质量要求
不同的系统、同一系统的不同利益干系人(参与的不同角色)对于质量的定义和要求都可能是不同的,这毫无疑问是影响测试策略的一个关键因素。
对于仅有内部用户的系统,关注的重心可能是系统的功能;而对外发布的产品,则要求更高,一个按钮位置的不恰当都可能带来大量用户的流失。
- 痛点
真正的痛点往往也是优先级最高,迫切需要解决的。那些可以通过测试策略的调整来解决的痛点,自然成为了关键的影响因素之一。比如,CI Pipeline出包太慢,为了提高出包的效率,一方面在Pipeline本身想办法,另一方面调整自动化测试的比例、执行频率等也是解决方案之一。
演进式测试策略
处在不同阶段的项目,在业务价值这个大目标下,其他影响因素也是会不一样的,跟技术架构的演进一样,测试策略也应该是演进式的。
从目标出发,综合所处阶段各个方面的影响因素,制定出适合当时的测试策略。随着时间的推移,对策略进行评估和度量,并进一步改进、提高,以更好的满足需求。这就是目标驱动的演进式测试策略。

总结
微服务架构下多个服务的整合是最具有挑战的,对此最重要的是契约测试。契约测试有效保证服务间的契约关系不被破坏,确保服务的连通性,有助于实现真正的独立部署和独立交付。
微服务架构引入的不确定性并不是坏事,可以利用这些不确定性,采用生产环境下的QA等技术,增强系统的反脆弱性,从中获益。
测试策略的影响因素不是唯一的,技术架构并不是最关键的因素。微服务架构下的测试策略跟其他架构下的并不会有本质的区别。
业务价值始终是我们的终极目标。在这个终极目标的驱动下,测试策略不是制定完了就可以束之高阁的,需要在整个软件系统构建过程中不断的度量和改进,是演进式的。
微服务实战测试之Java实例篇
测试分为好多种类型
- 测试用例的组织
- 单元测试
- 集成测试
- 端到端测试
- 消费者测试
- 契约测试
这是著名的测试金字塔, 也叫测试冰淇淋, 意谓我们应该从下到上地组织编写我们的测试, 大量地单元测试覆盖80%的代码行数, 有了一个坚实的基础, 我们再添加组织测试, 集成测试, API 测试, 端到端和用户界面测试, 越往上维护成本越高, 测试代码越脆弱, 越有可能由于环境问题导致并非代码错误引起的失败

测试用例的组织
首先要胸有成竹, 哪些测试应该做, 在[微服务实战测试之理论篇] 中已提过一些指导原则, 我们要根据这些原则, 结合要测试的特性, 把所有有可能出错的地方覆盖到, 防患于未然.
借用 Cucumber 中的定义的 Gherkin 语法, 一个特性 Feature 有若干个场景 Scenario
而每个场景都必须有独立的意义, 并且不依赖任何其他场景而独立运行.
以表格的形式组织测试用例是比较常见的做法
特性Feature | 场景Scenario | 给定Given | 事件 When | 结果 Then |
---|---|---|---|---|
作为系统用户, 我想在任务即将截止设置三次提醒来通知我, 从而我能即时采取措施而不至于超时 | 今天周一, 我要在周日前交稿 | 截止日期前一天要有邮件提醒 | 周六到了 | 收到提醒邮件 |
也可以用 wiki 或其他文件格式来存储用例, 推荐用格式化, 易解析的格式化文本文件, 比如 json.
结构层次为 1) Test Suite -- 2) Test Case -- 3) Test Steps(Given, When, Then)
例如:
{
"testsuites": [
{
"name": "login_module_test",
"testcases": [
{
"name": "login_by_phone_step1",
"feature": "login",
"scenario": "login by mobile phone",
"given": "input mobile phone number",
"when": "submit",
"then": "send a sms for authenticate code"
},
{
"name": "login_by_phone_step2",
"feature": "login",
"scenario": "login by mobile phone",
"given": "input mobile phone number and authenticate code",
"when": "submit",
"then": "redirect the user's home paeg"
},
{
"name": "login_by_error_password",
"feature": "login",
"scenario": "login by username and password",
"given": "input username, password, and captcha",
"when": "submit",
"then": "dispatch login failure message: you inputed improper username or password"
}
]
}
]
}
也可以自己写一个注解来自己生成测试用例, 我们在文末给出一个例子
单元测试
在微服务实战测试之理论篇中我们提到测试的分类和测试金字塔, 单元测试是基石, 做好单元是首要的测试工作, 以我熟悉的几种语言为例
测试的写法就四步 SEVT (TVes 许多电视倒过来)
- 准备 setup
- 执行 exercise
- 验证 verify
- 清理 teardown
简单测试可以忽略1) 和 4) 步
Java 单元测试
哪些库我们可以用呢
如果你使用 spring-boot-starter-test ‘Starter’ (test scope), 你会发现它所提供的下列库:
- JUnit — The de-facto standard for unit testing Java applications.
- Spring Test & Spring Boot Test — Utilities and integration test support for Spring Boot applications.
- AssertJ — A fluent assertion library.
- Hamcrest — A library of matcher objects (also known as constraints or predicates).
- Mockito — A Java mocking framework.
- JSONassert — An assertion library for JSON.
- JsonPath — XPath for JSON.
单元测试框架的鼻祖是 junit, 为什么不用 junit 呢? TestNG 有什么独到之处可以后来居上呢? 原因就在于 testng 更为强大的功能, 如 @Test 注解, 可以指定 testcase 的依赖关系, 调用次数, 调用顺序, 超时时间, 并发线程数以及期望的异常, 考虑得非常周到.
当然, 这只是个人喜好, Junit 新版本也多了很多改进.
举个例子, Fibonacci 数列大家很熟悉, 用 Java8 的 stream, lambda 的新写法比老的写法酷很多, 代码行数少了许多.
老写法
public List<Integer> fibonacci1(int size) {
List<Integer> list = new ArrayList<>(size);
int n0 = 1, n1 = 1, n2;
list.add(n0);
list.add(n1);
for(int i=0;i < size - 2; i++) {
n2 = n1 + n0;
n0 = n1;
n1 = n2;
list.add(n2);
}
return list;
}
新写法
public List<Integer> fibonacci2(int size) {
return Stream.iterate(new int[]{1, 1}, x -> new int[]{x[1], x[0] + x[1]})
.limit(size).map(x -> x[0])
.collect(Collectors.toList());
}
然而性能如何呢? 写个单元测试吧
package com.github.walterfan.example.java8;
import com.google.common.base.Stopwatch;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.testng.annotations.AfterClass;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.stream.Collector;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Created by walter on 24/03/2017.
* @see http://testng.org/doc/documentation-main.html
*/
public class LambdaPerfTest {
private static final Logger logger = LoggerFactory.getLogger(LambdaPerfTest.class);
private Map<Integer, Long> oldFibonacciResults;
private Map<Integer, Long> newFibonacciResults;
@BeforeClass
public void init() {
oldFibonacciResults = new ConcurrentSkipListMap<>();
newFibonacciResults = new ConcurrentSkipListMap<>();
}
@AfterClass
public void summarize() {
int rounds = oldFibonacciResults.size();
System.out.println("--- old vs. new ---");
oldFibonacciResults.forEach((key, value) -> {
System.out.println(key + ": " + value + " vs. " + newFibonacciResults.get(key));
//TODO: add assert for performance compare
});
}
public List<Integer> fibonacci1(int size) {
List<Integer> list = new ArrayList<>(size);
int n0 = 1, n1 = 1, n2;
list.add(n0);
list.add(n1);
for(int i=0;i < size - 2; i++) {
n2 = n1 + n0;
n0 = n1;
n1 = n2;
list.add(n2);
}
return list;
}
public List<Integer> fibonacci2(int size) {
return Stream.iterate(new int[]{1, 1}, x -> new int[]{x[1], x[0] + x[1]})
.limit(size).map(x -> x[0])
.collect(Collectors.toList());
}
@DataProvider
public Object[][] getFibonacciSize() {
return new Object[][]{
{10},
{50},
{100},
{1000},
{10000}
};
}
@Test(dataProvider = "getFibonacciSize", description = "old fibonacci", timeOut = 1000)
public void testOldFibonacci(int size) {
long duration = testFibonacci("testFibonacci1", size, x->fibonacci1(x));
oldFibonacciResults.put(size, duration);
}
@Test(dataProvider = "getFibonacciSize", description = "lambda fibonacci", timeOut = 1000)
public void testNewFibonacci(int size) {
long duration = testFibonacci("testFibonacci2", size, x->fibonacci2(x));
newFibonacciResults.put(size, duration);
}
public long testFibonacci(String name, int size, Function<Integer, List<Integer> > func) {
Stopwatch stopwatch = Stopwatch.createStarted();
List<Integer> list = func.apply(size);
stopwatch.stop();
long duration = stopwatch.elapsed(TimeUnit.MICROSECONDS);
list.stream().forEach(x -> System.out.print(x +", "));
System.out.println(String.format("\n--> %s (%d): %d\n" , name, size, duration));
return duration;
}
}
做了5组数列长度从10到10000 的测试, 输出结果如下
--- old vs. new ---
10: 34 vs. 28965
50: 9 vs. 154
100: 13 vs. 669
1000: 112 vs. 2600
10000: 1019 vs. 13548
不测不知道, 一测吓一跳, 新的写法看起来不错, 但是性能完败, 关键在于多做了两次转换(map , collect), 这里的测试代码用到了 @BeforeClass, @AfterClass, @Test, @DataProvider, TestNG 还有一些不错的功能, 比如 @threadPoolSize, @expectedExceptions, 详情参见 http://testng.org/doc/documentation-main.html
不知道你发现没有, 这里有个大问题, 这段测试代码缺少 Assert, 多数情况下对于功能测试必需要有 assert , 这些 assert 就是检查点, 没有检查点的测试起不到真正的作用. 你不可能去看每个测试的输出, 当然这里说的是单元测试,而对于性能测试, 一般要出一个性能测试的报告, Assert 检查点也不是必需的
所以我们应该这样写, 尽量多地加断言, 例如我们对 google 的 libphonenumber 作一个简单的测试
package com.github.walterfan.devaid.util;
import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.i18n.phonenumbers.Phonenumber;
import lombok.extern.slf4j.Slf4j;
import org.testng.annotations.Test;
import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertTrue;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.fail;
@Slf4j
public class PhoneNumberUtilTest {
@Test
public void testIsNumberNsnMatch() {
String phoneNumberOne = "+86055112345678";
String phoneNumberTwo = "86055112345678";
PhoneNumberUtil.MatchType matchType = PhoneNumberUtil.getInstance().isNumberMatch(phoneNumberOne, phoneNumberTwo);
log.info("matchType is {}", matchType);
assertFalse(matchType == PhoneNumberUtil.MatchType.NO_MATCH);
assertFalse(matchType == PhoneNumberUtil.MatchType.NOT_A_NUMBER);
assertEquals(matchType , PhoneNumberUtil.MatchType.NSN_MATCH);
}
@Test
public void testIsNumberShortMatch() {
String phoneNumberOne = "+86055112345678";
String phoneNumberTwo = "086(0551)1234-5678";
PhoneNumberUtil.MatchType matchType = PhoneNumberUtil.getInstance().isNumberMatch(phoneNumberOne, phoneNumberTwo);
assertFalse(matchType == PhoneNumberUtil.MatchType.NO_MATCH);
assertFalse(matchType == PhoneNumberUtil.MatchType.NOT_A_NUMBER);
assertEquals(matchType , PhoneNumberUtil.MatchType.SHORT_NSN_MATCH);
}
@Test
public void testGetCountryCode() {
String strPhoneNumber = "+86-0551-12345678";
try {
Phonenumber.PhoneNumber phoneNumber = PhoneNumberUtil.getInstance().parse(strPhoneNumber, "US");
log.info("phoneNumber.getCountryCode() is {}", phoneNumber.getCountryCode());
assertTrue(phoneNumber.getCountryCode() == 86);
} catch (NumberParseException e) {
fail(e.getMessage());
}
}
}
数据驱动测试
- 举例如下, 被测试类为 HttpUtil
public class HttpUtil
{
public static boolean hasFieldValue(String httpHeader, String fieldKey, String fieldVal) {
if(null == httpHeader || null == fieldKey || null == fieldVal) {
return false;
}
String[] toggles = httpHeader.split(";");
for(String toggle: toggles) {
String[] toggleKeyVal = toggle.split("=");
if(toggleKeyVal.length > 1) {
String key = StringUtils.trim(toggleKeyVal[0]);
String val = StringUtils.trim(toggleKeyVal[1]);
if(fieldKey.equals(key) && fieldVal.equalsIgnoreCase(val)) {
return true;
}
}
}
return false;
}
}
- 我们会用多个不同的 HTTP 头域来测试这个待测方法是否可正确地把相应头域的值判断出来, 用到的测试数据不必手工构造, 可以放在一个在 Object[][]为返回结果的方法中返回, 这些数据会逐个喂给测试方法, 决窍在于这个注解:
@Test(dataProvider= "makeHttpHeadFields")
所以我们的一个测试方法最终 会生成 8 个测试用例
具体代码如下
public class HttpUtilTest {
@DataProvider
public Object[][] makeHttpHeadFields() {
return new Object[][] {
{ "acl_enabled= true", true },
{ "acl_enabled=true; auth_type=oauth", true },
{ "acl_enabled =TRue; auth_type=basic", true },
{ "acl_enabled = false; auth_type=basic", false },
{ " acl_enabled = ; auth_type=basic", false },
{ "auth_type=basic", false },
{ "", false }
};
}
@Test(dataProvider= "makeHttpHeadFields")
public void testHasFieldValue(String toggleHeader, boolean ret) {
Assert.assertEquals(HttpUtil.hasFieldValue(toggleHeader, "acl_enabled", "true") ,ret);
}
}
运行结果如下

对于单元测试的测试用例组织主要是要逻辑分支覆盖, 符合 微服务实战测试之理论篇 中所提到的三大原则
- FIRST 原则
- Right-BICEP
- CORRECT 检查原则
还有很多线程测试, 性能测试, 压力测试, 异常测试, API 测试, 以及消费者契约测试,
这些测试我们后面慢慢道来, Mock 和 API 测试可参见 微服务实战之Mock
下面我们就之前提到的测试用例的组织编写一个 TestCase 注解和它的注解处理器, 可在很方便地生成测试用例文档
编写注解来自动生成测试用例
package com.github.walterfan.hello.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface TestCase {
String value();
String feature() default "";
String scenario() default "";
String given() default "";
String when() default "";
String then() default "";
//String[] checkpoints();
}
在编译阶段处理注解并生成测试用例文档
package com.github.walterfan.hello.annotation;
import com.google.auto.service.AutoService;
import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.ProcessingEnvironment;
import javax.annotation.processing.Processor;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.annotation.processing.SupportedSourceVersion;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.TypeElement;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
@SupportedSourceVersion(SourceVersion.RELEASE_8)
@SupportedAnnotationTypes("com.github.walterfan.hello.annotation.TestCase")
@AutoService(Processor.class)
public class TestCaseProcessor extends AbstractProcessor {
public final static String TABLE_TITLE1 = "| # | feature | case | scenario | given | when | then |\n";
public final static String TABLE_TITLE2 = "|---|---|---|---|---|---|---|\n";
public final static String TABLE_ROW = "| %d | %s | %s | %s | %s | %s | %s |\n";
private File testcaseFile = new File("./TestCases.md");
private StringBuilder testcaseBuilder = new StringBuilder();
private AtomicInteger testCaseNum = new AtomicInteger(0);
@SuppressWarnings("unchecked")
@Override
public synchronized void init(ProcessingEnvironment processingEnv) {
super.init(processingEnv);
testcaseBuilder.append("# Testcases");
testcaseBuilder.append("\n");
testcaseBuilder.append(TABLE_TITLE1);
testcaseBuilder.append(TABLE_TITLE2);
try (BufferedWriter bw = new BufferedWriter(new FileWriter(testcaseFile))) {
bw.write(testcaseBuilder.toString());
bw.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
@SuppressWarnings("unchecked")
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnvironment) {
StringBuilder sb = new StringBuilder();
for (TypeElement annotation : annotations) {
for (Element element : roundEnvironment.getElementsAnnotatedWith(annotation)) {
TestCase testCase = element.getAnnotation(TestCase.class);
if (testCase != null) {
String line = String.format(TABLE_ROW, testCaseNum.incrementAndGet(), testCase.feature(), testCase.value(), testCase.scenario(), testCase.given(), testCase.when(), testCase.then());
sb.append(line);
}
}
}
try (BufferedWriter bw = new BufferedWriter(new FileWriter(testcaseFile, true))) {
bw.write(sb.toString());
System.out.println("testcases:\n" + sb.toString());
bw.flush();
} catch (IOException e) {
e.printStackTrace();
}
return true;
}
}
假设我们有一个简单的类 User
package com.github.walterfan.hello.annotation;
import lombok.Data;
import java.util.Calendar;
import java.util.Date;
@Data
public class User {
private String name;
private String email;
private Date birthDay;
public int age() {
Calendar now = Calendar.getInstance();
now.setTime(new Date());
Calendar birth = Calendar.getInstance();
birth.setTime(birthDay);
return Math.abs(now.get(Calendar.YEAR) - birth.get(Calendar.YEAR));
}
}
我们写一个测试类
package com.github.walterfan.hello.annotation;
import com.github.walterfan.hello.annotation.User;
import lombok.extern.slf4j.Slf4j;
import org.testng.annotations.Test;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import static org.testng.Assert.assertEquals;
public class UserTest {
@Test
@TestCase(value = "testAge", feature = "UserManage", scenario = "CreateUser" ,given = "setBirthday", when="retrieveAge", then = "Age is current time minus birthday")
public void testAge() throws ParseException {
User user = new User();
SimpleDateFormat formatter = new SimpleDateFormat("yyyy/MM/dd");
Date birthDay = formatter.parse("1980/02/10");
user.setBirthDay(birthDay);
Calendar birthCal = Calendar.getInstance();
birthCal.setTime(birthDay);
int diffYear = Calendar.getInstance().get(Calendar.YEAR) - birthCal.get(Calendar.YEAR);
System.out.println("diffYear: "+ diffYear);
assertEquals(user.age(), diffYear);
}
@Test
@TestCase(value = "testName", feature = "UserManage", scenario = "UpdateUser" ,given = "setName", when="retrieveName", then = "name is same")
public void testName() throws ParseException {
String name = "Walter";
User user = new User();
user.setName(name);
user.getName().equals(name);
}
}
编译这个类会自动生成一个 TestCase.md, 内容如下
Testcases
| # | feature | case | scenario | given | when | then |
|---|---|---|---|---|---|---|
| 1 | UserManage | testAge | CreateUser | setBirthday | retrieveAge | Age is current time minus birthday |
| 2 | UserManage | testName | UpdateUser | setName | retrieveName | name is same |
也就是
# | feature | case | scenario | given | when | then |
---|---|---|---|---|---|---|
1 | UserManage | testAge | CreateUser | setBirthday | retrieveAge | Age is current time minus birthday |
2 | UserManage | testName | UpdateUser | setName | retrieveName | name is same |
完整代码可参见 https://github.com/walterfan/helloworld/tree/master/helloannotation
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!
2018-10-12 有效使用Mock编写java单元测试
2018-10-12 单元测试mock框架——jmockit实战
2018-10-12 Mock Server利器 - Moco
2018-10-12 Mock Server 入门
2018-10-12 WAF攻防实战
2018-10-12 简单的Web日志分析脚本