优秀的后端Coder如何写好UT

1,381 阅读8分钟

1. 为什么要写UT

1.1 单元测试的定义

广义的测试包括 UT、IT、压力测试、硬件测试等等,这里重点讨论 Unit Test 即单元测试。

单元测试(英语:Unit Testing)又称为模块测试 ,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;维基百科:单元测试

1.2 UT要解决什么问题

让你的代码质量更可靠

  1. 测试动作左移

    虽然编写单测会花费我们一些功夫,但是Bug发现的越早,修复的代价越小。另外如果代码有良好的单元测试,集成测试和系统测试就只需要关注功能和流程方面的问题,回归测试成本也大幅减少。

  2. 重构或者业务迭代增强信息

    单测可以确保重构后的代码没有改变原来的逻辑,如果逻辑发生来改变,会有失败的用例提示你。

  3. 帮助研发更好的理解业务逻辑

    有助于理解代码的逻辑。测试用例很直观的可以看出待测试方法想要表达的逻辑,很多著名的框架都提供对应的测试用例,如Spring,Guava。

  4. Coder的基本功

    很多公司尤其是大型互联网公司,已经默认coding的能力中必须要包含UT,以google为例,研发工程师提交的代码都必须包含UT,并通过大量的测试用例(google的测试用例也是RD自己写)来提升自动化测试水平减少QA数量。

2. 编写UT的时机

2.1 测试驱动研发

TDD:Test Driven Development,也被认为是Test Driven Design,TDD一改以往的破坏性测试的思维方式,测试在先、编码在后,更符合“缺陷预防”的思想。百度百科:TDD

WechatIMG4418.png

  1. 不可运行——写一个功能最小完备的单元测试,并使得该单元测试编译失败。
  2. 可运行——快速编写刚刚好使测试通过的代码,不需要考虑太多,甚至可以使用一些不合理的方法。
  3. 重构——消除刚刚编码过程引入的重复设计,优化设计结构。

如何编写

步骤一:抽象场景与预期

步骤二:以方法维度把所有场景都囊括进去

  • should:返回值,应该产生的结果

  • when:哪个方法

  • given:哪个场景

    @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());
            }
        }
    

TDD的难点

  • 缺乏软件质量意识
  • 缺乏一定程度的程序设计能力,很难设计出高内聚低耦合、意图清晰的结构和代码。
  • 缺乏分析需求并进行任务分解和规划的能力,很容易在还没开始 TDD 的时候就被打乱了节奏。
  • 缺乏合适的测试环境和测试规范。
  • 测试优先的习惯难以养成。
  • 重构手法不熟练。

但是,目前业内支持TDD和反对TDD的声音从来就没停歇过,TDD最大的问题在于偏向思想级别,实施起来极其困难而且目前几乎国内没有任何一家公司能够实践落地,变成最佳实践,所以我们不过多的讨论了。

2.2 传统单元测试

传统的单测开发时机是在业务代码编写完毕后进行,不一定需要全部编写完毕,甚至完成某一个method、sevice的时候都可以进行单元测试,它不依赖spring框架或者其它服务就可以进行测试。

image.png

3. 如何写UT

3.1 选用什么框架

目前市面上比较流行的框架包括Junit、TestNG以及阿里巴巴的Fast-tester,其中Junit系列框架从市占和面世历史上都占有绝对优势,我们以Junit为例展开探讨;

版本选用最新的Junit5系列(相对4序列的版本,5序列的版本做了大量的更新);

3.2 那些代码需要写UT

理论上所有的代码都可以写单测,但是很多代码写单元测试的意义不大或者几乎没有,比如类中大量的set/get方法,完全没有必要写单测(除非是充血模型中,在get方法中充斥了大量的逻辑操作);

所以,建议如下的代码

image.png

3.3 命名规范

代码放在哪里项目名称{项目名称}/src/test/java 目录下
package规范与测试类package保持一直
class规范被测试类的类名前加入TestXXXX
method命名规范原方法名前加入test前缀,如testOrderBiz

3.4 Demo

步骤一:你的pom文件需要引入JUnit框架,这里推荐5序列(要求JDK版本8以上)

    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-engine</artifactId>
        <version>5.5.2</version>
        <scope>test</scope>
    </dependency>
    

步骤二:写一个最简单的

   public class TestService {
        private int record=1;

        @BeforeEach
        @DisplayName("计数器")
        public void testRecordNum(){
            record++;
        }
    }

a)注解解释(所有的注解都在org.junit.jupiter.api下)

注解名称解释说明备注
@Test表示方法是测试方法。
@ParameterizedTest表示方法是参数化测试。与@valueSource等配合使用
@RepeatedTest表示方法是重复测试的测试模板
@DisplayName声明测试类或测试方法的自定义显示名称。
@Disabled用于禁用测试类或测试方法;类似于JUnit 4的@Ignore。
@BeforeAll表示应在当前类中的所有@Test、@RepeatedTest、@ParameterizedTest和@TestFactory方法之前执行带注释的方法;类似于JUnit 4的@BeforeClass。
@AfterAll表示在当前类中,所有@Test、@RepeatedTest、@ParameterizedTest和@TestFactory方法都应该执行注释的方法;类似于JUnit 4的@AfterClass。
@BeforeEach表示在当前类中每个@Test、@RepeatedTest、@ParameterizedTest或@TestFactory方法之前执行注释的方法;类似于JUnit 4的@Before。
@AfterEach表示在当前类中的每个@Test、@RepeatedTest、@ParameterizedTest或@TestFactory方法之后,都应该执行带注释的方法;类似于JUnit 4的@After。

b)断言部分

前面的断言一旦失败,后面的断言就不会再继续执行;

image.png

步骤三:mock服务

上面的步骤只是最简单的一个测试,在实际生产中其实出现最多的内部biz,server层中关于类和方法的单元测试,里面涉及到了2个问题:

第一内外部的接口服务如何mock;

第二存储容器如何不因为UT而产生脏数据;

和单元测试框架一样,mock框架也有很多的选择:Mockito、Jmock、TestableMock、EasyMock等;

常见mock框架

Mockito

语法特别优雅,对于容器类的模拟比较合适,且对于返回值为空的函数调用也提供比较好的断言。缺点是不能模拟静态方法(3.4.x以上版本已支持)
EasyMock

使用方法类似,但是更严格
PowerMock

可以作为Mockito的一个补充,比如要测试静态方法,不过不支持junit5
Spock

基于Groovy语言的单元测试框架

目前业界使用最多的都是MocKito,这里我们不在讨论选型问题了,后面统一使用MocKito框架进行测试;

首先在pom文件中引入框架。

<!-- mockito -->
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>3.9.0</version>
    <scope>test</scope>
</dependency>
<!-- mockito 的junit5适配器 -->
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-junit-jupiter</artifactId>
    <version>3.9.0</version>
    <scope>test</scope>
</dependency>

三种方式注入:

注解方式解释说明
@InjectMocks将标记了 @Mock 或 @Spy 的属性注入到 service 中,server中的
@Mock要测试的server中需要被mock的部分
@Spy要测试的server中需要被mock的部分

@mock与@spy的区别

对于未指定mock的方法,spy默认会调用真实的方法,有返回值的返回真实的返回值,而mock默认不执行,有返回值的,默认返回null。spy的如果使用不合理可能会有很大的坑。

步骤四:适当的时候可以使用自动化框架

Squaretest是目标比较广泛的自动化测试UT生成工具,但是坑也比较多。建议可以先尝试生产,然后修改生成后的代码。

目前有idea插件,网上教程也比较多,这里先不赘述了。

4、UT的标准是什么?

好的单元测试必须遵守AIR原则,感觉像空气(AIR)一样并不存在。

  • A:Automatic(自动化) 全自动执行,无人为干预和交互。一般是在CI/CD流程中。
  • I:Independent(独立性) 要求单测无调用关系,无调用顺序。
  • R: Repeatable(可重复)。是指不能受到外界环境的影响

好的UT

1)只测要测试的方法;

2)Mock第三方依赖

尽量不要采用直接调用的方式去调用三方的接口数据,多采用mock的方式;

3)使用最细粒度的断言

示例

Assert.assertThat(items, hasItems("one", "two"));       //第一个方法明显要比第二个方法更加清晰明确

Assert.assertThat(items, not(empty()));

什么在阻止你写UT

代码本身的原因

如果代码复杂度较高还缺少必要的抽象和拆分,就会让人对写 UT 望而生畏。

编码工作量的原因

无论是用什么样的单元测试框架,最后写出来的单元测试代码量也比业务代码只多不少,在不作弊的前提下要保证相关的测试覆盖率,大概要三倍源码左右的工作量。

难以维护的原因

4)避免使用SpringTestRunner

  1. 慢,加载Spring一般要1min左右,如果Test Case多的话,跑完全部话费的时间太长,而且随着用例次数的增长线性增长。
  2. 引入额外依赖,创建太多不需要的bean,有可能因为不相关的bean的创建失败,导致整个用例失败。