自信地構建:JUnit 測試指南

已發表: 2022-03-11

隨著技術和行業的進步,從瀑布模型到敏捷,再到現在的 DevOps,應用程序中的更改和增強功能在它們產生的那一刻就被部署到生產環境中。 隨著代碼如此快速地部署到生產中,我們需要確信我們的更改是有效的,並且它們不會破壞任何預先存在的功能。

為了建立這種信心,我們必須有一個自動回歸測試的框架。 為了進行回歸測試,應該從 API 級別的角度進行許多測試,但這裡我們將介紹兩種主要類型的測試:

  • 單元測試,任何給定的測試都涵蓋程序的最小單元(函數或過程)。 它可能會或可能不會接受一些輸入參數,並且可能會或可能不會返回一些值。
  • 集成測試,將單個單元一起測試以檢查所有單元是否按預期相互交互。

每種編程語言都有許多可用的框架。 我們將專注於為使用 Java 的 Spring 框架編寫的 Web 應用程序編寫單元和集成測試。

大多數時候,我們在一個類中編寫方法,而這些方法又與其他類的方法交互。 在當今世界——尤其是在企業應用程序中——應用程序的複雜性使得單個方法可能調用多個類的多個方法。 因此,在為這種方法編寫單元測試時,我們需要一種從這些調用中返回模擬數據的方法。 這是因為此單元測試的目的是僅測試一種方法,而不是該特定方法進行的所有調用。


讓我們使用 JUnit 框架進入 Spring 中的 Java 單元測試。 我們將從您可能聽說過的東西開始:嘲笑。

什麼是嘲笑,什麼時候出現?

假設您有一個類CalculateArea ,它有一個函數calculateArea(Type type, Double... args)計算給定類型(圓形、正方形或矩形)的形狀的面積。

代碼在不使用依賴注入的普通應用程序中是這樣的:

 public class CalculateArea { SquareService squareService; RectangleService rectangleService; CircleService circleService; CalculateArea(SquareService squareService, RectangleService rectangeService, CircleService circleService) { this.squareService = squareService; this.rectangleService = rectangeService; this.circleService = circleService; } public Double calculateArea(Type type, Double... r ) { switch (type) { case RECTANGLE: if(r.length >=2) return rectangleService.area(r[0],r[1]); else throw new RuntimeException("Missing required params"); case SQUARE: if(r.length >=1) return squareService.area(r[0]); else throw new RuntimeException("Missing required param"); case CIRCLE: if(r.length >=1) return circleService.area(r[0]); else throw new RuntimeException("Missing required param"); default: throw new RuntimeException("Operation not supported"); } } }
 public class SquareService { public Double area(double r) { return r * r; } }
 public class RectangleService { public Double area(Double r, Double h) { return r * h; } }
 public class CircleService { public Double area(Double r) { return Math.PI * r * r; } }
 public enum Type { RECTANGLE,SQUARE,CIRCLE; }

現在,如果我們想對CalculateArea類的函數calculateArea()進行單元測試,那麼我們的動機應該是檢查switch case 和異常條件是否有效。 我們不應該測試形狀服務是否返回正確的值,因為如前所述,對函數進行單元測試的目的是測試函數的邏輯,而不是函數調用的邏輯。

因此,我們將模擬各個服務函數返回的值(例如rectangleService.area()並根據這些模擬值測試調用函數(例如CalculateArea.calculateArea() )。

矩形服務的一個簡單測試用例——測試calculateArea()確實使用正確的參數調用了rectangleService.area() ——看起來像這樣:

 import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.mockito.Mockito; public class CalculateAreaTest { RectangleService rectangleService; SquareService squareService; CircleService circleService; CalculateArea calculateArea; @Before public void init() { rectangleService = Mockito.mock(RectangleService.class); squareService = Mockito.mock(SquareService.class); circleService = Mockito.mock(CircleService.class); calculateArea = new CalculateArea(squareService,rectangleService,circleService); } @Test public void calculateRectangleAreaTest() { Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d); Assert.assertEquals(new Double(20d),calculatedArea); } }

這裡需要注意的兩條主要線路是:

  • rectangleService = Mockito.mock(RectangleService.class); ——這會創建一個模擬對象,它不是一個實際的對象,而是一個模擬對象。
  • Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); ——這就是說,當被模擬時,使用指定的參數調用rectangleService對象的area方法,然後返回20d

現在,當上面的代碼是 Spring 應用程序的一部分時會發生什麼?

 import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class CalculateArea { SquareService squareService; RectangleService rectangleService; CircleService circleService; public CalculateArea(@Autowired SquareService squareService, @Autowired RectangleService rectangeService, @Autowired CircleService circleService) { this.squareService = squareService; this.rectangleService = rectangeService; this.circleService = circleService; } public Double calculateArea(Type type, Double... r ) { // (same implementation as before) } }

這裡我們有兩個註解供底層 Spring 框架在上下文初始化時檢測:

  • @Component : 創建一個CalculateArea類型的bean
  • @Autowired :搜索 beans rectangleServicesquareServicecircleService並將它們注入到 bean calculatedArea

同樣,我們也為其他類創建 bean:

 import org.springframework.stereotype.Service; @Service public class SquareService { public Double area(double r) { return r*r; } }
 import org.springframework.stereotype.Service; @Service public class CircleService { public Double area(Double r) { return Math.PI * r * r; } }
 import org.springframework.stereotype.Service; @Service public class RectangleService { public Double area(Double r, Double h) { return r*h; } }

現在,如果我們運行測試,結果是一樣的。 我們在這裡使用了構造函數注入,幸運的是,不要更改我們的測試用例。

但是還有另一種注入方形、圓形和矩形服務的bean的方法:字段注入。 如果我們使用它,那麼我們的測試用例將需要一些小的改動。

我們不會討論哪種注入機制更好,因為這不在本文的範圍內。 但我們可以這樣說:無論您使用什麼類型的機制來注入 bean,總有一種方法可以為它編寫 JUnit 測試。

在字段注入的情況下,代碼如下所示:

 @Component public class CalculateArea { @Autowired SquareService squareService; @Autowired RectangleService rectangleService; @Autowired CircleService circleService; public Double calculateArea(Type type, Double... r ) { // (same implementation as before) } }

注意:由於我們使用字段注入,因此不需要參數化構造函數,因此使用默認值創建對象,並使用字段注入機制設置值。

我們服務類的代碼和上面一樣,但是測試類的代碼如下:

 public class CalculateAreaTest { @Mock RectangleService rectangleService; @Mock SquareService squareService; @Mock CircleService circleService; @InjectMocks CalculateArea calculateArea; @Before public void init() { MockitoAnnotations.initMocks(this); } @Test public void calculateRectangleAreaTest() { Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d); Assert.assertEquals(new Double(20d),calculatedArea); } }

這裡有一些不同之處:不是基礎,而是我們實現它的方式。

首先,我們模擬對象的方式:我們使用@Mock註釋和initMocks()來創建模擬。 其次,我們使用@InjectMocksinitMocks()將模擬注入到實際對像中。

這樣做只是為了減少代碼行數。

什麼是測試跑者,跑者有哪些類型?

在上面的示例中,用於運行所有測試的基本運行器是BlockJUnit4ClassRunner ,它檢測所有註釋並相應地運行所有測試。

如果我們想要更多功能,那麼我們可以編寫一個自定義運行器。 例如,在上面的測試類中,如果我們想跳過這行MockitoAnnotations.initMocks(this); 然後我們可以使用構建在BlockJUnit4ClassRunner之上的不同運行器,例如MockitoJUnitRunner

使用MockitoJUnitRunner ,我們甚至不需要初始化模擬並註入它們。 這將由MockitoJUnitRunner本身通過閱讀註釋來完成。

(還有SpringJUnit4ClassRunner ,它初始化 Spring 集成測試所需的ApplicationContext ——就像在 Spring 應用程序啟動時創建ApplicationContext一樣。我們稍後會介紹。)

部分模擬

當我們希望測試類中的一個對像模擬一些方法,但也調用一些實際的方法時,那麼我們需要部分模擬。 這是通過 JUnit 中的@Spy實現的。

與使用@Mock不同,使用@Spy創建一個真實的對象,但可以模擬或實際調用該對象的方法——無論我們需要什麼。

例如,如果類RectangleService中的area方法調用了一個額外的方法log()並且我們實際上想要打印該日誌,那麼代碼將更改為如下所示:

 @Service public class RectangleService { public Double area(Double r, Double h) { log(); return r*h; } public void log() { System.out.println("skip this"); } }

如果我們將rectangleService@Mock註釋更改為@Spy ,並進行如下所示的一些代碼更改,那麼在結果中我們實際上會看到正在打印日誌,但是方法area()將被模擬。 也就是說,原來的函數只是為了它的副作用而運行的; 它的返回值被模擬的替換。

 @RunWith(MockitoJUnitRunner.class) public class CalculateAreaTest { @Spy RectangleService rectangleService; @Mock SquareService squareService; @Mock CircleService circleService; @InjectMocks CalculateArea calculateArea; @Test public void calculateRectangleAreaTest() { Mockito.doCallRealMethod().when(rectangleService).log(); Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d); Assert.assertEquals(new Double(20d),calculatedArea); } }

我們如何測試ControllerRequestHandler

根據我們上面了解到的,我們示例的控制器測試代碼如下所示:

 import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; @Controller public class AreaController { @Autowired CalculateArea calculateArea; @RequestMapping(value = "api/area", method = RequestMethod.GET) @ResponseBody public ResponseEntity calculateArea( @RequestParam("type") String type, @RequestParam("param1") String param1, @RequestParam(value = "param2", required = false) String param2 ) { try { Double area = calculateArea.calculateArea( Type.valueOf(type), Double.parseDouble(param1), Double.parseDouble(param2) ); return new ResponseEntity(area, HttpStatus.OK); } catch (Exception e) { return new ResponseEntity(e.getCause(), HttpStatus.INTERNAL_SERVER_ERROR); } } }
 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 org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @RunWith(MockitoJUnitRunner.class) public class AreaControllerTest { @Mock CalculateArea calculateArea; @InjectMocks AreaController areaController; @Test public void calculateAreaTest() { Mockito .when(calculateArea.calculateArea(Type.RECTANGLE,5.0d, 4.0d)) .thenReturn(20d); ResponseEntity responseEntity = areaController.calculateArea("RECTANGLE", "5", "4"); Assert.assertEquals(HttpStatus.OK,responseEntity.getStatusCode()); Assert.assertEquals(20d,responseEntity.getBody()); } }

查看上面的控制器測試代碼,它運行良好,但它有一個基本問題:它只測試方法調用,而不是實際的 API 調用。 缺少所有需要針對不同輸入測試 API 參數和 API 調用狀態的測試用例。

這段代碼更好:

 import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; @RunWith(SpringJUnit4ClassRunner.class) public class AreaControllerTest { @Mock CalculateArea calculateArea; @InjectMocks AreaController areaController; MockMvc mockMvc; @Before public void init() { mockMvc = standaloneSetup(areaController).build(); } @Test public void calculateAreaTest() throws Exception { Mockito .when(calculateArea.calculateArea(Type.RECTANGLE,5.0d, 4.0d)) .thenReturn(20d); mockMvc.perform( MockMvcRequestBuilders.get("/api/area?type=RECTANGLE&param1=5&param2=4") ) .andExpect(status().isOk()) .andExpect(content().string("20.0")); } }

在這裡,我們可以看到MockMvc如何承擔執行實際 API 調用的工作。 它還具有一些特殊的匹配器,例如status()content() ,可以輕鬆驗證內容。

使用 JUnit 和 Mocks 進行 Java 集成測試

現在我們知道代碼的各個單元可以工作,讓我們確保它們也可以按照我們的預期相互交互。

首先,我們需要實例化所有 bean,這與在應用程序啟動期間 Spring 上下文初始化時發生的事情相同。

為此,我們在一個類中定義所有 bean,比如說TestConfig.java

 import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class TestConfig { @Bean public AreaController areaController() { return new AreaController(); } @Bean public CalculateArea calculateArea() { return new CalculateArea(); } @Bean public RectangleService rectangleService() { return new RectangleService(); } @Bean public SquareService squareService() { return new SquareService(); } @Bean public CircleService circleService() { return new CircleService(); } }

現在讓我們看看我們如何使用這個類並編寫一個集成測試:

 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; @RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration(classes = {TestConfig.class}) public class AreaControllerIntegrationTest { @Autowired AreaController areaController; MockMvc mockMvc; @Before public void init() { mockMvc = standaloneSetup(areaController).build(); } @Test public void calculateAreaTest() throws Exception { mockMvc.perform( MockMvcRequestBuilders.get("/api/area?type=RECTANGLE&param1=5&param2=4") ) .andExpect(status().isOk()) .andExpect(content().string("20.0")); } }

這裡有一些變化:

  • @ContextConfiguration(classes = {TestConfig.class}) — 這告訴測試用例所有 bean 定義所在的位置。
  • 現在我們使用@InjectMocks代替:
 @Autowired AreaController areaController;

其他一切都保持不變。 如果我們調試測試,我們會看到代碼實際上一直運行到RectangleServicearea()方法的最後一行,其中計算了return r*h 。 換句話說,實際的業務邏輯運行。

這並不意味著集成測試中沒有可用的方法調用或數據庫調用的模擬。 在上面的示例中,沒有使用第三方服務或數據庫,因此我們不需要使用模擬。 在現實生活中,這樣的應用程序很少見,我們經常會遇到數據庫或第三方 API,或兩者兼而有之。 在這種情況下,當我們在TestConfig類中創建 bean 時,我們不會創建實際對象,而是創建一個模擬對象,並在需要的地方使用它。

獎勵:如何創建大對象測試數據

通常阻止後端開發人員編寫單元或集成測試的是我們必須為每個測試準備的測試數據。

通常,如果數據足夠小,有一個或兩個變量,那麼很容易創建一個測試數據類的對象並分配一些值。

例如,如果我們期望一個模擬對象返回另一個對象,當在模擬對像上調用一個函數時,我們會做這樣的事情:

 Class1 object = new Class1(); object.setVariable1(1); object.setVariable2(2);

然後為了使用這個對象,我們會做這樣的事情:

 Mockito.when(service.method(arguments...)).thenReturn(object);

在上面的 JUnit 示例中這很好,但是當上面的Class1類中的成員變量不斷增加時,設置單個字段變得相當痛苦。 有時甚至可能會發生一個類定義了另一個非原始類成員。 然後,創建該類的對象並設置單獨的必需字段進一步增加了開發工作,只是為了完成一些樣板。

解決方法是生成上述類的JSON模式,在JSON文件中添加一次對應的數據。 現在在我們創建Class1對象的測試類中,我們不需要手動創建對象。 相反,我們讀取 JSON 文件,並使用ObjectMapper將其映射到所需的Class1類:

 ObjectMapper objectMapper = new ObjectMapper(); Class1 object = objectMapper.readValue( new String(Files.readAllBytes( Paths.get("src/test/resources/"+fileName)) ), Class1.class );

這是創建 JSON 文件並向其添加值的一次性工作。 之後的任何新測試都可以使用該 JSON 文件的副本,其中字段根據新測試的需要進行更改。

JUnit 基礎:多種方法和可轉移技能

很明顯,根據我們選擇注入 bean 的方式,有許多編寫 Java 單元測試的方法。 不幸的是,大多數關於該主題的文章都傾向於假設只有一種方法,因此很容易混淆,尤其是在使用在不同假設下編寫的代碼時。 希望我們的方法可以節省開發人員找出正確的模擬方法和使用哪個測試運行程序的時間。

無論我們使用何種語言或框架——甚至可能是任何新版本的 Spring 或 JUnit——概念基礎都與上述 JUnit 教程中解釋的相同。 祝測試愉快!