现如今,单元测试已经变得相当普遍,那些还没有实践单元测试的开发者理应感到无地自容。在维基百科上对于单元测试是这样定义的:
一种软件测试方法,利用这种方法对个别的源代码单元……进行测试,以确定这些单元是否适用。
而在面向对象语言,尤其是 Java 语言中,通常的理解是“源代码的单元”即为某个方法。为了充分展现这一点,让我们引用一个来自于 Spring 的经典应用 Pet Clinic 中的一个示例,这里摘录了 PetController 类的部分代码,只是为了说明的方便起见:
@Controller @SessionAttributes("pet") public class PetController { private final ClinicService clinicService; @Autowired public PetController(ClinicService clinicService) { this.clinicService = clinicService; } @ModelAttribute("types") public Collection<PetType> populatePetTypes() { return this.clinicService.findPetTypes(); } @RequestMapping(value = "/owners/{ownerId}/pets/new", method = RequestMethod.GET) public String initCreationForm(@PathVariable("ownerId") int ownerId, Map<String, Object> model) { Owner owner = this.clinicService.findOwnerById(ownerId); Pet pet = new Pet(); owner.addPet(pet); model.put("pet", pet); return "pets/createOrUpdatePetForm"; } ... }
正所我们所见,initCreationForm() 方法的作用是加载用于创建新的 Pet 实例的表单。我们的目标就是测试该方法的行为:
- 在 model 中放入一个 Pet 的实例
- 设置该 Pet 实例的 Owner 属性
- 返回一个预定义的视图
简单的传统单元测试
正如上面所定义的一样,单元测试中需要用桩来取代方法中的依赖。下面是一个基于流行的 Mockito 框架所创建的典型单元测试:
public class PetControllerTest { private ClinicService clinicService; private PetController controller; @Before public void setUp() { clinicService = Mockito.mock(ClinicService.class); controller = new PetController(clinicService); } @Test public void should_set_pet_in_model() { Owner dummyOwner = new Owner(); Mockito.when(clinicService.findOwnerById(1)).thenReturn(dummyOwner); HashMap<String, Object> model = new HashMap<String, Object>(); controller.initCreationForm(1, model); Iterator<Object> modelIterator = model.values().iterator(); Assert.assertTrue(modelIterator.hasNext()); Object value = modelIterator.next(); Assert.assertTrue(value instanceof Pet); Pet pet = (Pet) value; Owner petOwner = pet.getOwner(); Assert.assertNotNull(petOwner); Assert.assertSame(dummyOwner, petOwner); } }
- setUp() 方法负责将 controller 进行初始化以便进行测试,同时也负责解析 ClinicService 这个依赖。Mockito 将使用 mock 方式提供这个依赖的实现。
- should_set_pet_in_model() 这个测试的目的是检查在主体方法运行后,该 model 应当包含一个 Pet 实例,并且该实例的 Owner 应当与经 mock 的 ClinicService 所返回的 Owner 相同。
- 注意这里并没有对返回视图这一结果进行测试,因为对于它的测试与 controller 中的代码将完全相同。
在单元测试中缺少了什么
到目前火上,我们已经成功地实现了对该方法的 100% 代码覆盖率,我们也可以选择告一段落了。但是,如果在代码完成单元测试之后就停止测试,其结果就像是在生产汽车时,在测试过车辆的每一个螺母和螺栓之后就直接开始组装汽车一样。很显然,没有人愿意承担如此巨大的风险。在实际生活中,首先要对汽车进行全面的测试驱动,以检查所有参与整体汽车动作的部件,而不仅仅是螺母与螺栓的组装。而在软件开发世界中,我们把这种类似的测试驱动称为集成测试。集成测试是目的是确保各种类之间能够正确地进行协作。
在 Java 世界中,Spring 框架与 Java EE 平台都可以被视为一种容器,它们为各种可用的服务提供了 API,例如使用 JDBC 进行数据库访问。要确保使用 Spring 或 Java EE 进行开发的应用程序能够正常地运行,就需要在容器中进行测试,以测试它们与容器中所提供的服务的集成是否能够正常运行。
在以上所举的示例中,还有一部分内容没有测试到,也无法进行测试:
- Spring 的配置,即通过 autowiring 方式将所有类进行组装
- 在 model 中对 PetType 的加载,即 populatePetTypes() 方法
- URL 是否映射到正确的 controller,以及方法标注,即 @RequestMapping
- 在 HTTP 会话中对 Pet 实例的设置,即 @SessionAttributes(“pet”) 标注
容器内测试
集成测试,以及特定的“容器内测试”正是测试以上提过的这些测试点的解决方案。幸运的是,Spring 中提供了一套完整的测试框架,旨在实现这一目的。而 Java EE 的用户也可以通过使用 Arquillian 测试框架加入容器内测试的过程中。不过在 Java EE 的应用程序中存在着不同的类装配方法,例如 CDI,而 Arquillian 中也提供了处理这些差异的方法。有了这些背景知识之后,让我们重新回来看一看这个 Pet Clinic 示例,并且为上面提过的这些测试点创建测试方法。
Spring 中的 JUnit 集成
正如 JUnit 的名称所暗示的一样,这是一种用于单元测试的框架。Spring 中提供了一种专用的 JUnit 执行器,它能够在测试开始运行时启动 Spring 容器。这是在测试类中通过 @RunWith 标注进行配置的。
@RunWith(SpringJUnit4ClassRunner.class) public class PetControllerIT { ... }
Spring 框架也有着自己的配置组件集,可以使用老式的 XML 文件配置,或选择最近流行的 Java“配置”类。这里有一种优秀的实践,就是使用细粒度的配置组件,因此使用者可以自由选择所需的组件,并按照测试的上下文进行组合。这些配置组件可以通过测试类的 @ContextConfiguration 标注进行设置。
@ContextConfiguration("classpath:spring/business-config.xml") public class PetControllerIT { ... }
最后,Spring 框架允许根据某种跨整个应用程序范围的标记,又称为档案,对某些配置选项进行激活(或是关闭)。使用档案的方式相当简单,就是在在测试类中设置一个 @ActiveProfile 标注即可。
@ActiveProfiles("jdbc") public class PetControllerIT { ... }
要使用 JUnit 测试标准的 Spring bean 的话,以上的方式就已经足够了。但要测试 Spring MVC controller 的话,你还需要进行更多的工作。
Spring 中的测试 web 上下文
对于 web 应用程序来说,Spring 能够创建一个结构化的上下文,类似于分层架构中的父 - 子关系。子结点中的内容与 web 相关,例如 controller、格式化器、资源包等等。而父结点中包含了其它部分的内容,例如服务与仓储(repository)等等。为了模仿这种关系,需要在测试类中使用 @ContextHierarchy 标注,并且对该标注进行配置,让它引用必需的 @ContextConfiguration 标注:
在下面这个测试片段中,business-config.xml 代表了父,而 mvc-core-config.xml 则代表了子:
@ContextHierarchy({ @ContextConfiguration("classpath:spring/business-config.xml"), @ContextConfiguration("classpath:spring/mvc-core-config.xml") })
使用者还需要设置 @WebAppConfiguration 标注,以模仿一个 WebApplicationContext 的行为,而不是使用更简单的 ApplicationContext。
测试 controller
在测试中按照以上描述的方法设置好 web 上下文之后,终于能够开始测试 controller 了。MockMvc 类是实现这一切的入口点,这个类中包含了以下属性:
- 一个 _Request__ 生成器 _,以创建一个 Fake 的请求
- 一个 _Request__ 匹配器 _,以检查 controller 的方法执行的结果
- 一个 _Result__ 处理器 _,可以对结果进行任意地操作
MockMvc 的实例是通过调用 MockMvcBuilders 类的静态方法所生成的,其中某个方法生成的实例用于特定的 controller 集,而另一个方法生成的实例用于整个应用程序的上下文。在后一种情况下,需要提供一个 WebApplicationContext 的实例以作为参数。实现这一点非常简单,只需在测试类中对该类型的某个属性使用 autowire 即可。
@RunWith(SpringJUnit4ClassRunner.class) public class PetControllerIT { @Autowired private WebApplicationContext context; }
接下来,通过 perform(RequestBuilder) 方法执行经过配置的 MockMvc 实例方法,而 RequestBuilder 的实例又是通过调用 MockMvcRequestBuilders 中的静态方法所生成的。其中每一个静态方法都是一种执行特定 HTTP 方法的途径。
总结一下,使用者可以通过以下代码模仿一个对 /owners/1/pets/new 路径的 GET 调用。
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(PetController.class).build(); RequestBuilder getNewPet = MockMvcRequestBuilders.get("/owners/1/pets/new"); mockMvc.perform(getNewPet);
最后,Spring 还通过 ResultMatcher 接口提供了大量的断言功能,并且提供了 MockMvc 的 fluent API:包括 cookie、内容体、底层模型、HTTP 状态码,所有这些内容都可以进行检查或实现更多操作。
组合在一起使用
要测试以上那个 controller 的准备工作都已经完成了,而仅仅使用单元测试是无法对其进行测试的。
1 @RunWith(SpringJUnit4ClassRunner.class) 2 @ContextHierarchy({ 3 @ContextConfiguration("classpath:spring/business-config.xml"), 4 @ContextConfiguration("classpath:spring/mvc-core-config.xml") 5 }) 6 @WebAppConfiguration 7 @ActiveProfiles("jdbc") 8 public class PetControllerIT { 9 10 @Autowired 11 private WebApplicationContext context; 12 13 @Test 14 public void should_set_pet_types_in_model() throws Exception { 15 MockMvc mockMvc = webAppContextSetup(context).build(); 16 RequestBuilder getNewPet = get("/owners/1/pets/new"); 17 mockMvc.perform(getNewPet) 18 .andExpect(model().attributeExists("types")) 19 .andExpect(request().sessionAttribute("pet", instanceOf(Pet.class))); 20 } 21 }
在这个代码片段中一共进行了以下操作:
- 在第 3 行与第 4 行,我们确保 Spring 的配置文件都已正确配置
- 在第 16 行,我们确保了应用程序对表单生成的 URL 的某个 GET 访问能够正确地发送响应
- 在第 18 行,我们测试了该 model 的属性中包含了 types 这一属性
- 最后,在第 19 行,我们测试了会话中包含了 pet 这一属性,并且确认了它的类型确实是我们所期望的 Pet 类型
(注意,instanceOf() 这一静态方法是由 Hamcrest API 所提供的。)
其它一些测试方面的挑战
上文中所描述的那个 PetClinic 示例是有些偏简单的。它已经使用了某个内置的 Hypersonic SQL 数据库,自动地创建了数据库 schema,并插入了一些数据,这些操作全部是在容器启动时发生的。而在常规的应用程序中,开发者很可能会在生产环境与测试环境中使用不同的数据库。此外,在生产环境中不会对数据进行初始化操作。这里的挑战包括如何切换至另一台数据库,以达到测试的目的,如何在测试执行开始前将数据库设置为必需的状态,以及如何在执行结束后检查数据库的状态。
与之类似的是,我们已经测试了 PetController 中的 initCreationForm() 这个单一方法,而在其创建过程中也隐含了对 processCreationForm() 方法的调用。为了减少对测试进行初始化的代码,选择对用例本身进行测试,而不是对每个方法进行测试的做法也不无道理。而这种方法也许意味着一个巨大的测试方法:而一旦该测试失败,要找到失败的根源或许会非常困难。另一种途径是,创建细粒度的、具有良好命名的方法,并且按顺序执行它们。不幸的是,JUnit 作为一种真正的单元测试框架,并不允许使用这种方式。
每一个与某种基础设计资源,例如数据库、邮件服务器、FTP 服务器等等进行打交道的组件,都面临着相同的挑战:对该资源进行 mock 对于测试本身来说没有带来任何价值。比方说,用户如何在测试中检查复杂的 JPA 查询,这不是对数据库进行 mock 就可以实现的功能。常见的实践方法是搭建一种专用的内存数据库。根据上下文情况的不同,也可能存在更好的实现方式。在这种情况下,所面临的挑战在于如何选择正确的实现方式,以及如何在测试中管理这些资源的生命周期。
说到基础设施的资源,在任何现代的 web 应用程序中,很大一部分的依赖都来自于 web service。而随着微服务这一趋势的走红,情况变得更加糟糕。如果某个应用程序依赖于其它外部 web service,那么测试这个应用程序与它的依赖之间的协作就成为一种不可避免的需求。当然,这些 web service 依赖的搭建方式很大程度上依赖于它们的自然属性,在大多数情况下它们都会以 SOAP 或 REST 的方式提供。
此外,如果应用程序的目标平台并非 Spring,而是 Java EE 的话,所面临的挑战也会变得不同。在 Java EE 中提供了上下文及依赖注入(CDI)服务,其动作方式依赖于 autowiring。要测试这样的应用程序,就意味着要正确地将组件组合在一起,包括类与配置文件。不仅如此,Java EE 还承诺相同的应用程序可以运行在不同的适用应用服务器上。如果该应用程序对应着不同的目标平台,比方说这是一个可能会部署在不同的客户环境中的产品,那么对这种兼容性也要进行彻底的测试。
结论
在本文中,我为读者展示了集成测试的某些技术能够让你对你的代码更有自信,我在这里使用了 Spring MVC web 框架作为示例。
我同时也简要地表示,测试中存在的某些挑战是无法由单元测试本身所解决的,而必须通过集成测试实现。
本文中的内容只是一些基础的技术,要想深入地研究其它技术以及更多的工具,请参与由我编著的《Integration Testing from the Trenches》一书,我在本书中展现了多种工具与技术,并且表现了如何使用这些工具以更好地保证你的软件质量。
InfoQ 曾对本书进行评论,在 Leanpub 上可以找到本书的多种电子版本,涵盖了所有主流的格式,而在 Amazon 上也可以订购本书的实体书。
关于作者
Nicolas Fränkel是一位成功的 Java 与 Java EE 方面的软件架构师与开发者,他在为不同的客户进行顾问这方面有着超过 12 年的经验。他同时还在法国与瑞士的各大高等学府从事培训师与兼职讲师的工作,这也使他对软件技术的理解更加全面。Nicolas 作为一位演讲者,曾参与在欧洲举行的各大与 Java 相关的技术大会,例如比利时的 Deovxx、JEEConf、JavaLand 和一些 Java 用户小组,他也是《Learning Vaadin》与《Learning Vaadin 7》这两本书籍的作者。Nicolas 对于软件的爱好非常广泛,包括富客户端应用、到开源软件、以及在质量保证流程中实施自动化构建,其中包括了各种形式的测试方法。
查看英文原文: You’ve Completed Unit Testing; Your Testing has Just Begun
评论