现如今,单元测试已经变得相当普遍,那些还没有实践单元测试的开发者理应感到无地自容。在维基百科上对于单元测试是这样定义的:
一种软件测试方法,利用这种方法对个别的源代码单元……进行测试,以确定这些单元是否适用。
而在面向对象语言,尤其是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