有信心的构建(JUnit测试指南)

本文概述

  • 什么是Mocking?什么时候出现?
  • 什么是Test Runner, 有哪些类型的Runner?
  • 部分模拟
  • 我们如何测试控制器或RequestHandler?
  • 使用JUnit和Mocks进行Java集成测试
  • 奖励:如何创建大对象测试数据
  • JUnit基础:多种方法和可转让的技能
随着技术和行业的发展, 从瀑布模型过渡到敏捷模型, 再到现在的DevOps, 应用程序中的更改和增强功能在开发过程中即刻部署到生产中。随着代码以如此快的速度部署到生产环境中, 我们需要确信所做的更改能够奏效, 并且它们不会破坏任何现有功能。
为了建立这种信心, 我们必须有一个自动回归测试的框架。为了进行回归测试, 应从API级别的角度进行许多测试, 但这里我们将介绍两种主要的测试类型:
  • 单元测试, 其中任何给定的测试都涵盖程序(功能或过程)的最小单元。它可能会或可能不会使用某些输入参数, 并且可能会或可能不会返回某些值。
  • 集成测试, 其中将各个单元一起测试以检查所有单元是否按预期相互交互。
每种编程语言都有许多可用的框架。我们将专注于用Java的Spring框架编写的Web应用程序的编写单元和集成测试。
大多数时候, 我们在类中编写方法, 而这些方法又与其他类的方法进行交互。在当今世界, 尤其是在企业应用程序中, 应用程序的复杂性使得单个方法可能调用多个类的多个方法。因此, 在为这种方法编写单元测试时, 我们需要一种从这些调用返回模拟数据的方法。这是因为此单元测试的目的是仅测试一种方法, 而不是测试该特定方法进行的所有调用。
让我们使用JUnit框架进入Spring中的Java单元测试。我们将从你可能听说过的东西开始:Mocking。
什么是Mocking?什么时候出现?假设你有一个类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()进行单元测试, 那么我们的动机应该是检查切换用例和异常条件是否有效。我们不应该测试形状服务是否返回正确的值, 因为如前所述, 单元测试功能的动机是测试功能的逻辑, 而不是功能正在进行的调用的逻辑。
因此, 我们将模拟各个服务函数(例如, 矩形服务.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); } }

这里要注意的两行是:
  • radiusService = Mockito.mock(RectangleService.class); -这将创建一个模拟, 它不是实际的对象, 而是一个模拟的对象。
  • Mockito.when(rectangleService.area(5.0d, 4.0d))。thenReturn(20d); -这表示在进行模拟时, 使用指定的参数调用了矩形服务对象的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:搜索bean的rectangularService, squareService和circleService并将它们注入到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()来创建模拟。其次, 我们使用@InjectMocks和initMocks()将模拟注入到实际对象中。
这样做是为了减少代码行数。
什么是Test Runner, 有哪些类型的Runner?在上面的示例中, 用于运行所有测试的基本运行程序是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"); } }

如果我们将矩形服务的@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); } }

我们如何测试控制器或RequestHandler?从上面的学习中, 我们示例的控制器的测试代码如下所示:
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 = "http://www.srcmini.com/api/area", method = RequestMethod.GET) @ResponseBody public ResponseEntity calculateArea( @RequestParam("type") String type, @RequestParam("param1") String param1, @RequestParam(value = "http://www.srcmini.com/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;

其他所有内容保持不变。如果我们调试测试, 我们将看到代码一直运行到RectangleService中area()方法的最后一行, 在该行的最后一行计算返回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单元测试的方法。不幸的是, 有关该主题的大多数文章倾向于假定只有一种方法, 因此很容易造成混淆, 尤其是在使用以不同假设编写的代码时。希望我们的方法可以节省开发人员的时间, 以找出正确的模拟方法以及要使用的测试运行程序。
【有信心的构建(JUnit测试指南)】无论我们使用哪种语言或框架(甚至可能是Spring或JUnit的任何新版本), 概念基础都与上述JUnit教程中说明的相同。测试愉快!

    推荐阅读