@yishuailuo
2017-08-04T11:29:33.000000Z
字数 11691
阅读 1082
今天我们来谈谈 java 平台上的单元测试与集成测试,其他语言平台上的笔者不熟悉,暂且不论。既然要谈论,那么得先明确谈论的对象,即到底什么是单元测试,什么是集成测试。
首先我们从软件开发生命周期中的测试谈起,常见的软件开发生命周期模型有“瀑布模型”、“迭代模型”、“螺旋模型”、“ V 模型”、“大爆炸模型”等等,这里以瀑布模型与迭代模型为例。

在瀑布模型的软件开发生命周期中,关键活动有需求分析、系统设计、编码、测试、维护;在迭代模型的软件开发生命周期中,关键活动有设计、编码、测试、验证。在两个模型中,测试都有相当重要的位置。模型的中测试一般包括单元测试、集成测试、系统测试、验收测试。我们可以看到,单元测试与集成测试是最先执行的测试,也是发现软件缺陷最重要的两个测试。我们先来简单地描述一下这四个测试:
单元测试:对程序中的单元(函数、方法或者模块)进行测试的过程
集成测试:在单元测试的基础之上,将单元或模块组装成子系统再进行测试,主要是测试单元或者模块之间的接口
系统测试:针对整个系统的测试,目标是验证系统是否满足需求规格说明,功能及性能是否满足规约
验收测试:目标是验证软件满足用户的需求,确保软件就绪可部署
在初步了解什么是单元测试与集成测试之后,下面的章节我们逐一地详细谈谈。
在企业级软件开发中,单元测试在保证软件质量与保持开发速度上起到的作用似乎已经无需赘言,然而,也许只有少数团队能够理解单元测试真正的价值,并且发挥其价值于软件开发实践中。在这里,我们不妨重申一下单元测试的价值,来回答许多开发人员(特别是对代码有极高自信 的极客)对于为什么要做单元测试的疑问。
总的来讲,自动化的单元测试能发现错误,保护回归,帮助设计,从而改善生产力,使我们获得并保持开发速度。
单元测试主要有以下价值:
1. 帮助我们捕获错误

2. 帮助我们针对实际使用来塑造设计

3. 价值不在于结果,而在于编写单测的学习
我们对单元测试的代码质量的追求,应该如同对待生产代码一样,从代码的执行速度、可读性、可靠性、可信赖性与可维护性上提升单元测试代码的质量。

从上图我们可以看出,单元测试的代码质量(执行速度、可读性、可靠性、可信赖性)通过影响开发的反馈环长度与调试间接地影响开发者的生产力。
我们先来谈谈反馈环长度与调试时间:
接着简单解析一下图中的含义:
在了解了单元测试的价值之后,接下来的章节我们谈谈如何做单元测试。
要做单元测试,首先得了解单元测试的工具,本文主要讲 Java 平台下的单元测试,那么只介绍 Java 平台常用的单元测试的工具。
@Servicepublic class CityServiceImpl implements CityService {@Autowiredprivate CityMapper cityMapper;@Overridepublic City getById(Integer id) {City city = cityMapper.getById(id);if (null == city) {throw new EntityNotFoundException("NoCity");}return city;}}
这里有一个 service 实现类叫 cityServiceImpl,其中的方法 getById 调用了 cityMapper 方法获取 city 实体,如果有则返回实体,否则抛 EntityNotFoundException 异常。我们来看看针对这个 service 实现类的 getById 方法的单元测试都需要具备哪些元素。
public class CityServiceTest {@InjectMocksprivate CityServiceImpl cityService;@Mockprivate CityMapper cityMapper;@Beforepublic void setUp() {MockitoAnnotations.initMocks(this);}@Testpublic void testGetById_entityExists_getSuccess() throws Exception {// Given these pre-conditionsInteger id = 1;City city = City.builder().id(id).build();given(cityMapper.getById(id)).willReturn(city);// When this method is executedCity actualCity = cityService.getById(id);// Then this should be the resultassertThat(actualCity.getId(), is(id));}@Afterpublic void tearDown() {}}
上面这个是测试类 CityServiceTest,使用了 JUnit 和 mockito。类中首先声明被测试的类 CityServiceImpl,标注 @InjectMocks 注解表示被 mock 对象注入,然后声明 CityMapper,标注 @Mock 注解表示 mock 对象,此时被测试对象 CityServiceImpl 与被测试对象的 mock 依赖 CityMapper 已经准备好,可以开始测试。
接着我们开始写一个获取实体成功测试用例,方法为 testGetById_entityExists_getSuccess,标注 @Test 表示方法会被 JUnit 框架识别为测试方法。
cityMapper 的行为,当传入参数 id =1 调用它的 getById 方法时,返回预先构造的 city 实体对象;cityService 中被测试的方法 getById 来获得实体对象;测试方法被调用之前,需要注入 mock 对象到被测试类的实例,在被 @Before 标注的 setup 方法中执行注入操作。另外,标注了 @After 的 tearDown 方法可以写在测试方法调用之后需要执行的代码,一般为释放资源,还原状态等。
到这里一个简单的单元测试解释完毕,我们可以看到,单元测试中的一个重点是需要 mock 被测方法所依赖的对象,并模拟对象的多种行为,以便覆盖测试方法的多个路径,从而验证测试方法是否编写正确。这里的 mock 对象通常叫测试替身,测试替身有多种类型,mock 对象是其中一种。下面我们来谈谈测试替身。
在单元测试过程中,开发人员会写许多仅供测试的方法或者类,这些方法或者类通常用于隔离被测代码、加速执行测试、使随机行为变得确定、模拟特殊情况、以及使测试能够访问隐藏信息。它们被称作测试替身(test double)。

为了验证一段代码的行为符合期望,最好是替换其周围代码,使得我们可以获得对环境的完整控制,从而在其中测试我们的代码,这需要我们将被测代码与真实依赖隔离开,用测试替身替换,以便进行测试。将测试代码与周围环境隔离开,是引入测试替身的最根本原因。
针对上面的简单例子,测试替身的作用如下:

当然,除了隔离被测代码,测试替身还有如下作用:
这里我们着重讲一下测试替身访问隐藏信息这个作用。假设有一个 Car 类,依赖 Engine 引擎来启动,start 方法返回值。具体代码如下:
public class Car {private Engine engine;public Car(Engine engine) {this.engine = engine;}public void start() {engine.start();}}interface Engine {void start();}class EngineImpl implements Engine {@Overridepublic void start() {}}
然后针对 start 方法做单元测试,如果不使用测试替身,则难以观测到引擎是否启动了,比如下面那样,当 Car 实例启动之后,引擎 engine 到底启动了没有呢?方法是没有返回值,不方便做检验。我们可能一下子会想到在 Engine 的 start 方法中 print 一下来观测,不过这会侵入生产代码,似乎不太好。
public class CarTest {@Testpublic void testStart_engineStart_startSuccess_withoutSpy() {// Given these pre-conditionsEngine engine = new EngineImpl(); // real dependencyCar car = new Car(engine);// When this method is executedcar.start();// Then this should be the result// how to assert? print something in engine's start method?}}
那么如何解决呢,这时候我们想到了测试间谍 spy。下面我们不妨看看测试间谍是如何窃取引擎是否启动的信息来给开发者做汇报的。
public class CarTest {@Testpublic void testStart_engineStart_startSuccess_withSpy() {// Given these pre-conditionsEngineSpy engine = new EngineSpy();Car car = new Car(engine);// When this method is executedcar.start();// Then this should be the resultassertThat(engine.isRunning(), is(true));}}class EngineSpy implements Engine {private boolean isRunning;@Overridepublic void start() {isRunning = true;}public boolean isRunning() {return isRunning;}}
创建测试间谍类 EngineSpy 实现 Engine 接口,通过 isRunning 标志位来观测 start 方法的执行情况。那么在单元测试就可以验证引擎的启动情况。这就是测试替身访问隐藏信息作用的一个简单例子。
除了测试间谍,测试替身还有三个类型 —— 测试桩、伪造对象、模拟对象。
测试桩 Stub(简单短小)
public interface Logger {void log(LoggerLevel loggerLever, String message);LoggerLevel getLogLevel();}enum LoggerLevel{INFO,WARN,WRROE}public class LoggerStub implements Logger {@Overridepublic void log(LoggerLevel loggerLever, String message) {System.out.println("log something"); // 简单的 print 输出作为 log 测试桩}@Overridepublic LoggerLevel getLogLevel() {return LoggerLevel.WARN; // 简单的硬编码作为 getLogLevel 测试桩}}
伪造对象 Fake(不产生副作用)
public class User {private int id;private String name;// getter and setter ommitted for clarity}public interface UserRepository {void save(User user);User findById(Integer id);}public class FakeUserRepository implements UserRepository{// 通过集合 users 模拟真实的数据库private Collection<User> users = new ArrayList<>();@Overridepublic void save(User user) {if(findById(user.getId()) != null ) {users.add(user);}}@Overridepublic User findById(Integer id) {for(User user : users) {if (user.getId() == id) {return user;}}return null;}}
测试间谍 Spy(窃取隐藏信息)
public class CarTest {@Testpublic void testStart_engineStart_startSuccess_withSpy() {// Given these pre-conditionsEngineSpy engine = new EngineSpy();// 第一步将测试间谍传入Car car = new Car(engine);// When this method is executed// 第二步让测试间谍记录信息car.start();// Then this should be the result// 第三步让测试间谍汇报信息assertThat(engine.isRunning(), is(true));}}class EngineSpy implements Engine {private boolean isRunning;@Overridepublic void start() {isRunning = true;}public boolean isRunning() {return isRunning;}}
模拟对象 Mock(反对惊喜)
public class CityServiceTest {// rest ommitted for clarity@Testpublic void testGetById_entityExists_getSuccess() throws Exception {// Given these pre-conditionsInteger id = 1;City city = City.builder().id(id).build();given(cityMapper.getById(id)).willReturn(city);// rest ommitted for clarity}@Test(expected = EntityNotFoundException.class)public void testGetById_entityNotExists_getFailure() throws Exception {// Given these pre-conditionsInteger id = 1;given(cityMapper.getById(id)).willReturn(null);// rest ommitted for clarity}// rest ommitted for clarity}
这里当模拟了两种情况,当 id = 1 时返回 city 对象或者 null,这就是模拟对象的作用。
总的来讲,要为单元测试挑选合适的测试替身。
当我们要进行单元测试时,特别要留意被测代码的外部依赖,这些依赖包括:
如果测试中没有做好隔离,将难以运行和维护我们的测试。比如:
那么,我们需要尽量避免这些复杂的依赖,尝试做如下事情:
常见的使用 JUnit 做单元测试的基本结构
public class JUnitTest extends SuperJUnitTest {// 使用 JUnit Rule 为每个类前后做初始和清理工作@BeforeClasspublic static void setUpClass() {// init before test classSystem.out.println("JUnitTest BeforeClass setUp ");}@Beforepublic void setUp() {// init before test methodSystem.out.println("JUnitTest Before setUp");}// test1 test2 test3 不一定按照方法在测试类中的顺序执行。@Testpublic void testSomeMethod1_when_then() throws Exception {System.out.println("JUnitTest test1");}@Testpublic void testSomeMethod2_when_then() throws Exception {System.out.println("JUnitTest test2");}@Testpublic void testSomeMethod3_when_then() throws Exception {System.out.println("JUnitTest test3");}@Afterpublic void tearDown() {// clear after test methodSystem.out.println("JUnitTest After tearDown");}@AfterClasspublic static void tearDownClass() {// clear after test classSystem.out.println("JUnitTest AfterClass tearDown");}}print result:----------------------------JUnitTest BeforeClass setUpJUnitTest Before setUpJUnitTest test1JUnitTest After tearDownJUnitTest Before setUpJUnitTest test2JUnitTest After tearDownJUnitTest Before setUpJUnitTest test3JUnitTest After tearDownJUnitTest AfterClass tearDown----------------------------
当测试类有继承关系的情况下,在 JUnit 以前的版本中,当执行子类的测试方法前,是会先后执行父类的 @BeforeClass @Before 以及 @After 与 @AfterClass 方法的,现在似乎已经不执行了,这块需要继续再研究。
public class CityServiceTest {// rest ommitted for clarity// BDD 风格:Given-When-Then@Testpublic void testGetById_entityExists_getSuccess() throws Exception {// Given these pre-conditionsInteger id = 1;City city = City.builder().id(id).build();given(cityMapper.getById(id)).willReturn(city);// When this method is executedCity actualCity = cityService.getById(id);// Then this should be the resultassertThat(actualCity.getId(), is(id));}// TDD 风格:Arrange-Act-Assert@Testpublic void testGetById_entityExists_getSuccess_with3A() throws Exception {// ArrangeInteger id = 1;City city = City.builder().id(id).build();when(cityMapper.getById(id)).thenReturn(city);// ActCity actualCity = cityService.getById(id);// AssertassertThat(actualCity.getId(), is(id));}// rest ommitted for clarity}
方法体内遵循三段式(无论是 BDD 风格还是 TDD 风格)我们每次进行单元测试的时候,不妨关注一下这
注:BDD (Behavior-Driven Development) 指行为驱动开发;TDD (Test-Driven Development) 指测试驱动开发。
测试类命名:在被测试类名后加上 Test
UserService 类写单元测试,那么测试命名名为 “UserServiceTest”测试方法命名:在测试方法名上描述被测试方法的名字、期望的输入或者状态和期望的行为
registerNewUserAccount(),那么我们的单元测试方法名为 registerNewUserAccount_ExistingEmailAddressGiven_ShouldThrowException().
public class CityServiceTest {@Testpublic void testGetById_entityExists_getSuccess() throws Exception {...}@Test(expected = EntityNotFoundException.class)public void testGetById_entityNotExists_getFailure() throws Exception {...}}