良好的編程習慣-從單元測試開始

原文博客:Doi技術團隊
鏈接地址:https://blog.doiduoyi.com
初心:記錄優秀的Doi技術團隊學習經歷

系列目錄

J0FgAO.png

引言

這篇文章文中的實用例子只是一個拋磚引玉的作用。

適合新手學習,或者時間充裕可以深入研究以這篇爲目錄進行查漏補缺。

瞭解單元測試

單元測試屬於小型測試,針對單個函數的測試,關注其內部邏輯輸出的結果是否正確。如果將一個單元測試看成是一個單位,只需保證每一個單元測試都通過,則可以大大提高項目質量。單元測試可以保證能夠代碼覆蓋率達到100%的測試。

但是我們往往在開發中都不願意好好寫單元測試,理由有很多,絕大多數如下:

  1. 需求趕,沒有足夠的時間寫單元測試
  2. 功能需求太簡單,沒有必要寫單元測試
  3. 當需求變動的時候,又要修改單元測試,增加了開發時間
  4. 應該交給測試人員來完成

以上的問題,其實對於每一個項目普遍存在。在以前,我也是這樣的心態,不願意寫單元測試。但當我嘗試了幾次單例的帶來的甜頭後,越發喜歡和習慣寫單元測試。我覺得當你認識到單元測試的意義,以及熟悉使用單元測試,你自然會打消以上的疑慮並且愛上它。

單元測試的意義

  • 它可以保證你寫的代碼是你想要的結果。這個點很重要,因爲在編程中,經常會敲錯代碼導致結果並不是自己腦子裏想的。如果不經過單元測試測試下運行結果,那麼代碼質量是肯定保證不了的。
  • 單元測試是最少單位,一個高可用的系統需要靠一個一個最小的穩定的單位組成。所以保證一個最小單位的準確率是必須的。
  • 單元測試應該是快速的,因此它不應該使用任何Web服務器。
  • 每個單元測試應該獨立於其他測試。
  • 當出現問題的時候,單元測試可以很快幫助你排查問題。因爲單元測試保證你寫的代碼是你想要的結果,當出現異常效果,只需要從對應的單元測試是排查,就可以很快定位問題。

如何實現單元測試

在討論如果實現單元測試的之前,我們要先想想,什麼是好的單元測試呢?

  • 完整性:覆蓋率高,意思就是對各種情況都要考慮到
  • 健壯性:具有健壯性的單元測試,完全不需要被修改或者只有極少的修改。因爲單元測試只是關注輸出結果是否符合期望,如果只是修改了實現邏輯,那麼單元測試是不需要改動的。
  • 粒度細:其實這裏跟代碼的設計和實現有關。考慮到單測實現的簡潔,把各個功能分成每個函數,保證粒度足夠細。(評判代碼或者設計好不好的⼀個準則是看它容不容易測試

那麼接下來我們要討論下需要測試什麼?

上文已經提到,單元測試測試是最小粒度的代碼,通常是一個方法或函數。通常是通過⼀系列不同的⾏爲。⾏爲就是對不同的輸⼊場景有不同的輸出,每⼀個⾏爲都需要獨⽴的單測。

實戰

接下來,我們來討論一下如何寫單元測試

如何保證單元測試細粒度

在實戰前,我們要考慮如何保證單元測試的細粒度呢?
在絕大多數業務中,單個方法/函數也是有調用其他方法/函數的,那麼當我們測試的方法調用鏈很深的時候,這相當於測試用例的粒度變大了,返回的結果情況也會因爲調用鏈的深度而變複雜。
又或者測試的方法/函數有調用遠程數據源或者遠程接口,這種情況往往測試依賴性很高。如果數據庫沒有準備好,或者遠程接口不允許測試,那麼單元測試就沒辦法進行下去。這樣是打擊了寫單元測試的熱情。

以上情況,其實我們通常會用內嵌數據庫或者Mock來解決。下面就來介紹一下他們的用途

內嵌數據庫

在開發應用的過程中使用內嵌的內存數據庫是非常方便的,很明顯,內存數據庫不提供數據的持久化存儲;當應用啓動時你需要填充你的數據庫,當應用結束時數據將會丟棄

內嵌數據庫一般使用

MysqlH2
MongoDBfongo
Redisembedded-redis

例子

此處結合Mybatis-plus的初始化工程看看如何使用H2內嵌數據庫

添加依賴

		<dependency>
			<groupId>com.h2database</groupId>
			<artifactId>h2</artifactId>
			<scope>runtime</scope>
		</dependency>
		<dependency>
			<groupId>com.baomidou</groupId>
			<artifactId>mybatis-plus-boot-starter</artifactId>
		</dependency>

配置

# DataSource Config
spring:
  datasource:
    driver-class-name: org.h2.Driver
    schema: classpath:db/schema-h2.sql
    data: classpath:db/data-h2.sql
    url: jdbc:h2:mem:test
    username: root
    password: test
    initialization-mode: always

# Logger Config
logging:
  level:
    com.baomidou.mybatisplus.samples.quickstart: debug

schema-h2.sql

DROP TABLE IF EXISTS user;

CREATE TABLE user
(
	id BIGINT(20) NOT NULL COMMENT '主鍵ID',
	name VARCHAR(30) NULL DEFAULT NULL COMMENT '姓名',
	age INT(11) NULL DEFAULT NULL COMMENT '年齡',
	email VARCHAR(50) NULL DEFAULT NULL COMMENT '郵箱',
	PRIMARY KEY (id)
);

data-h2.sql

DELETE FROM user;

INSERT INTO user (id, name, age, email) VALUES
(1, 'Jone', 18, '[email protected]'),
(2, 'Jack', 20, '[email protected]'),
(3, 'Tom', 28, '[email protected]'),
(4, 'Sandy', 21, '[email protected]'),
(5, 'Billie', 24, '[email protected]');

Spring boot啓動類添加@MapperScan註解

@SpringBootApplication
@MapperScan("com.example.h2.mapper")
public class H2Application {

	public static void main(String[] args) {
		SpringApplication.run(H2Application.class, args);
	}

}

編碼

Entity 實體類

@Data
public class User {
    private Long id;
    private String name;
    private Integer age;
    private String email;
}

Mapper類

public interface UserMapper extends BaseMapper<User> {

}

啓動

@SpringBootTest
public class SampleTest {

    @Autowired
    private UserMapper userMapper;

    @Test
    public void testSelect() {
        System.out.println(("----- selectAll method test ------"));
        List<User> userList = userMapper.selectList(null);
        Assert.assertEquals(5, userList.size());
        userList.forEach(System.out::println);
    }

}

控制檯輸出

User(id=1, name=Jone, age=18, email=test1@baomidou.com)
User(id=2, name=Jack, age=20, email=test2@baomidou.com)
User(id=3, name=Tom, age=28, email=test3@baomidou.com)
User(id=4, name=Sandy, age=21, email=test4@baomidou.com)
User(id=5, name=Billie, age=24, email=test5@baomidou.com)

使用Mock測試

上文用到了內嵌數據庫可以完成單例測試,但是使用還是很繁瑣,需要初始化數據庫,另外如果是測試的函數中需要調用其他服務的接口,這時候就不是內嵌數據庫可以解決的。因此我們可以使用另外一種方法,Mock測試。Mock是對於一些不容易構造/獲取的對象,創建一個Mock對象來模擬對象的行爲。Mock對象是虛構的,是可以構造任意你想要的數據。

在本章中,主要使用Mockito,一個強大的用於 Java 開發的模擬測試框架,而且使用簡單。官方中文文檔

Maven依賴

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>2.0.111-beta</version>
</dependency>

編碼

  1. 利用Mock對象記錄行爲

一旦mock對象被創建了,mock對象會記住所有的交互。你可以驗證是否存在該操作

public void testMockito() {
        //構建Mock對象
        List mock = Mockito.mock(List.class);
        //使用mock對象
        mock.add("one");

        //驗證 mock對象是否進行過這些操作
        Mockito.verify(mock).add("one");
        //會拋出錯誤,因爲沒有進行過這個操作
       // Mockito.verify(mock).remove("one");

    }
  1. Mock最核心的功能,做測試樁(Stub)

測試樁Stub是什麼呢?
寫碼的時候你會遇到一些外部依賴,比如在本機上寫代碼,可能會調用谷歌的API,來完成遠程調用。而我在做測試的時候並不想真的發出這個請求,(貴,得不到想要的結果),因此我選擇通過某種方式(Mockito)來進行模擬。Stub指的就是這種模擬,把服務端的依賴用本機來進行模擬
作者:CC stone
鏈接:https://www.zhihu.com/question/21017494/answer/604154516

    @Test
    public void testMockitoStub() {
        // 你可以mock具體的類型,不僅只是接口
        LinkedList mockedList = Mockito.mock(LinkedList.class);

        //測試樁,這個算是埋點。當我們調用mockedList.get(0)的時候,會返回first
        Mockito.when(mockedList.get(0)).thenReturn("first");

        // 輸出“first”
        System.out.println(mockedList.get(0));
    }

測試例子

不定期更新測試例子
主要寫一下平時工作中會用到的測試方式,隨着水平提高,應該會對單元測試有更深的理解

注意: 測試用例統一使用的JUnit5

Junit5 的使用

JUnit 5 是一個項目名稱(和版本),其 3 個主要模塊關注不同的方面:JUnit JupiterJUnit PlatformJUnit Vintage。在單元測試中,我們通常只是使用到JUnit Jupiter

Junit Jupiter 模塊

Junit Jupiter模塊用於編寫單元測試,包含兩個部分 JUnit Jupiter APIJUnit Jupiter Test Engine

  • JUnit Jupiter API:使用 JUnit Jupiter API 創建單元測試來測試您的應用程序代碼。使用該 API 的基本特性 — 註解、斷言等
  • JUnit Jupiter Test Engine:發現和執行 JUnit Jupiter 單元測試,可將 JUnit Jupiter Test Engine 看作單元測試與用於啓動它們的工具(比如 IDE)之間的橋樑
JUnit Platform模塊

這個模塊主要用於發現測試API和執行測試API。JUnit Platform 負責使用 IDE 和構建工具(比如 Gradle 和 Maven)發起測試發現流程。以前我們常用``@RunWith(SpringRunner.class)```在JUnit4,在JUnit5我們使用@RunWith(JUnitPlatform.class)`(對於一些支持JUnit5得IDE,不需要此註解了)

JUnit Vintage 模塊

該模塊主要是爲了兼容JUnit4。這個模塊包含junit-vintage-enginejunit-jupiter-migration-support 組件

JUnit Platform 而言,JUnit Vintage 只是另一個測試框架,包含自己的 TestEngineJUnit API

Junit5 註解

Junit5的註解與Junit4還是有不少區別的

註解 描述
@Test 表示測試方法,該註解沒有任何屬性,因爲JUnit Jupiter測試擴展有專門的註解操作
@BeforeEach 表示被註解的方法應在當前類的每個@Test,類似於JUnit 4的@Before
@AfterEach 表示被註解的方法應該在當前類的所有@Test,類似於JUnit 4的@After
@BeforeAll 表示被註解的方法應該在當前類的所有@Test,類似於JUnit 4的@BeforeClass
@AfterAll 表示被註解的方法應該在當前類的所有@Test,類似於JUnit 4的@AfterClass
@RunWith 對於支持Junit5的IDE,不需要此註解。對於未支持的需要@RunWith(JUnitPlatform.class)使用
@DisplayName 聲明測試類或者測試方法的自定義顯示名稱
@Disabled 聲明JUnit不允許此@Test方法
Junit5 斷言

如果斷言失敗,用例即結束。

JUnit Jupiter提供了許多JUnit4已有的斷言方法,並增加了一些適合與Java 8 lambda一起使用的斷言方法。

org.junit.jupiter.api.Assertions類提供

更多例子,可以查看官方文檔

    @Test
    void standardAssertions() {
        Assertions.assertEquals(2, 2);
        Assertions.assertEquals(4, 4, "The optional assertion message is now the last parameter.");
        Assertions.assertTrue(2 == 2, () -> "Assertion messages can be lazily evaluated -- "
                + "to avoid constructing complex messages unnecessarily.");
    }

    @Test
    void groupedAssertions() {
        // In a grouped assertion all assertions are executed, and any
        // failures will be reported together.
        Assertions.assertAll("person",
            () -> assertEquals("John", person.getFirstName()),
            () -> assertEquals("Doe", person.getLastName())
        );
    }
Junit5 假設

如果假設失敗,相關測試用例被忽略,但與假設同級別的收尾工作還要繼續執行。

JUnit Jupiter附帶了JUnit4提供的一些assumption方法的子集,並增加了一些適合與Java 8 lambda一起使用的方法。

org.junit.jupiter.Asumptions類提供

更多例子,可以查看官方文檔

    @Test
    void testOnlyOnCiServer() {
        Asumptions.assumeTrue("CI".equals(System.getenv("ENV")));
        // remainder of test
    }

    @Test
    void testOnlyOnDeveloperWorkstation() {
        Asumptions.assumeTrue("DEV".equals(System.getenv("ENV")),
            () -> "Aborting test: not on developer workstation");
        // remainder of test
    }
Junit5 參數化測試

有時候我們需要傳值測試,在Junit5中,支持我們傳入參數進行測試。

數據庫的初始化請看上文的內嵌數據庫

@JdbcTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY)
public class ParameterTest {
    @Autowired
    private JdbcTemplate jdbcTemplate;

    @ParameterizedTest
    @ValueSource(longs = {1L,2L,3L,4L,5L})
    public void parameterTest(long id) {
        String sql = "select * from user where id=?";
        RowMapper<User> rowMapper=new BeanPropertyRowMapper<User>(User.class);
        List<User> users = jdbcTemplate.query(sql, rowMapper,id);
        System.out.println(users);
    }
}

參數化測試需要用到@ParameterizedTest@ValueSource

Spring boot 單元測試

spring-boot-starter-test 測試包

spirng boot 對於測試,提供了一個spring-boot-starter-test

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>

在這個包中,包含了以下的庫

  • Junit5:單元測試Java,目標是爲JVM上的開發人員端測試創建最新的基礎。官方文檔
  • Spring測試和Spring boot 測試:Spring Boot應用程序的實用程序和集成測試支持
  • AssertJ:一個流程的斷言庫
  • Hamcrest:匹配器對象庫
  • Mockito:一個Java模擬框架
  • JSONassert:JSON的斷言庫
  • JsonPath:JSON的XPath
簡單的Spring boot 單元測試
@SpringBootTest
public class Test {
    
    @org.junit.jupiter.api.Test
    public void test() {
        System.out.println("Hello World");
    }
}
  1. 使用的註解是的包是org.junit.jupiter.api 這是JUnit5,而不是org.junit
  2. 由於使用JUnit5,所以@RunWith(SpringRunner.class)已經不再需要了

@SpringBootTest 工作原理與項目的啓動類中@SpringBootApplication差不多。@SpringBootTest提供了webEnvironment屬性,默認是MOCK,不會啓動嵌入式服務器,所以不會起端口。

具體參數如下:

  • MOCK(默認):不會啓動嵌入式服務器。但是提供模擬網絡環境,可以使用@AutoConfigureMockMvc或者@AutoConfigureWebTestClient測試Web應用程序接口。

  • RANDOM_PROT:啓動嵌入式服務器,並且隨機監聽端口

  • DEFINED_PORT:啓動嵌入式服務器,定義配置文件的端口或者默認端口8080

  • NONE:不提供任何模擬網絡環境

spring boot 分片測試

當項目很龐大,每次啓動耗時都很長的時候,就需要考慮只加載需要測試的配置和資源。**spring boot **提供了很多自動配置的註解,這些註解只會加載對應的資源信息,這會大大提高你的單元測試效率。

**spring boot **提供的註解如下,更多內容請查閱官方文檔

  • @DataJdbcTest :加載JdbcTemplateAutoConfigurationDataSourceAutoConfiguration等配置。
  • @DataJpaTest: 加載HibernateJpaAutoConfiguration,DataSourceAutoConfiguration
  • @DataLdapTest:加載LdapAutoConfiguration
  • @DataMongoTest:加載Mongodb配置
  • @DataNeo4jTest:加載Neo4j
  • @DataRedisTest:加載redis
  • @JdbcTest:加載DataSource
  • @JooqTest:加載Jooq
  • @JsonTest: 加載Json配置,GSON,Jackson都支持
  • @RestClientTest:加載RestTemplate配置
  • @WebFluxTest:加載WebFlut配置
  • @WebMvcTest:加載SpringMvc配置
數據庫測試,@JdbcTest的使用
@JdbcTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY)
public class H2Test {
    @Autowired
    private DataSource dataSource;

    @Test
    public void h2Test() {
        System.out.println(dataSource);
    }
}

@JdbcTest註解會自動加載以下類相關的配置

org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration 
org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration 
org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration 
org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration 
org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration 
org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration 
org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration 
org.springframework.boot.test.autoconfigure.jdbc.TestDatabaseAutoConfiguration

@AutoConfigureTestDatabase有3種模式

  • ANY: 測試數據源代替所有的自動配置數據源和手動定義的數據源
  • AUTO_CONFIGURED:測試數據源僅代替所有自動配置的數據源
  • NONE:不代替系統默認數據源,當你不想啓動內嵌數據庫的時候,可以選擇這個模式
Spring MVC測試 @WebMvcTest
  1. 場景1

Controller類

@RestController
@RequestMapping("/user")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }


    @GetMapping("/info")
    public User userInfo(@RequestParam long id) {
        return userService.findById(id);
    }
}

測試類

@WebMvcTest(UserController.class)
public class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;
    @MockBean
    private UserService userService;


    @Test
    public void testMvc() throws Exception {
        Mockito.when(userService.findById(Mockito.eq(1L))).thenReturn(buildUser());
        /**
         *  mockMvc.perform 開始執行一個請求
         *  MockMvcRequestBuilders.get(xxx) 構建一個get方法的請求
         *  accept(MediaType.APPLICATION_JSON_UTF8_VALUE) header頭信息,Accept:"application/json;charset=UTF-8"
         *  param 請求參數
         *  andExpect  添加執行完成後的斷言
         *  andDo 返回結果處理器,可以添加一個對結果處理的Handler,例如MockMvcResultHandlers.print()
         */
        mockMvc.perform(MockMvcRequestBuilders.get("/user/info")
                .accept(MediaType.APPLICATION_JSON_UTF8_VALUE)
                .param("id","1"))
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.content().json("{\"id\":1,\"name\":\"測試人員1\",\"age\":15,\"email\":\"[email protected]\"}"))
                .andDo(MockMvcResultHandlers.print());
    }

    private User buildUser() {
        return new User().setId(1).setName("測試人員1").setAge(15).setEmail("[email protected]");
    }

@WebMvcTest(UserController.class)Spring boot提供的單個片測試註解,value=UserController.class 相當於StandaloneMockMvcBuilder測試方式(相對應的還有DefaultMockMvcBuilder集成Web環境測試),獨立構建UserController的Web環境。

@MockBean用在構建UserService對象。因爲UserService需要查詢數據庫,有點麻煩。所以我就想用Mock方式了,主要想測試Controller層,就沒必要關心其他層了。

  1. 場景2

當你想訪問真正的Service層邏輯的時候,而不是用Mock構建Service層對象時。我們可以用MockMvcBuilders來構建我們想要的MockMvc

@SpringBootTest
@ActiveProfiles("test")
class AccountControllerTest {

    private MockMvc mockMvc;
    @Autowired
    protected WebApplicationContext wac;


    @BeforeEach
    @DisplayName("初始化MockMvc")
    public void init() {
        UserController bean = wac.getBean(UserController.class);
        mockMvc = MockMvcBuilders.standaloneSetup(bean).build();
    }
  
    
    @Test
    public void testMvc() throws Exception {
        //測試代碼同上
    }
    
}

WebApplicationContext是實現ApplicationContext接口的子類。 它允許從相對於Web根目錄的路徑中加載配置文件完成初始化工作。從WebApplicationContext中可以獲取ServletContext引用,整個Web應用上下文對象將作爲屬性放置在ServletContext中,以便Web應用環境可以訪問Spring上下文。

MockMvcBuilders.standaloneSetup(bean) 構建單個ControllerMockMvc,可以提高項目啓動速度。

控制檯輸出結果

request 信息

MockHttpServletRequest:
      HTTP Method = GET
      Request URI = /user/info
       Parameters = {id=[1]}
          Headers = [Accept:"application/json;charset=UTF-8"]
             Body = <no character encoding set>
    Session Attrs = {}

response信息

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"application/json;charset=UTF-8"]
     Content type = application/json;charset=UTF-8
             Body = {"id":1,"name":"測試人員1","age":15,"email":"[email protected]"}
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

Json 測試 @JsonTest
@JsonTest
public class MyJsonTest {

    
    /*
    * 相對應的,你也可以使用@JsonbTester,@GsonTester,@BasicJsonTester
    */
    @Autowired
    private JacksonTester<User> jacksonTester;

    @BeforeEach
    public void init() {
        ObjectMapper objectMapper = new ObjectMapper();
        JacksonTester.initFields(jacksonTester,objectMapper);
    }
    
    @Test
    public void t() throws IOException {
        JsonContent<User> jsonContent = jacksonTester.write(buildUser());
        //斷言Json串是一樣
        Assertions.assertThat(jsonContent).isEqualToJson("{\"id\":1,\"name\":\"測試人員1\",\"age\":15}");
        //斷言name屬性的值是測試人員1
        Assertions.assertThat(jsonContent).hasJsonPathStringValue("name","測試人員1");
        //斷言email屬性是空的
        Assertions.assertThat(jsonContent).hasEmptyJsonPathValue("email");
    }

    private User buildUser() {
        return new User().setId(1).setName("測試人員1").setAge(15).setEmail("[email protected]");
    }
}

@JsonTest 註解會自動配置Jackson的ObjectMapper,所有@JsonComponentbean和Jackson Modules

如果使用Jackson,你想自定義ObjectMapper,可以在@BeforeEach的方法中使用JacksonTester.initFields方法。

同樣地Json測試也有提供斷言方法。可以用org.assertj.core.api.Assertions類來斷言JsonContent對象

參考文章:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章