title: 最佳單元測試實踐
date: 2021/09/08 15:11
注:本文使用的是 SpringBootTest2.x + Junit4 + Mockito,本文的前提是你已經(jīng)會使用這些工具了。
引言:常見單元測試方法
@RunWith(SpringRunner.class)
@SpringBootTest(classes = Application.class) // 1
@Transactional // 2
@Rollback
public class HelloServiceTest {
@Autowired
private HelloService helloService;
@Test
public void sayHello() {
helloService.sayHello("zhangsan"); // 3
}
}
這樣的寫法不符合規(guī)范的地方如下:
- 使用
@SpringBootTest注解將主啟動類 Application 的 BeanDefintion 加入到了 Spring 容器中,從而加載了所有的 Bean,導(dǎo)致大量的時間耗費在于容器啟動上。而且,如果被 @Component 注解的類里有多線程方法,那么在你執(zhí)行單元測試的時候,由于多線程任務(wù)的影響,就可能對你的數(shù)據(jù)庫造成了數(shù)據(jù)修改,即使你使用了事務(wù)回滾注解 @Transactional。
- eg. 在我運行單元測試的時候,代碼里的其他類的多線程中不停接收activeMQ消息,然后更新數(shù)據(jù)庫中對應(yīng)的數(shù)據(jù)。跟單元測試的執(zhí)行過程交叉重疊,導(dǎo)致單元測試失敗。其他組員在操作數(shù)據(jù)庫的時候,也因為我無意中帶起的多線程更改了數(shù)據(jù)庫,造成了開發(fā)上的困難。
- 單元測試應(yīng)與數(shù)據(jù)庫完全隔離(不應(yīng)受到外界環(huán)境的影響,違反了可重復(fù)的原則),數(shù)據(jù)庫相關(guān)操作應(yīng)使用 mock 代替。
- 沒有使用斷言(assert),無法實現(xiàn)自動化判斷。
一、單元測試的原則
1.1 AIR 原則
- Automatic(自動化的):自動通過一系列的斷言給出執(zhí)行結(jié)果,而不需要人為去判斷,在幾十上百的測試用例下很難人為的去判斷。
- Independent(獨立的):測試用例之間不能相互依賴影響,是獨立的
- Repeatable(可重復(fù)的):單元測試是可以重復(fù)執(zhí)行的,不能受到外界環(huán)境的影響,如數(shù)據(jù)庫、遠程調(diào)用、中間件等外部依賴不能影響測試用例的執(zhí)行。
1.2 BCDE 原則
保證被測試模塊的交付質(zhì)量。
- B:Border,邊界值測試,包括循環(huán)邊界、特殊取值、特殊時間點、數(shù)據(jù)順序等。
- C:Correct,正確的輸入,并得到預(yù)期的結(jié)果。
- D:Design,與設(shè)計文檔相結(jié)合,來編寫單元測試。
- E:Error,強制錯誤信息輸入(如:非法數(shù)據(jù)、異常流程、業(yè)務(wù)允許外等),并得到預(yù)期的結(jié)果。
1.3 使用 Mock 對象
Mock 可以用來解除外部服務(wù)依賴,從而保證了測試用例的獨立性
-
Mock 可以減少全鏈路測試數(shù)據(jù)準備,從而提高了編寫測試用例的速度
傳統(tǒng)的集成測試,需要準備全鏈路的測試數(shù)據(jù),可能某些環(huán)節(jié)并不是你所熟悉的。最后,耗費了大量的時間和經(jīng)歷,并不一定得到你想要的結(jié)果。現(xiàn)在的單元測試,只需要模擬上游的輸入數(shù)據(jù),并驗證給下游的輸出數(shù)據(jù),編寫測試用例并進行測試的速度可以提高很多倍。
-
Mock可以模擬一些非正常的流程,從而保證了測試用例的代碼覆蓋率
根據(jù)單元測試的BCDE原則,需要進行邊界值測試(Border)和強制錯誤信息輸入(Error),這樣有助于覆蓋整個代碼邏輯。在實際系統(tǒng)中,很難去構(gòu)造這些邊界值,也能難去觸發(fā)這些錯誤信息。而 Mock 從根本上解決了這個問題:想要什么樣的邊界值,只需要進行Mock;想要什么樣的錯誤信息,也只需要進行Mock。
Mock可以不用加載項目環(huán)境配置,從而保證了測試用例的執(zhí)行速度
在進行集成測試時,我們需要加載項目的所有環(huán)境配置,啟動項目依賴的所有服務(wù)接口。往往執(zhí)行一個測試用例,需要幾分鐘乃至幾十分鐘。采用Mock實現(xiàn)的測試用例,不用加載項目環(huán)境配置,也不依賴其它服務(wù)接口,執(zhí)行速度往往在幾秒之內(nèi),大大地提高了單元測試的執(zhí)行速度。
什么是集成測試?
集成測試是指在單元測試的基礎(chǔ)上,將所有模塊(或單元)按照設(shè)計要求(如根據(jù)結(jié)構(gòu)圖)組裝成為子系統(tǒng)或系統(tǒng),進行集成測試。
實踐表明,一些模塊雖然能夠單獨地工作,但并不能保證連接起來也能正常的工作。 一些局部反映不出來的問題,在全局上很可能暴露出來。
由于集成測試的單位是一整個系統(tǒng),一般有專業(yè)的測試人員來進行,本文只做簡單介紹,不繼續(xù)探討。
二、最佳實踐
寫好單元測試,以下兩點尤為重要:
- 使用 Mock 脫離數(shù)據(jù)庫
-
不使用
@SpringBootTest注解加載全部 BeanDefinition,轉(zhuǎn)而使用@ContextConfiguration注解加載需要的配置類
2.1 使用 Mock 代替數(shù)據(jù)庫
mockito 為 Junit 提供了MockitoJUnitRunner用于解析單元類中 mockito 相關(guān)注解。當然使用 SpringRunner 也行,因為他內(nèi)置了 MockitoTestExecutionListener 來處理 mockito 的注解。
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.MockitoJUnitRunner;
import java.util.Arrays;
import java.util.List;
@RunWith(MockitoJUnitRunner.class)
// 使用 SpringRunner 也行,因為他內(nèi)置了 MockitoTestExecutionListener 來處理 mockito 的注解
// @RunWith(SpringRunner.class)
public class MockitoTest {
/**
* 如 UserService 是接口,則需 new 出他的實現(xiàn)類,如下:
*
* <pre> {@code
* @InjectMocks
* private UserService userService = new UserServiceImpl();
* }</pre>
*
* 否則使用 @InjectMocks 注解無法注入
*/
@InjectMocks
private UserService userService;
@Mock
private UserRepository userRepository;
@Test
public void test() {
// 模擬依賴方法
Mockito.when(userRepository.findAll())
.thenReturn(Arrays.asList(new User(1, "zs"), new User(2, "ls")));
// 調(diào)用被測方法
List<User> users = userService.listAll();
// 斷言方法結(jié)果
Assert.assertEquals(2, users.size());
// 驗證依賴方法
// 是否只調(diào)用了一次 findAll() 方法
Mockito.verify(userRepository).findAll();
// 是否與 userRepository 對象再無交互
Mockito.verifyNoMoreInteractions(userRepository);
}
}
注:如對象較大,則可在類路徑下存放 json 文件,通過 Json 工具將其序列化成對象。
Junit5 版,注意 Test 注解的包名與上面不同
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Arrays;
import java.util.List;
@ExtendWith(MockitoExtension.class)
//@ExtendWith(SpringExtension.class)
public class MockitoTest {
@InjectMocks
private UserService userService;
@Mock
private UserRepository userRepository;
@Test
public void test() {
// 模擬依賴方法
Mockito.when(userRepository.findAll())
.thenReturn(Arrays.asList(new User(1, "zs"), new User(2, "ls")));
// 調(diào)用被測方法
List<User> users = userService.listAll();
// 斷言方法結(jié)果
Assertions.assertEquals(2, users.size());
// 驗證依賴方法
// 是否只調(diào)用了一次 findAll() 方法
Mockito.verify(userRepository).findAll();
// 是否與 userRepository 對象再無交互
Mockito.verifyNoMoreInteractions(userRepository);
}
}
如果一個方法的調(diào)用鏈路如下:
Controller -> Service -> Repo,那么應(yīng)該將其拆分成兩個單元來測試:
- TestController + mockService
- TestService -> mockRepo
如果在測試
Controller的時 mock 了Repo(TestController + @Autowired Service + mockRepo),這樣這就不能叫做單元測試了。單元測試只保證每個單元能夠單獨地工作,但并不能保證連接起來也能正常的工作;上面這種多個跨了多個單元的應(yīng)該使用集成測試。
2.2 只加載需要的 Bean
上面的寫法只適用于不使用 Spring 給我們提供的功能情況下,但往往有的時候我們需要他們給我們提供的功能,就比如通過@Async注解啟動異步任務(wù)。那么這種情況我們要怎樣做單元測試呢?讓我們回到?jīng)]有 SpringBoot 的時代,看看 Spring Test 是怎樣進行單元測試的。
Spring Test 為我們提供了@ContextConfiguration注解,該注解可以加載 Spring 的 xml 配置文件和配置類,使用方式如下:
被測試 bean:
@Component
public class AsyncService {
private static final Logger log = LoggerFactory.getLogger(AsyncService.class);
@Autowired
private FeginClient feginClient;
@Async
public Future<Integer> startTask(String taskInstanceId) {
log.info("taskInstanceId:「{}」", taskInstanceId);
return new AsyncResult<>(feginClient.calc(taskInstanceId));
}
}
單元測試:
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;
import java.util.concurrent.ExecutionException;
@RunWith(SpringRunner.class)
// 只引入需要的 bean
@ContextConfiguration(classes = {
// 被測 bean
AsyncService.class,
// 開啟異步注解解析
SpringTest2.AsyncTestConfig.class,
// 線程池默認的自動配置類,如果自定義了則替換成自定義的配置類
TaskExecutionAutoConfiguration.class
})
public class SpringTest2 {
/**
* 由于需要使用 Spring 提供的異步功能,故需要使用 Spring 提供的 Mock 注解
*/
@MockBean
private FeginClient feginClient;
/**
* AsyncService 中依賴了 FeginClient,會將上面 mock 的對象進行 DI
*/
@Autowired
private AsyncService asyncService;
@Test
public void testCalc() throws ExecutionException, InterruptedException {
// 模擬依賴方法
Mockito.when(feginClient.calc(ArgumentMatchers.anyString())).thenReturn(1);
// 調(diào)用被測方法
Integer result = asyncService.startTask("1").get();
// 斷言方法結(jié)果
Assert.assertEquals(1, result.intValue());
// 驗證依賴方法
Mockito.verify(feginClient).calc(ArgumentMatchers.anyString());
Mockito.verifyNoMoreInteractions(feginClient);
}
@EnableAsync
@Configuration
public static class AsyncTestConfig {
}
}
都寫的挺好的,按照順序看一下:
https://www.cnblogs.com/myitnews/p/12330297.html
https://fanlychie.github.io/post/spring-boot-testing.html
https://blog.51cto.com/codewalker/4375122
單元測試:
- spring test & junit
- @ContextConfiguration、@RunWith(SpringRunner.class)、@Test
切面測試(啟動一部分組件):
- spring-boot-test-autoconfig
- @* Test 系列注解
@DataRedisTest:該注解用于測試對Redis操作,自動掃描被@RedisHash描述的類,并配置Spring Data Redis的庫。該注解會啟動一個內(nèi)存中的Redis服務(wù)器,并使用隨機端口進行監(jiān)聽,同時自動配置RedisTemplate和StringRedisTemplate等bean,以便我們可以輕松地執(zhí)行Redis操作。
@DataJpaTest:該注解用于測試基于JPA的數(shù)據(jù)庫操作,同時提供了TestEntityManager替代JPA的EntityManager。該注解會用嵌入式的數(shù)據(jù)庫(例如H2)創(chuàng)建一個測試環(huán)境,同時自動配置EntityManagerFactory、DataSource、TransactionManager等bean,以便我們可以輕松地測試JPA實體和Repository層代碼。
@DataJdbcTest:該注解用于測試基于Spring Data JDBC的數(shù)據(jù)庫操作。該注解會用嵌入式的數(shù)據(jù)庫(例如H2)創(chuàng)建一個測試環(huán)境,并自動配置JdbcTemplate、NamedParameterJdbcTemplate等bean,以便我們可以輕松地測試JDBC操作。
@JsonTest:該注解用于測試JSON的序列化和反序列化。該注解會自動配置Jackson ObjectMapper bean,并提供了一些輔助方法,方便我們進行JSON序列化和反序列化操作。
@WebMvcTest:該注解用于測試Spring MVC中的controllers。該注解會自動配置MockMvc bean,并提供了一些輔助方法,方便我們進行controller層的測試操作。
@WebFluxTest:該注解用于測試Spring WebFlux中的controllers。該注解會自動配置WebTestClient bean,并提供了一些輔助方法,方便我們進行WebFlux相關(guān)的測試操作。
@RestClientTest:該注解用于測試對REST客戶端的操作。該注解會自動配置RestTemplate bean,并提供了一些輔助方法,方便我們進行REST客戶端相關(guān)的測試操作。
@DataLdapTest:該注解用于測試對LDAP的操作。該注解會使用嵌入式的LDAP服務(wù)器創(chuàng)建一個測試環(huán)境,并自動配置LdapTemplate bean和EmbeddedLdap bean,以便我們可以輕松地測試LDAP操作。
@DataMongoTest:該注解用于測試對MongoDB的操作。該注解會用嵌入式的MongoDB服務(wù)器創(chuàng)建一個測試環(huán)境,并自動配置MongoTemplate、MongoClient等bean,以便我們可以輕松地測試MongoDB操作。
@DataNeo4jTest:該注解用于測試對Neo4j的操作。該注解會使用嵌入式的Neo4j服務(wù)器創(chuàng)建一個測試環(huán)境,并自動配置Neo4jTemplate、Neo4jClient等bean,以便我們可以輕松地測試Neo4j操作。
功能測試(啟動全部容器):@SpringBootTest
契約測試:todo
import com.dist.xdata.dgpm.dao.TopicCatalogRepo;
import com.dist.xdata.dgpm.entity.TopicCatalog;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import javax.persistence.EntityManager;
import java.util.List;
/**
* @author yujx
* @since 2023/6/13 4:53 PM
*/
@DataJpaTest
@ExtendWith(SpringExtension.class)
public class TestDataJpa {
@Autowired
private EntityManager entityManager;
@Autowired
private TestEntityManager testEntityManager;
@Autowired
private TopicCatalogRepo repo;
@Test
public void test() {
System.out.println("entityManager = " + entityManager);
System.out.println("testEntityManager = " + testEntityManager);
TopicCatalog save = repo.save(RandomUtil.nextObject(TopicCatalog.class));
System.out.println("save = " + save);
List<TopicCatalog> all = repo.findAll();
System.out.println("all = " + all);
}
}
https://raw.githubusercontent.com/x54256/pic_home/master/img/202306131738520.png
三、切片測試
測試一個普通接口
@WebMvcTest(value = {XWebTestController.class}) // 被測類
public class XWebTestControllerMvcTest {
@Autowired
private MockMvc mockMvc;
/**
* 測試 /rest/xweb/result_wrapper/dto 端點
* 驗證返回的HTTP狀態(tài)碼為200 OK
* 驗證響應(yīng)內(nèi)容是一個空的DemoDTO對象(JSON格式)
*/
@Test
public void testResultDtoWrapper() throws Exception {
MvcResult mvcResult = mockMvc.perform(get("/rest/xweb/dto"))
.andExpect(status().isOk())
.andExpect(content().contentType("application/json"))
.andExpect(jsonPath("$.id").hasJsonPath())
.andExpect(jsonPath("$.name").isEmpty())
.andExpect(jsonPath("$.createTime").doesNotExist())
.andReturn();
String contentAsString = mvcResult.getResponse().getContentAsString();
System.out.println("contentAsString = " + contentAsString);
}
}
結(jié)果:
{"id":null,"name":null,"createTime":null}
我們發(fā)現(xiàn)結(jié)果沒有進行包裝(ResultWrapper),怎么辦呢?有兩種方法:
-
通過 spring-test 的 org.springframework.test.context.ContextConfiguration 注解引入所有相關(guān)的類(普通類、配置類、自動配置類)【推薦】
注意:@WebMvcTest 注解中的 value 字段會被 @ContextConfiguration 注解覆蓋掉(忘記為啥了,反正我有印象)
@WebMvcTest
@ContextConfiguration(classes = {
XWebTestController.class, // 被測類
XWebAutoConfiguration.class, // xdata-web 模塊的自動配置類
XDataInfrastructureAutoConfiguration.class, // xdata-web 模塊依賴的基礎(chǔ)配置類
XRbacAutoConfiguration.class // xdata-web 模塊依賴的用戶信息配置類
})
public class XWebTestControllerMvcTest {
@Autowired
private MockMvc mockMvc;
@Test
public void testResultDtoWrapper() throws Exception {
MvcResult mvcResult = mockMvc.perform(get("/rest/xweb/dto"))
.andExpect(status().isOk())
.andExpect(content().contentType("application/json"))
.andExpect(jsonPath("$.data.id").hasJsonPath())
.andExpect(jsonPath("$.data.name").isEmpty())
.andExpect(jsonPath("$.data.createTime").doesNotExist())
.andReturn();
String contentAsString = mvcResult.getResponse().getContentAsString();
System.out.println("contentAsString = " + contentAsString);
}
}
- 通過 org.springframework.boot.autoconfigure.ImportAutoConfiguration 引入自動配置類(不能用來引入普通類、配置類)【不推薦】
@WebMvcTest(value = {XWebTestController.class}) // 被測類
@ImportAutoConfiguration(value = {
XWebAutoConfiguration.class, // xdata-web 模塊的自動配置類
XDataInfrastructureAutoConfiguration.class, // xdata-web 模塊依賴的基礎(chǔ)配置類
XRbacAutoConfiguration.class // xdata-web 模塊依賴的用戶信息配置類
})
public class XWebTestControllerMvcTest {
@Autowired
private MockMvc mockMvc;
@Test
public void testResultDtoWrapper() throws Exception {
MvcResult mvcResult = mockMvc.perform(get("/rest/xweb/dto"))
.andExpect(status().isOk())
.andExpect(content().contentType("application/json"))
.andExpect(jsonPath("$.data.id").hasJsonPath())
.andExpect(jsonPath("$.data.name").isEmpty())
.andExpect(jsonPath("$.data.createTime").doesNotExist())
.andReturn();
String contentAsString = mvcResult.getResponse().getContentAsString();
System.out.println("contentAsString = " + contentAsString);
}
}
返回結(jié)果:
{
"app": "product",
"version": "unknown",
"buildTime": "unknown",
"status": 200,
"code": 1,
"respCode": "00000",
"message": "success",
"data": {
"id": null,
"name": null,
"createTime": null
},
"detailMessage": null,
"detailStackTraces": null
}