从单元测试到TDD

某次面试被问 “有用过TDD吗?”,现场顿时一排乌鸦飞过~~于是,突发奇想:

TDD定义

1. TDD是什么?

TDD: Test-driven development is a software development process relying on software requirements being converted to test cases before software is fully developed, and tracking all software development by repeatedly testing the software against all test cases.

以上一段来自Wiki关于 TDD 的定义,从字面上来看,就是测试驱动开发(先写一系列测试用例,再根据测试用例来写代码)吧。

2. TDD的开发过程是怎样的?

  1. Add a test (写测试用例)
  2. Run all tests. The new test should fail for expected reasons (运行测试用例:这时候大几率会失败,因为我们代码都没写呀)
  3. Write the simplest code that passes the new test (先写一点最简单的代码逻辑,保证测试用例通过吧)
  4. All tests should now pass (这下子测试用例通过了!)
  5. Refactor as needed, using tests after each refactor to ensure that functionality is preserved(然后可以开始优化代码了,但是每次改完都要记得运行测试用例)
  6. Repeat (追求完就重复以上步骤吧)

个人认为,有几个关键点:

  1. 测试用例很关键,最好全面覆盖需求,如果测试用例写得过份简单,测试结果通过了,但是豪无意义(写个“如果1+1应该返回2的测试用例”?)
  2. 写完代码,测试用例通过了,但是会不会有一些代码超出了测试用例范围?(用例写的“1+1=2”,但是写代码的时候还考虑到"1+2=3"啊~~)所以,这里还有个覆盖率的问题。

了解了TDD的概念,理一下涉及的技术点:

  • 单元测试(Unit Test)
  • 代码覆盖率(Code Coverage)

TDD实践

翻了不少现有的文章,这个例子比较简单实用,实现一个自定义的List,可以对照ArrayList跟着上面5个步骤不断优化。
参考例子: Baeldung - How to TDD a List Implementation in Java

环境准备

* 环境:Java 8, Spring 5, Spring Boot 2, Maven
* 开发工具:IntelliJ IDEA 2022.1 社区版

创建Spring项目

  1. IDEA已经集成了Coverage插件,可以用来统计覆盖率,可以根据官方文档设置:IDEA - Code coverage
  2. 使用Spring Initializr,或直接创建Maven Project,在pom.xml添加相关依赖
    Spring Initializr.png

mvn dependency:tree可以看到,JUnit 5已经被自动引入了

 \- org.springframework.boot:spring-boot-starter-test:jar:2.7.2:test
    +- org.springframework.boot:spring-boot-test:jar:2.7.2:test
    +- org.springframework.boot:spring-boot-test-autoconfigure:jar:2.7.2:test
    +- org.assertj:assertj-core:jar:3.22.0:test
    +- org.hamcrest:hamcrest:jar:2.2:test
    +- org.junit.jupiter:junit-jupiter:jar:5.8.2:test
    |  +- org.junit.jupiter:junit-jupiter-api:jar:5.8.2:test
    |  |  +- org.opentest4j:opentest4j:jar:1.2.0:test
    |  |  +- org.junit.platform:junit-platform-commons:jar:1.8.2:test
    |  |  \- org.apiguardian:apiguardian-api:jar:1.1.2:test
    |  +- org.junit.jupiter:junit-jupiter-params:jar:5.8.2:test
    |  \- org.junit.jupiter:junit-jupiter-engine:jar:5.8.2:test
    |     \- org.junit.platform:junit-platform-engine:jar:1.8.2:test
    +- org.mockito:mockito-core:jar:4.5.1:test
    |  +- net.bytebuddy:byte-buddy:jar:1.12.12:test
    |  +- net.bytebuddy:byte-buddy-agent:jar:1.12.12:test
    |  \- org.objenesis:objenesis:jar:3.2:test
    +- org.mockito:mockito-junit-jupiter:jar:4.5.1:test

TDD实践

  1. 创建一个类CustomList,实现List接口
package com.example.tdd.unittest.utils;

public class CustomList implements List {
    private Object[] internal = {};
    // 以下是List的实现方案,先不用写任何逻辑
    @Override
    public int size() {
        return 0;
    }
    // 以下省略...
}
  1. 创建测试类CustomListTest
package com.example.tdd.unittest.utils;

public class CustomListTest {
}

注意文件位置, test文件夹和main文件夹是一样的结构,test类都放在 test文件夹里

 +-unit-test
 |  +- src
 |  |  +- main
 |   |  |  +- java
 |  |  +- test
 |   |  |  +- java
  1. 根据TDD的5个步骤来开始实现CustomList
    Step 1. 写测试用例
package com.example.tdd.unittest.utils;

import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;

public class CustomListTest {
    /**
     * 预期:向空的list添加元素,返回True
     */
    @Test
    public void givenEmptyList_whenElementIsAdded_thenGetReturnsThatElement() {
        List list = new CustomList<>();
        boolean succeeded = list.add(null);
        assertTrue(succeeded);
    }
}

Step 2. 运行测试用例
运行结果失败,因为我们还没实现CustomList的add()方法,默认返回false

image.png

Step 3. 使测试用例通过
如果用最简单的方法使测试用例通过,直接把返回值改成true就可以了,但是因为这是演示,测试用例不可能这么简单的!

我们这里不直接使用运行了,我们使用Code Coverage来运行测试用例


image.png

看看结果


image.png

现在通过了,因为使用Code Coverage来运行,可以看看覆盖率
image.png

Class:只有一个类,所以100%覆盖了
Method:CustomList一共有23个方法,但是只覆盖了add()这1个
Line: 只覆盖了add()的部分
具体参数可以从官司网查看: Code Coverage Report

Step 4. 通过全部测试用例
假如测试用例有多个

package com.example.tdd.unittest.utils;

import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;

public class CustomListTest {
    /**
     * 预期:向空的list添加元素,返回True
     */
    @Test
    public void givenEmptyList_whenElementIsAdded_thenGetReturnsThatElement() {
        List list = new CustomList<>();
        boolean succeeded = list.add(null);
        assertTrue(succeeded);
    }
}

    /**
     * 预期:向list添加多元素,并且使用get()可以返回所有元素
     */
    @Test
    public void givenListWithAnElement_whenAnotherIsAdded_thenGetReturnsBoth() {
        List list = new CustomList<>();
        list.add("test1");
        Object element1 = list.get(0);
        assertEquals("test1", element1);
    }

运行用例,结果


image.png

一个成功,一个失败了,修改一下add()方法和get()方法

public class CustomList implements List {
    @Override
    public boolean add(E e) {
        internal = new Object[] { e };
        return true;
    }
    @Override
    public E get(int index) {
        return (E) internal[index];
    }
}

再运行一次,可以发再用例都通过了


image.png

因为我们修改过代码,所以代码覆盖率也会发生变化


image.png

Step 5. 重构代码,通过全部测试用例
因为上面都是简单粗暴地写代码,让用例通过,后面可能需要重构代码,每次改完都运行测试用例,以及查看覆盖率

Step 6. 重复以上步骤
上面的测试用例是不是太简单了?覆盖不了每个方法,那么要增删用例,就要重复以上步骤啦~~~

总结

TDD,基于测试用例来进行开发,对我来说是一个新鲜的概念(虽然不确定实践效果,或者会不会增加工作量)。作为CRUE的编程小白,单元测试这块再陌生不过了,但是我觉得这块不应该被忽略。

最后的最后,上面的例子只是最探索了一下TDD概念,并不适用真正的项目。可以想想,目前我们的项目,基于上都是基于多层(Controller - Service - Repository) 结构,还有各种外部依赖,直接跑测试用例怎么办?于是我又发现了Mockito这个东西,接下来准备研究研究~~

你可能感兴趣的:(从单元测试到TDD)