朋友,你听说过单元测试吗

以终为始,拆解任务

在软件研发过程中,小到一个函数,大到一个平台,都是为了达成某个目标。
在达成目标的过程中,分治思想可以帮助我们完成任务的拆解,以终为始则引导我们先确定验收标准,以便明确任务发布者和实施者的理解是一致的,并且保障整体进度可感知。
当然,这些只是思想,面对知易行难的困境,单元测试可以作为我们落地实践的一个抓手

抓手: 一口锅,如果没有抓手,就会扣在那里。有了抓手,就可以甩出去。

误区

Q

  1. 谁来测试
  2. 单测是额外的工作量
  3. 代码不好测试
  4. 先写代码还是先测试?

A

  • 1&2: 测试其实已经在潜移默化的进行了,贯穿整个开发过程。完成的功能一定是测试(运行)过的
  • 3&4: 写好的代码不好补充单测,在编码之前先通过单测明确目标及完成任务拆解

目标

编写可测试的代码。

理想情况下,我们将任务拆分为独立验收的测试用例,此时运行一次,可以看到所有用例均为RED(Fail)。
完成任务的过程就是通过我们的代码实现,一个个将这些用例变绿(Success)。
同时,通过红绿比例,可以大致评估任务完成进度。
最重要的是,当我们完成一个base版本之后,因为有用例存在,我们可以放心的进行重构,只要保障所有用例都是GREEN即可。

先完成base版本也是另一种任务拆解方式,通过小步快跑来降低复杂性。

Manual

测试用例需要一些经验技巧,列举下列非主流场景代码作为参考。

1. 是否需要针对外部系统调用编写单测?

不需要。各系统内部保障鲁棒性,调用方依照接口约定进行mock。
开发调试阶段,可以编写 debug 代码。 通过@Ignore@Disabled 忽略执行。

1
2
3
4
5
6
7
8
9
10
@Ignore
public class OuterAdapter {

@Test
public void request_for_debug(){
String result = new RestTemplate().getForObject("http://www.baidu.com", String.class);
assertNull(result);
}

}

2. 依赖方法执行顺序

破坏了独立测试原则!每个测试case应该彼此独立运行测试,不应该存在顺序依赖。

junit5提供@TestMethodOrder用于控制测试方法执行顺序。包括

  1. OrderAnnotation: 通过Order指定方法顺序
  2. MethodName: 方法名称字母顺序
  3. Random: 随机。可能每次运行不一致,不可预期
  4. DisplayName: @DisplayName指定显示名称字母顺序
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@org.junit.jupiter.api.TestMethodOrder(org.junit.jupiter.api.MethodOrderer.OrderAnnotation.class)
//@org.junit.jupiter.api.TestMethodOrder(MethodOrderer.Random.class)
//@org.junit.jupiter.api.TestMethodOrder(MethodOrderer.MethodName.class)
//@org.junit.jupiter.api.TestMethodOrder(MethodOrderer.DisplayName.class)
public class TestCaseRequireOrder {

@org.junit.jupiter.api.Test
@org.junit.jupiter.api.DisplayName("a")
@org.junit.jupiter.api.Order(3)
public void b(){
System.out.println("b");
}

@org.junit.jupiter.api.Test
@org.junit.jupiter.api.DisplayName("b")
@org.junit.jupiter.api.Order(1)
public void c(){
System.out.println("c");
}

@org.junit.jupiter.api.Test
@org.junit.jupiter.api.DisplayName("c")
@org.junit.jupiter.api.Order(2)
public void a(){
System.out.println("a");
}

}

junit4中为 @FixMethodOrder(MethodSorters.NAME_ASCENDING)

3. 用例方法执行N次

破坏了可重复性原则!

@RepeatedTest可以指定方法执行次数

1
2
3
4
@org.junit.jupiter.api.RepeatedTest(value = 3)
public void repeat_print() {
System.out.println(new java.util.Random().nextInt());
}

4. 用例方法抛出某个异常

junit5中Assertions提供了异常断言

1
2
3
4
5
@org.junit.jupiter.api.Test
public void should_throw_exception(){
Assertions.assertDoesNotThrow(()->{Arrays.asList("a","b").get(1);});
Assertions.assertThrows(IndexOutOfBoundsException.class, ()->{Arrays.asList("a","b").get(10);}, "should over bounds");
}

junit4中通过@Rule配合@ExpectedException完成异常断言

1
2
3
4
5
6
7
8
@org.junit.Rule
public org.junit.rules.ExpectedException thrown = org.junit.rules.ExpectedException.none();

@org.junit.Test
public void should_throw_exception(){
thrown.expect(IndexOutOfBoundsException.class);
Arrays.asList("a","b").get(10);
}

5. N个断言放在一组

通过Assertions.assertAll将语义相同的多个断言聚合成一组,并指定heading。因为assertAll也是一种Assertions,所以可以嵌套判断

1
2
3
4
5
6
7
8
9
10
11
@org.junit.jupiter.api.Test
public void assert_collect() {
List<String> fields = Arrays.asList("id", "name");
Assertions.assertAll("field check",
() -> Assertions.assertTrue(fields.size() > 1),
() -> Assertions.assertAll("id check",
() -> Assertions.assertTrue(fields.contains("id")),
() -> Assertions.assertEquals("id", fields.get(1))
)
);
}

另一种聚合方式则是通过内嵌测试类 Nested

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private static final List<String> fields = Arrays.asList("id", "name");

@org.junit.jupiter.api.Test
public void should_not_empty() {
Assertions.assertTrue(fields.size() > 1);
}

@org.junit.jupiter.api.Nested
class IdChecker {

@org.junit.jupiter.api.Test
public void should_contains_id() {
Assertions.assertTrue(fields.contains("id"));
}

@org.junit.jupiter.api.Test
public void id_by_first() {
Assertions.assertEquals("id", fields.get(0));
}
}

6. 方法超时断言

assertTimeoutassertTimeoutPreemptively的区别是:assertTimeout会等待方法执行运行结束,而assertTimeoutPreemptively会在超过预期运行时长后立即结束。

1
2
3
4
5
@org.junit.jupiter.api.Test
public void should_timeout() {
Assertions.assertTimeout(Duration.ofSeconds(1), () -> Thread.sleep(2000), "method execute time over 2 seconds");
Assertions.assertTimeoutPreemptively(Duration.ofSeconds(1), () -> Thread.sleep(20000), "method execute time over 2 seconds");
}

7. 逻辑相同,数据不同

@ParameterizedTest 配合 xxxSource 完成参数化测试。Source包括

  1. CsvSource: csv格式数据
  2. CsvFileSource: 远程csv文件
  3. ValueSource: 基本类型
  4. EnumSource: 枚举
  5. MethodSource: 反射调用静态方法,需要满足
    1. static 方法
    2. 无入参
    3. 返回值可迭代
  6. ArgumentSource: 自定义参数扩展

CsvSource 使用

1
2
3
4
5
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.CsvSource({"1,1,2", "2,4,6", "3,3,6"})
public void sum(int a, int b, int total) {
Assertions.assertEquals(a + b, total);
}

MethodSource 使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.MethodSource({"a", "b", "c"})
public void even_check(int a) {
Assertions.assertEquals(0, a % 2);
}

static Stream<Integer> a() {
return Stream.of(2, 4, 6);
}

static List<Integer> b() {
return Arrays.asList(2, 4, 6);
}

static int[] c() {
return new int[]{2, 4, 6};
}

ArgumentSource 使用。背景为测试graphql生成多个java文件的代码生成器。

需要实现 ArgumentsProvider 并提供相关Anno及Bean作为信息载体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class CodeGenResourceProvider implements ArgumentsProvider, AnnotationConsumer<CodeGenResources> {

private List<CodeGenResourceArgument> arguments = new ArrayList<>();

@Override
public void accept(CodeGenResources codeGenResources) {
Arrays.stream(codeGenResources.value())
.map(it -> new CodeGenResourceArgument(it.basePackage(),
it.generatorClazz(),
it.sourceGraphqlSchemaPath(),
it.generatedJavaCodePaths()))
.forEach(it -> arguments.add(it));
}

@Override
public Stream<? extends Arguments> provideArguments(ExtensionContext context) throws Exception {
return arguments.stream().map(Arguments::of);
}
}

效果

1
2
3
4
5
6
7
8
9
10
11
12
13
@ParameterizedTest
@CodeGenResources({
@CodeGenResource(generatorClazz = DataFetcherGenerator.class, sourceGraphqlSchemaPath = "data_fetcher_test.graphqls", generatedJavaCodePaths = {"data_fetcher_test_ProjectDataFetcher.java", "data_fetcher_test_MutationDataFetcher.java", "data_fetcher_test_QueryDataFetcher.java"}),
@CodeGenResource(generatorClazz = DictionaryGenerator.class, sourceGraphqlSchemaPath = "dictionary_test.graphqls", generatedJavaCodePaths = {"dictionary_test_E1.java"}),
@CodeGenResource(generatorClazz = InputGenerator.class, sourceGraphqlSchemaPath = "input_test.graphqls", generatedJavaCodePaths = {"input_test_I1.java"}),
@CodeGenResource(generatorClazz = RepositoryGenerator.class, sourceGraphqlSchemaPath = "repository_test.graphqls", generatedJavaCodePaths = {"repository_test_ProjectRepository.java"}),
@CodeGenResource(generatorClazz = TypeGenerator.class, sourceGraphqlSchemaPath = "type_test.graphqls", generatedJavaCodePaths = {"type_test_Milestone.java", "type_test_Mutation.java", "type_test_Project.java", "type_test_Query.java", "type_test_User.java"}),
@CodeGenResource(generatorClazz = TypeGenerator.class, sourceGraphqlSchemaPath = "type_union_test.graphqls", generatedJavaCodePaths = {"type_union_test_Milestone.java", "type_union_test_Entity.java", "type_union_test_Project.java"}),
@CodeGenResource(generatorClazz = TypeGenerator.class, sourceGraphqlSchemaPath = "type_interface_test.graphqls", generatedJavaCodePaths = {"type_interface_test_Milestone.java", "type_interface_test_Entity.java", "type_interface_test_Project.java"})
})
public void gen_e2e_test(CodeGenResourceArgument argument) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException, IOException {
...
}

8. 测试servlet

spring-test提供了相关mock类,包括request、response、cookie、MockMultipartFile等,可以查看org.springframework.mock.web包下的class

1
2
3
4
5
6
7
@Test
public void should_return_true_when_not_enable_and_header_auth_existed() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();
request.addHeader("Authorization", "xxxx");
assertTrue(loginInterceptor.preHandle(request, response, null));
}

9. 修改私有变量

可以通过反射完成。spring-test提供了相关封装工具类ReflectionTestUtils

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@org.junit.jupiter.api.Test
public void set_private_field() {
A a = new A();
org.springframework.test.util.ReflectionTestUtils.setField(a,"b", "123");
Assertions.assertEquals("123",a.getB());
}

static class A {
private String b;

public String getB() {
return b;
}
}

10. 结果可能是A或者B

借助hamcrest提供的anyOforoneOf完成断言。
除此之外,hamcrest还提供了大量声明式的Matchers,提升用例的阅读性。比如: hasPropertygreaterThanOrEqualToequalToIgnoringWhiteSpace

1
2
3
4
5
6
7
8
@org.junit.jupiter.api.Test
public void set_private_field() {
A a = new A();
ReflectionTestUtils.setField(a,"b", "123");
org.hamcrest.MatcherAssert.assertThat(a.getB(), isOneOf("123", "2", "3"));
org.hamcrest.MatcherAssert.assertThat(a.getB(), is(oneOf("123", "2", "3")));
org.hamcrest.MatcherAssert.assertThat(a.getB(), anyOf(equalTo("123"), equalTo("2"), equalTo("3")));
}

流派

框架体系复杂,但是文档完善。不展开描述,可以直接查阅相关使用。

0. Spring

通过@SpringBootTest启动Spring容器、完成组件注册

1. 持久化

也是反原则的需求。一般通过embedded数据库完成。

  1. redis -> it.ozimov.embedded-redis
  2. mysql -> com.h2database.h2
  3. mongodb -> de.flapdoodle.embed.mongo
  4. http -> com.github.dreamhead.moco-core 启动一个http服务,基于json配置response body & header 等。作为非RestTemplate client无法使用MockRestServiceServer的补充

2. Mock

推荐使用的方案。对方法及返回值进行mock,避免对要测试的方法逻辑产生干扰。

  1. mockito
  2. PowerMock

PowerMock 对 static、private方法进行了增强。 如:统计私有方法被调用次数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@RunWith(PowerMockRunner.class)
@PrepareForTest(BizService.class)
public class 私有方法调用 {

@Test
public void count_handle_private_method_times() throws Exception {
BizService bizService = PowerMockito.spy(new BizService(PowerMockito.mock(CommonRepo.class)));
bizService.biz(10);
PowerMockito.verifyPrivate(bizService, new Times(10)).invoke("secret");
}

static class BizService {

private CommonRepo commonRepo;

public BizService(CommonRepo commonRepo) {
this.commonRepo = commonRepo;
}

public void biz(int times) {
while (times-- > 0) {
this.secret();
}
}

private void secret() {
System.out.println("do private");
}

}

static class CommonRepo {

}
}

其他

有没有想在下次迭代中,尝试先写一个@Test

Welcome to my other publishing channels