自信地构建: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 教程中解释的相同。 祝测试愉快!