原文博客:Doi技術團隊
鏈接地址:https://blog.doiduoyi.com
初心:記錄優秀的Doi技術團隊學習經歷
系列目錄
引言
這篇文章文中的實用例子只是一個拋磚引玉的作用。
適合新手學習,或者時間充裕可以深入研究以這篇爲目錄進行查漏補缺。
瞭解單元測試
單元測試屬於小型測試,針對單個函數的測試,關注其內部邏輯輸出的結果是否正確。如果將一個單元測試看成是一個單位,只需保證每一個單元測試都通過,則可以大大提高項目質量。單元測試可以保證能夠代碼覆蓋率達到100%的測試。
但是我們往往在開發中都不願意好好寫單元測試,理由有很多,絕大多數如下:
- 需求趕,沒有足夠的時間寫單元測試
- 功能需求太簡單,沒有必要寫單元測試
- 當需求變動的時候,又要修改單元測試,增加了開發時間
- 應該交給測試人員來完成
…
以上的問題,其實對於每一個項目普遍存在。在以前,我也是這樣的心態,不願意寫單元測試。但當我嘗試了幾次單例的帶來的甜頭後,越發喜歡和習慣寫單元測試。我覺得當你認識到單元測試的意義,以及熟悉使用單元測試,你自然會打消以上的疑慮並且愛上它。
單元測試的意義
- 它可以保證你寫的代碼是你想要的結果。這個點很重要,因爲在編程中,經常會敲錯代碼導致結果並不是自己腦子裏想的。如果不經過單元測試測試下運行結果,那麼代碼質量是肯定保證不了的。
- 單元測試是最少單位,一個高可用的系統需要靠一個一個最小的穩定的單位組成。所以保證一個最小單位的準確率是必須的。
- 單元測試應該是快速的,因此它不應該使用任何Web服務器。
- 每個單元測試應該獨立於其他測試。
- 當出現問題的時候,單元測試可以很快幫助你排查問題。因爲單元測試保證你寫的代碼是你想要的結果,當出現異常效果,只需要從對應的單元測試是排查,就可以很快定位問題。
如何實現單元測試
在討論如果實現單元測試的之前,我們要先想想,什麼是好的單元測試呢?
- 完整性:覆蓋率高,意思就是對各種情況都要考慮到
- 健壯性:具有健壯性的單元測試,完全不需要被修改或者只有極少的修改。因爲單元測試只是關注輸出結果是否符合期望,如果只是修改了實現邏輯,那麼單元測試是不需要改動的。
- 粒度細:其實這裏跟代碼的設計和實現有關。考慮到單測實現的簡潔,把各個功能分成每個函數,保證粒度足夠細。(評判代碼或者設計好不好的⼀個準則是看它容不容易測試)
那麼接下來我們要討論下需要測試什麼?
上文已經提到,單元測試測試是最小粒度的代碼,通常是一個方法或函數。通常是通過⼀系列不同的⾏爲。⾏爲就是對不同的輸⼊場景有不同的輸出,每⼀個⾏爲都需要獨⽴的單測。
實戰
接下來,我們來討論一下如何寫單元測試
如何保證單元測試細粒度
在實戰前,我們要考慮如何保證單元測試的細粒度呢?
在絕大多數業務中,單個方法/函數也是有調用其他方法/函數的,那麼當我們測試的方法調用鏈很深的時候,這相當於測試用例的粒度變大了,返回的結果情況也會因爲調用鏈的深度而變複雜。
又或者測試的方法/函數有調用遠程數據源或者遠程接口,這種情況往往測試依賴性很高。如果數據庫沒有準備好,或者遠程接口不允許測試,那麼單元測試就沒辦法進行下去。這樣是打擊了寫單元測試的熱情。
以上情況,其實我們通常會用內嵌數據庫或者Mock來解決。下面就來介紹一下他們的用途
內嵌數據庫
在開發應用的過程中使用內嵌的內存數據庫是非常方便的,很明顯,內存數據庫不提供數據的持久化存儲;當應用啓動時你需要填充你的數據庫,當應用結束時數據將會丟棄
內嵌數據庫一般使用
Mysql
:H2
MongoDB
:fongo
Redis
:embedded-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>
編碼
- 利用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");
}
- 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 Jupiter
、JUnit Platform
和 JUnit Vintage
。在單元測試中,我們通常只是使用到JUnit Jupiter
Junit Jupiter
模塊
Junit Jupiter
模塊用於編寫單元測試,包含兩個部分 JUnit Jupiter API
和 JUnit 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-engine
和 junit-jupiter-migration-support
組件
對 JUnit Platform
而言,JUnit Vintage
只是另一個測試框架,包含自己的 TestEngine
和 JUnit 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");
}
}
- 使用的註解是的包是
org.junit.jupiter.api
這是JUnit5,而不是org.junit
。 - 由於使用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
:加載JdbcTemplateAutoConfiguration
,DataSourceAutoConfiguration
等配置。@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
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
層,就沒必要關心其他層了。
- 場景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)
構建單個Controller
的MockMvc
,可以提高項目啓動速度。
控制檯輸出結果
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
,所有@JsonComponent
bean和Jackson Modules
。
如果使用Jackson
,你想自定義ObjectMapper
,可以在@BeforeEach
的方法中使用JacksonTester.initFields
方法。
同樣地Json
測試也有提供斷言方法。可以用org.assertj.core.api.Assertions
類來斷言JsonContent
對象
參考文章: