阿里妹导读:测试不应该是一门很高大尚的技术,应该是我们技术人的基本功。但现在好像慢慢地,单元测试已经脱离了基本功的范畴。笔者曾经在不同团队中推过单元测试,要求过覆盖率,但发现实施下去很难。后来在不停地刻意练习后,发现阻碍写UT的只是笔者的心魔,并不是时间和项目的问题。在经过一些项目的实践后,也是有了一些自己的理解和实践,希望和大家分享一下,和大家探讨下如何克服“单元测试”的心魔。
- 红:测试先行,现在还没有任何实现,跑UT的时候肯定不过,测试状态是红灯。编译失败也属于“红”的一种情况。
- 绿:当我们用最快,最简单的方式先实现,然后跑一遍UT,测试会通过,变成“绿”的状态。
- 重构:看一下系统中有没有要重构的点,重构完,一定要保证测试是“绿”的。
@RunWith(SpringBootRunner.class)
@DelegateTo(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = {Application.class})
public class ApiServiceTest {
@Autowired
ApiService apiService;
@Test
public void testMobileRegister() {
AlispResult<Map<String, Object>> result = apiService.mobileRegister();
System.out.println("result = " + result);
Assert.assertNotNull(result);
Assert.assertEquals(54,result.getAlispCode().longValue());
AlispResult<Map<String, Object>> result2 = apiService.mobileRegister();
System.out.println("result2 = " + result2);
Assert.assertNotNull(result2);
Assert.assertEquals(9,result2.getAlispCode().longValue());
AlispResult<Map<String, Object>> result3 = apiService.mobileRegister();
System.out.println("result3 = " + result3);
Assert.assertNotNull(result3);
Assert.assertEquals(200,result3.getAlispCode().longValue());
}
@Test
public void should_return_mobile_is_not_correct_when_register_given_a_invalid_phone_number() {
AlispResult<Map<String, Object>> result = apiService.mobileRegister();
Assert.assertNotNull(result);
Assert.assertFalse(result.isSuccess());
}
}
- should:返回值,应该产生的结果
- when:哪个方法
- given:哪个场景
- 契约测试:测试服务与服务之间的契约,接口保证。代价最高,测试速度最慢。
- 集成测试(Integration):集成当前spring容器、中间件等,对服务内的接口,或者其他依赖于环境的方法的测试。
// 加载spring环境
@RunWith(SpringBootRunner.class)
@DelegateTo(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = {Application.class})
public class ApiServiceTest {
@Autowired
ApiService apiService;
//do some test
}
- 单元测试(Unit Test):纯函数,方法的测试,不依赖于spring容器,也不依赖于其他的环境。
- 一个类里面测试太多怎么办?
- 不知道别人mock了哪些数据怎么办?
- 测试结构太复杂?
- 测试莫名奇妙起不来?
- 通过组合Fixture(固定设施),来构造一个Scenario(场景)。
- 通过组合Scenario(场景)+ Fixture(固定设施),构造一个case(用例)。
- Case:当用户正常登录后,获取当前登录信息时,应该返回正确的用户信息。这是一个简单的用户登录的case,这个case里面总共有两个动作、场景,一个是用户正常登录,一个是获取用户信息,演化为两个scenario。
- Scenario:用户正常登录,肯定需要登录参数,如:手机号、验证码等,另外隐含着数据库中应该有一个对应的用户,如果登录时需要与第三方系统进行交互,还需要对第三方系统进行mock或者stub。获取用户信息时,肯定需要上一阶段颁发的凭证信息,另外该凭证可能是存储于一些缓存系统的,所以还需要对中间件进行mock或者stub。
- Fixture
-
利用Builder模式构造请求参数。
-
利用DataFile来存储构造用户的信息,例如DB transaction进行数据的存储和隔离。
-
利用Mockito进行三方系统、中间件的Mock。
public class GetUserInfoCase extends BaseTest {
private String accessToken;
@Autowired
private UserFixture userFixture;
/**
* 通用场景的mock
*/
@Before
public void setUp() {
//三方系统mock
userFixture.whenFetchUserInfoThenReturn("1", new UserVO());
//依赖的其他场景
accessToken = new SimpleLoginScenario()
.mobile("1234567890")
.code("aaa")
.login()
.getAccessToken();
}
/**
* BDD的三段式
*/
@Test
public void should_return_user_info_when_user_login_given_a_effective_access_token() {
Response userInfoResponse = new GetUserInfoScenario()
.accessToken(accessToken)
.getUserInfo();
assertThat(userInfoResponse.jsonPath().getString("id"), equals("1"));
}
}
@Data
public class SimpleLoginScenario {
// 请求参数
private String mobile;
private String code;
// 登录结果
private String accessToken;
public SimpleLoginScenario mobile(String mobile) {
this.mobile = mobile;
return this;
}
public SimpleLoginScenario code(String code) {
this.code = code;
return this;
}
//登录,并且保存AccessToken,这里返回自身,是因为有可能返回参数是多个。
public SimpleLoginScenario login() {
Response response = loginWithResponse();
this.accessToken = response.jsonPath().getString("accessToken");
return this;
}
//利用RestAssured进行登录,这个方法可以是public,也可以通过参数传递一些验证方法
private Response loginWithResponse() {
return RestAssured.get(API_PATH, ImmutableMap.of("mobile", mobile, "code", code))
.thenReturn();
}
}
Fixture
public class MockitoTest {
@MockBean(classes = CacheImpl.class)
private Cache cache;
@Test
public void should_return_success() {
// 固定参数,固定返回值
Mockito.when(cache.get("KEY")).thenReturn("VALUE");
// 动态参数,固定返回值
Mockito.when(cache.get(Mockito.anyString())).thenReturn("VALUE");
// 动态参数,固定返回值
Mockito.when(cache.get(Mockito.anyString())).then((invocation) -> {
String key = (String) invocation.getArguments()[0];
return "VALUE";
});
// 固定参数,异常
Mockito.when(cache.get("KEY")).thenThrow(new RuntimeException("ERROR"));
// 验证调用次数
Mockito.verify(cache.get("KEY"), Mockito.times(1));
}
}
(b)stub
//使用spring的@Primary来替换一个bean,如果不同的测试需要的bean不同,推荐使用@Configuration + @Import的方式,动态加载Bean
@Primary
@Component("cache")
public class CacheStub implements Cache {
@Override
public String get(String key) {
return null;
}
@Override
public int setex(String key, Integer ttl, String element) {
return 0;
}
@Override
public int incr(String key, Integer ttl) {
return 0;
}
@Override
public int del(String key) {
return 0;
}
}
- 使用@Transactional在一些测试的类上,这样在跑完测试后,数据不会commit,会回滚。但如果测试中对事物的传播有特殊要求,可能不适用。
- 通用的trancateAll和initSQL通过在每个测试前跑清除数据、mock数据的脚本,来达到每个测试对应一个隔离环境,这样数据间就不会产生干扰。
PowerMockito.mockStatic(C.class);
PowerMockito.when(C.isTrue()).thenReturn(true);
注意:
- PowerMock不仅仅是用来mock静态方法的。
- 不建议mock静态方法,因为静态方法的使用场景都是些纯函数,大部分的纯函数不需要mock。部分静态方法依赖于一些环境和数据,针对这些方法,需要考虑下到底是要mock其依赖的数据和方法,还是真的要mock这个函数,因为一旦mock了这个函数,意味着隐藏了细节。
@Builder
@Data
public class UserVO {
private String name;
private int age;
private Date birthday;
}
public class UserVOFixture {
// 注意:这里是个Supplier,并不是一个静态的实例,这样可以保证每个使用方,维护自己的实例
public static Supplier<UserVO.UserVOBuilder> DEFAULT_BUILDER = () -> UserVO.builder().name("test").age(11).birthday(new Date());
}
(b)数据文件
public class UserVOFixture {
public static UserVO readUser(String filename) {
return readJsonFromResource(filename, UserVO.class);
}
public static <T> T readJsonFromResource(String filename, Class<T> clazz) {
try {
String jsonString = StreamUtils.copyToString(new ClassPathResource(filename).getInputStream(), Charset.defaultCharset());
return JSON.parseObject(jsonString, clazz);
} catch (IOException e) {
return null;
}
}
}
- FSC本身会给测试带来复杂度,而UnitTest应该简单,如果UnitTest本身都很复杂了,项目带来难以估量的测试成本。
- Fixture其实可以在任何场景中使用,因为是底层的复用。
- 增加了代码复杂度。
- 通过IDE工具无法直接定位的测试文件,折衷的方案是case的命名符合ResouceTest的命名。
刻意练习,简而言之,就是刻意的练习,它突出的是有目的的练习。刻意练习也有它的一整套过程,在这个过程里,你需要遵守它的3F法则:
- 第一,Focus(保持专注)
- 第二,Feedback(注重反馈,收集信息)。
- 第三,Fix it(纠正错误,并且进行修改)。
UT本身是一项技术,是需要我们打磨、练习的,最好的练习方式,就是刻意练习,如果有决心,一个周末在家刻意练习,为项目中的部分场景加上UT,相信收获会很丰富。
- 应不应该连日常环境进行测试?
- 个人不建议直接连日常环境进行测试,如果两个人同时在跑测试,那么很有可能测试环境的数据会处于混乱状态。而且UT尽可能不要依赖过多的外部环境,依赖越多越复杂。测试还是简单点好。
- 一个类里面测试太多怎么办?
- 考虑按测试的case区分,也可按测试的方法区分,也可以按正常、异常场景区分。
- 不知道别人mock了哪些数据怎么办?
- 尽量让大家Mock数据的命名规范,通过Fixutre的复用,来减少新写测试的成本。
- 测试结构太复杂?
- 考虑是不是自己应用的代码组织就有问题?
- 测试莫名奇妙起不来?
- 需要详细了解JUNIT、Spring、PandoraBoot等是如何进行测试环境的mock的,是不是测试间的数据冲突等。详细的我们会在方法篇持续更新,遇到问题解决问题。
- 不熟悉单元测试写法,尽量写简单的单元测试,覆盖核心方法。
- 熟悉单元测试,业务复杂,覆盖正常、一般异常场景,另外对核心业务逻辑要有单独的测试。
- DEBUG:阿里现在的基础设施是真的完善,中间件、各种监控、日志,只要系统埋点够好,遇到的很多问题都可以解决,即使有一些复杂问题,也可以local debug。但在一些特殊场景下,将数据MOCK好,利用UT来DEBUG,可能效率更高,大家可以试试。
- 测试如文档:我们现在开发有很多完善的文档,但文档这东西和代码上毕竟有一层映射关系,如果能快速了解业务,完善的测试,有时候也是个不错的选择,例如大家学习一些开源框架的时候,都会从测试开始看。
- 重构:当你想下定决心重构的时候,才发现项目中没有单元测试,什么心情?
最后
如果大家对于单元测试有好的实践,或者对文章中的一些观点有些共鸣,大家可以在评论区留言,我们互相学习一下。大家也可以在评论区写出自己的场景,大家一起探讨如何针对特定场景来实践。
相关链接 [1]https://martinfowler.com/bliki/TestPyramid.html
[2]https://martinfowler.com/articles/practical-test-pyramid.html