Mock和单元测试助手如何帮助Spring进行依赖性管理?

1,747 阅读8分钟

Mock和单元测试助手如何帮助Spring进行依赖性管理?

在我的上一篇文章中,我们谈到了如何使用Parasoft Jtest的Unit Test Assistant高效地构建和改进这些测试。在这篇文章中,我将继续讨论测试任何复杂应用程序的最大挑战之一:依赖性管理。

为什么我需要模拟(Mocking)?

说实话。复杂的应用程序并不是从头开始构建的——它们使用的是别人构建和维护的库、API和核心项目或服务。作为Spring的开发者,我们尽可能地利用现有的功能,这样我们就可以把时间和精力花在我们关心的事情上:应用程序的业务逻辑。我们把细节留给库,所以我们的应用有很多依赖关系,如下图橙色所示。

图1. 一个有多个依赖关系的Spring服务

图1. 一个有多个依赖关系的Spring服务

那么,如果我的应用程序(控制器和服务)的大部分功能依赖于这些依赖的行为,我如何将单元测试集中在我的应用程序上呢?最后,我是不是总是在执行集成测试而不是单元测试?如果我需要更好地控制这些依赖项的行为,或者在单元测试期间依赖项不可用怎么办?

我需要的是一种将我的应用与这些依赖关系隔离开来的方法,这样我就可以将单元测试的重点放在我的应用代码上。在某些情况下,我们可以为这些依赖关系创建专门的“测试”版本。然而,使用像Mockito这样的标准化库比这种方法有多种好处。

  • 你不需要自己编写和维护特殊的“测试”代码。
  • 模拟库可以跟踪对模拟的调用,提供额外的验证层。
  • 像PowerMock这样的标准库提供了额外的功能,比如模拟静态方法、私有方法或构造函数。
  • Mockito这样的模拟库的知识可以在各个项目中重用,而自定义测试代码的知识却不能重用。

图2. 一个模拟服务替换了多个依赖关系。

图2. 一个模拟服务替换了多个依赖关系。

Spring的依赖性

一般来说,Spring应用程序将功能分割成Bean。一个Controller可能依赖于一个Service Bean,而Service Bean可能依赖于一个EntityManager、JDBC连接或另一个Bean。大多数时候,需要将被测代码与之隔离的依赖关系是Bean。在集成测试中,所有层都应该是真实的——但对于单元测试,我们需要决定哪些依赖应该是真实的,哪些应该是mock。

Spring允许开发人员使用XML、Java或两者的结合来定义和配置bean,以便在你的配置中提供模拟和真实bean的混合。由于mock对象需要在Java中定义,所以应该使用一个Configuration类来定义和配置mocked beans。

模拟依赖

当UTA生成一个Spring测试时,你的控制器的所有依赖关系都被设置为mock,这样每个测试都能获得对依赖关系的控制。当测试运行时,UTA会检测在mock对象上对尚未配置方法模拟的方法进行的方法调用,并建议这些方法应该被模拟。然后,我们可以使用快速修复来自动模拟每个方法。

下面是一个依赖于PersonService的控制器示例:

@Controller
@RequestMapping("/people")
public class PeopleController {
 
    @Autowired
    protected PersonService personService;
    @GetMapping
    public ModelAndView people(Model model){
   
        for (Person person : personService.getAllPeople()) {
            model.addAttribute(person.getName(), person.getAge());
        }
        return new ModelAndView("people.jsp", model.asMap());
    }
}

还有一个测试示例,由Parasoft Jtest的单元测试助手生成:

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration
public class PeopleControllerTest {
 
    @Autowired
    PersonService personService;
 
    // Other fields and setup
 
    @Configuration
    static class Config {
 
        // Other beans
 
        @Bean
        public PersonService getPersonService() {
            return mock(PersonService.class);
        }
    }
 
    @Test
    public void testPeople() throws Exception {
        // When
        ResultActions actions = mockMvc.perform(get("/people"));
    }
}

在这里,测试使用了一个用@Configuration注解的内部类,它使用Java配置为被测Controller提供bean依赖。这样我们就可以模拟bean方法中的PersonService。目前还没有模拟任何方法,所以当我运行测试时,我看到以下建议:

没有模拟任何方法运行测试时的建议

这意味着在我模拟的PersonService上调用了getAllPeople()方法,但是测试还没有为这个方法配置模拟。当我选择 "Mock it "快速修复选项时,测试就会更新:

    @Test
    public void testPeople() throws Exception {
        Collection<Person> getAllPeopleResult = new ArrayList<Person>();
        doReturn(getAllPeopleResult).when(personService).getAllPeople();
        // When
        ResultActions actions = mockMvc.perform(get("/people"));

当我再次运行测试时,它通过了。我仍然应该填充由getAllPeople()返回的Collection,但是设置我的模拟依赖的挑战已经解决了。

请注意,我可以将生成的方法模拟从测试方法移到配置类的bean方法中。如果我这样做,就意味着类中的每个测试都会以同样的方式模拟同一个方法。将方法模拟保留在测试方法中意味着该方法可以在不同的测试之间以不同的方式进行模拟。

Spring Boot

Spring Boot 使得 bean mocking 更加简单。你不必为测试中的 bean 使用 @Autowired 字段,也不必使用定义它的 Configuration 类,你只需为 bean 使用一个字段并使用 @MockBean 来注释它。Spring Boot 将使用它在 classpath 上找到的 mocking 框架为 bean 创建一个 mock,并以注入容器中任何其他 bean 的方式注入它。当使用单元测试助理生成Spring Boot测试时,会使用@MockBean功能代替Configuration类。

@SpringBootTest
@AutoConfigureMockMvc
public class PeopleControllerTest {
    // Other fields and setup – no Configuration class needed!
 
    @MockBean
    PersonService personService;
 
    @Test
    public void testPeople() throws Exception {
        ...
    }
}

XML与Java配置

在上面的第一个例子中,Configuration类向Spring容器提供了所有的Bean。另外,你也可以使用XML配置来代替Configuration类进行测试;或者你可以将两者结合起来。例如,你可以使用:

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration({ "classpath:/**/testContext.xml" })
public class PeopleControllerTest {
 
    @Autowired
    PersonService personService;
 
    // Other fields and setup
 
    @Configuration
    static class Config {
        @Bean
        @Primary
        public PersonService getPersonService() {
            return mock(PersonService.class);
        }
    }
 
    // Tests
}

在这里,该类在@ContextConfiguration注解中引用了一个XML配置文件(这里没有显示)来提供大部分的bean,这些bean可以是真实的bean,也可以是测试专用的bean。我们还提供了一个@Configuration类,PersonService在这里被模拟。@Primary注解表示,即使在XML配置中找到了PersonService bean,这个测试也会使用@Configuration类中的模拟bean来代替。这种类型的配置可以使测试代码更小,更容易管理。

你可以配置UTA,使用你需要的任何特定的@ContextConfiguration属性生成测试。

模拟静态方法

有时,依赖关系是静态访问的。例如,一个应用程序可能会通过静态方法调用来访问一个第三方服务。

public class ExternalPersonService {
    public static Person getPerson(int id) {
       RestTemplate restTemplate = new RestTemplate();
       try {
           return restTemplate.getForObject("http://domain.com/people/" + id, Person.class);
        } catch (RestClientException e) {
            return null;
        }
    }
}

在我们的控制器中:

    @GetMapping
    public ResponseEntity<Person> getPerson(@PathVariable("id") int id, Model model)
    {
        Person person = ExternalPersonService.getPerson(id);
        if (person != null) {
            return new ResponseEntity<Person>(person, HttpStatus.OK);
        }
        return new ResponseEntity<>(HttpStatus.NOT_FOUND);
    }

在这个例子中,我们的处理方法使用静态方法调用从第三方服务中获取Person对象。当我们为这个处理方法构建一个JUnit测试时,每次测试运行时都会对服务进行真正的HTTP调用,而不是模拟静态的ExternalPersonService.getPerson()方法。

相反,让我们模拟静态的ExternalPersonService.getPerson()方法。这样就可以避免HTTP调用,并允许我们提供一个适合我们测试需求的Person对象响应。单元测试助手可以通过PowerMockito让模拟静态方法变得更容易。

UTA为上面的处理程序方法生成一个测试,它看起来像这样:

    @Test
    public void testGetPerson() throws Throwable {
        // When
        long id = 1L;
        ResultActions actions = mockMvc.perform(get("/people/" + id));
 
        // Then
        actions.andExpect(status().isOk());
}

当我们运行测试时,我们将在UTA流树中看到HTTP调用正在进行。让我们找到对ExternalPersonService.getPerson()的调用,并对其进行模拟:

找到对ExternalPersonService.getPerson()的调用,并对其进行模拟

测试已经更新为使用PowerMock模拟静态方法进行测试:

    @Test
    public void testGetPerson() throws Throwable {
        spy(ExternalPersonService.class);
 
        Person getPersonResult = null; // UTA: default value
        doReturn(getPersonResult).when(ExternalPersonService.class, "getPerson", anyInt());
 
        // When
        int id = 0;
        ResultActions actions = mockMvc.perform(get("/people/" + id));
 
        // Then
        actions.andExpect(status().isOk());
}

使用UTA,我们现在可以选择getPersonResult变量并将其实例化,这样模拟的方法调用就不会返回null:

    String name = ""; // UTA: default value
    int age = 0; // UTA: default value
Person getPersonResult = new Person(name, age);

当我们再次运行测试时,getPersonResult从mockedExternalPersonService.getPerson()方法返回,测试通过。

注意: 从流程树中,还可以选择 "添加可模拟方法模式 "来进行静态方法调用。这将配置Unit Test Assistant在生成新的测试时总是模拟这些静态方法调用。

结束语

复杂的应用程序经常会有一些功能上的依赖性,这些依赖性会使开发人员对代码进行单元测试的能力变得复杂并受到限制。使用像Mockito这样的模拟框架可以帮助开发人员将被测代码与这些依赖关系隔离开来,使他们能够更快地编写更好的单元测试。Parasoft Jtest 单元测试助手通过配置新的测试以使用 mock,以及在运行时查找缺失的方法 mock 并帮助开发人员为其生成 mock,使依赖性管理变得简单。