以终为始,拆解任务
在软件研发过程中,小到一个函数,大到一个平台,都是为了达成某个目标。 在达成目标的过程中,分治思想可以帮助我们完成任务的拆解,以终为始则引导我们先确定验收标准,以便明确任务发布者和实施者的理解是一致的,并且保障整体进度可感知。 当然,这些只是思想,面对知易行难的困境,单元测试 可以作为我们落地实践的一个抓手 。
抓手: 一口锅,如果没有抓手,就会扣在那里。有了抓手,就可以甩出去。
误区 Q
谁来测试
单测是额外的工作量
代码不好测试
先写代码还是先测试?
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
用于控制测试方法执行顺序。包括
OrderAnnotation
: 通过Order
指定方法顺序
MethodName
: 方法名称字母顺序
Random
: 随机。可能每次运行不一致,不可预期
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)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.Testpublic 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.Rulepublic org.junit.rules.ExpectedException thrown = org.junit.rules.ExpectedException.none();@org .junit.Testpublic 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.Testpublic 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.Testpublic void should_not_empty () { Assertions.assertTrue(fields.size() > 1 ); } @org .junit.jupiter.api.Nestedclass 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. 方法超时断言 assertTimeout
和assertTimeoutPreemptively
的区别是:assertTimeout
会等待方法执行运行结束,而assertTimeoutPreemptively
会在超过预期运行时长后立即结束。
1 2 3 4 5 @org .junit.jupiter.api.Testpublic 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包括
CsvSource: csv格式数据
CsvFileSource: 远程csv文件
ValueSource: 基本类型
EnumSource: 枚举
MethodSource: 反射调用静态方法,需要满足
static 方法
无入参
返回值可迭代
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
提供的anyOf
oroneOf
完成断言。 除此之外,hamcrest
还提供了大量声明式的Matchers
,提升用例的阅读性。比如: hasProperty
、greaterThanOrEqualTo
、equalToIgnoringWhiteSpace
等
1 2 3 4 5 6 7 8 @org .junit.jupiter.api.Testpublic 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数据库完成。
redis -> it.ozimov.embedded-redis
mysql -> com.h2database.h2
mongodb -> de.flapdoodle.embed.mongo
http -> com.github.dreamhead.moco-core 启动一个http服务,基于json配置response body & header 等。作为非RestTemplate
client无法使用MockRestServiceServer
的补充
2. Mock 推荐使用的方案。对方法及返回值进行mock,避免对要测试的方法逻辑产生干扰。
mockito
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
?