[关闭]
@yishuailuo 2017-08-04T19:29:33.000000Z 字数 11691 阅读 689

谈谈 Java 单元测试与集成测试


一、前言

今天我们来谈谈 java 平台上的单元测试与集成测试,其他语言平台上的笔者不熟悉,暂且不论。既然要谈论,那么得先明确谈论的对象,即到底什么是单元测试,什么是集成测试。

首先我们从软件开发生命周期中的测试谈起,常见的软件开发生命周期模型有“瀑布模型”、“迭代模型”、“螺旋模型”、“ V 模型”、“大爆炸模型”等等,这里以瀑布模型与迭代模型为例。

瀑布模型的软件开发生命周期中,关键活动有需求分析、系统设计、编码、测试、维护;在迭代模型的软件开发生命周期中,关键活动有设计、编码、测试、验证。在两个模型中,测试都有相当重要的位置。模型的中测试一般包括单元测试、集成测试、系统测试、验收测试。我们可以看到,单元测试与集成测试最先执行的测试,也是发现软件缺陷最重要的两个测试。我们先来简单地描述一下这四个测试:

在初步了解什么是单元测试与集成测试之后,下面的章节我们逐一地详细谈谈。

二、单元测试

在企业级软件开发中,单元测试在保证软件质量保持开发速度上起到的作用似乎已经无需赘言,然而,也许只有少数团队能够理解单元测试真正的价值,并且发挥其价值于软件开发实践中。在这里,我们不妨重申一下单元测试的价值,来回答许多开发人员(特别是对代码有极高自信 的极客)对于为什么要做单元测试的疑问。

2.1 为什么要做单元测试

2.1.1 单元测试的价值(单测与否的影响)

总的来讲,自动化的单元测试能发现错误保护回归帮助设计,从而改善生产力,使我们获得并保持开发速度

单元测试主要有以下价值:

1. 帮助我们捕获错误

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

3. 价值不在于结果,而在于编写单测的学习

2.2.2 单测层面影响生产力的因素(单测质量的影响)

我们对单元测试的代码质量的追求,应该如同对待生产代码一样,从代码的执行速度、可读性、可靠性、可信赖性与可维护性上提升单元测试代码的质量。

从上图我们可以看出,单元测试的代码质量(执行速度、可读性、可靠性、可信赖性)通过影响开发的反馈环长度调试间接地影响开发者的生产力。

我们先来谈谈反馈环长度与调试时间:

  1. 反馈环长度:在单元测试过程中,开发者需要通过单元测试的结果反馈来得知正在开发的软件单元(方法、函数或模块)是否正确。获得反馈的时间越长,开发者的生产力越低
    • 无论测试执行中开发者是在等待测试结果反馈,还是转去做别的任务,开发者的生产力都会受到影响
      • 如果在等待,那很明显无法生产
      • 如果转去做别的任务,待测试执行完毕,也需要花费很长的时间恢复上下文与专注力(特别是测试结果为失败是尤甚)
  2. 调试时间:在单元测试过程中,开发者有时候需要调试来找出软件的缺陷。花费在调试上的时间越多,开发者的生产力越低

接着简单解析一下图中的含义:

  1. 测试的执行速度的提高,有助于缩短反馈环长度
    • mock 对象替换慢依赖
    • 并发执行单元测试
    • 异步测试时减少 Thread.sleep 的使用
    • 减少冗余的 setUp 与 tearDown
    • ...
  2. 测试的可读性的提高,有助于避免调试
    • 命名整洁规范
    • 减少配置层级,让完整的测试配置清晰可见
    • ...
  3. 测试的可靠性的提高,有助于改进测试结果的精确度,进而防止测试代码的缺陷,有助于避免调试
    • 不依赖时间、随机数
    • 不依赖测试用例顺序
    • 不依赖不稳定数据源
    • ...
  4. 测试的可信赖性的提高,有助于改进测试结果的精确度,进而防止测试代码的缺陷,有助于避免调试
    • 测试代码(命名注释等)正确表达测试的意图
    • 没有错误的测试代码注释误导单测人员
    • 没有莫名注释掉的测试代码干扰单测人员
    • ...

2.2.3 无需单元测试的场景

在了解了单元测试的价值之后,接下来的章节我们谈谈如何做单元测试。

2.2 如何做单元测试

要做单元测试,首先得了解单元测试的工具,本文主要讲 Java 平台下的单元测试,那么只介绍 Java 平台常用的单元测试的工具。

2.2.1 单元测试的工具

2.2.2 单元测试简单例子

  1. @Service
  2. public class CityServiceImpl implements CityService {
  3. @Autowired
  4. private CityMapper cityMapper;
  5. @Override
  6. public City getById(Integer id) {
  7. City city = cityMapper.getById(id);
  8. if (null == city) {
  9. throw new EntityNotFoundException("NoCity");
  10. }
  11. return city;
  12. }
  13. }

这里有一个 service 实现类叫 cityServiceImpl,其中的方法 getById 调用了 cityMapper 方法获取 city 实体,如果有则返回实体,否则抛 EntityNotFoundException 异常。我们来看看针对这个 service 实现类的 getById 方法的单元测试都需要具备哪些元素。

  1. public class CityServiceTest {
  2. @InjectMocks
  3. private CityServiceImpl cityService;
  4. @Mock
  5. private CityMapper cityMapper;
  6. @Before
  7. public void setUp() {
  8. MockitoAnnotations.initMocks(this);
  9. }
  10. @Test
  11. public void testGetById_entityExists_getSuccess() throws Exception {
  12. // Given these pre-conditions
  13. Integer id = 1;
  14. City city = City.builder().id(id).build();
  15. given(cityMapper.getById(id)).willReturn(city);
  16. // When this method is executed
  17. City actualCity = cityService.getById(id);
  18. // Then this should be the result
  19. assertThat(actualCity.getId(), is(id));
  20. }
  21. @After
  22. public void tearDown() {
  23. }
  24. }

上面这个是测试类 CityServiceTest,使用了 JUnitmockito。类中首先声明被测试的类 CityServiceImpl,标注 @InjectMocks 注解表示被 mock 对象注入,然后声明 CityMapper,标注 @Mock 注解表示 mock 对象,此时被测试对象 CityServiceImpl 与被测试对象的 mock 依赖 CityMapper 已经准备好,可以开始测试。

接着我们开始写一个获取实体成功测试用例,方法为 testGetById_entityExists_getSuccess,标注 @Test 表示方法会被 JUnit 框架识别为测试方法。

  1. 方法体里第一步先模拟 cityMapper 的行为,当传入参数 id =1 调用它的 getById 方法时,返回预先构造的 city 实体对象;
  2. 方法的第二步真正调用 cityService 中被测试的方法 getById 来获得实体对象;
  3. 方法的第三步检验实际获取的实体对象和预期的是否一致,并且没有抛异常。

测试方法被调用之前,需要注入 mock 对象到被测试类的实例,在被 @Before 标注的 setup 方法中执行注入操作。另外,标注了 @AftertearDown 方法可以写在测试方法调用之后需要执行的代码,一般为释放资源,还原状态等。

到这里一个简单的单元测试解释完毕,我们可以看到,单元测试中的一个重点是需要 mock 被测方法所依赖的对象,并模拟对象的多种行为,以便覆盖测试方法的多个路径,从而验证测试方法是否编写正确。这里的 mock 对象通常叫测试替身,测试替身有多种类型,mock 对象是其中一种。下面我们来谈谈测试替身。

2.2.3 测试替身

在单元测试过程中,开发人员会写许多仅供测试的方法或者类,这些方法或者类通常用于隔离被测代码加速执行测试使随机行为变得确定模拟特殊情况、以及使测试能够访问隐藏信息。它们被称作测试替身(test double)

2.2.3.1 测试替身的作用

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

针对上面的简单例子,测试替身的作用如下:

当然,除了隔离被测代码,测试替身还有如下作用:

这里我们着重讲一下测试替身访问隐藏信息这个作用。假设有一个 Car 类,依赖 Engine 引擎来启动,start 方法返回值。具体代码如下:

  1. public class Car {
  2. private Engine engine;
  3. public Car(Engine engine) {
  4. this.engine = engine;
  5. }
  6. public void start() {
  7. engine.start();
  8. }
  9. }
  10. interface Engine {
  11. void start();
  12. }
  13. class EngineImpl implements Engine {
  14. @Override
  15. public void start() {
  16. }
  17. }

然后针对 start 方法做单元测试,如果不使用测试替身,则难以观测到引擎是否启动了,比如下面那样,当 Car 实例启动之后,引擎 engine 到底启动了没有呢?方法是没有返回值,不方便做检验。我们可能一下子会想到在 Enginestart 方法中 print 一下来观测,不过这会侵入生产代码,似乎不太好

  1. public class CarTest {
  2. @Test
  3. public void testStart_engineStart_startSuccess_withoutSpy() {
  4. // Given these pre-conditions
  5. Engine engine = new EngineImpl(); // real dependency
  6. Car car = new Car(engine);
  7. // When this method is executed
  8. car.start();
  9. // Then this should be the result
  10. // how to assert? print something in engine's start method?
  11. }
  12. }

那么如何解决呢,这时候我们想到了测试间谍 spy。下面我们不妨看看测试间谍是如何窃取引擎是否启动的信息来给开发者做汇报的。

  1. public class CarTest {
  2. @Test
  3. public void testStart_engineStart_startSuccess_withSpy() {
  4. // Given these pre-conditions
  5. EngineSpy engine = new EngineSpy();
  6. Car car = new Car(engine);
  7. // When this method is executed
  8. car.start();
  9. // Then this should be the result
  10. assertThat(engine.isRunning(), is(true));
  11. }
  12. }
  13. class EngineSpy implements Engine {
  14. private boolean isRunning;
  15. @Override
  16. public void start() {
  17. isRunning = true;
  18. }
  19. public boolean isRunning() {
  20. return isRunning;
  21. }
  22. }

创建测试间谍类 EngineSpy 实现 Engine 接口,通过 isRunning 标志位来观测 start 方法的执行情况。那么在单元测试就可以验证引擎的启动情况。这就是测试替身访问隐藏信息作用的一个简单例子。

除了测试间谍,测试替身还有三个类型 —— 测试桩、伪造对象、模拟对象

2.2.3.1 测试替身的类型

总的来讲,要为单元测试挑选合适的测试替身

2.2.4 让测试独立

当我们要进行单元测试时,特别要留意被测代码的外部依赖,这些依赖包括:

如果测试中没有做好隔离,将难以运行和维护我们的测试。比如:

那么,我们需要尽量避免这些复杂的依赖,尝试做如下事情:

2.2.3 JUnit

2.2.3.1 JUnit 单测类基本结构

常见的使用 JUnit 做单元测试的基本结构

  1. public class JUnitTest extends SuperJUnitTest {
  2. // 使用 JUnit Rule 为每个类前后做初始和清理工作
  3. @BeforeClass
  4. public static void setUpClass() {
  5. // init before test class
  6. System.out.println("JUnitTest BeforeClass setUp ");
  7. }
  8. @Before
  9. public void setUp() {
  10. // init before test method
  11. System.out.println("JUnitTest Before setUp");
  12. }
  13. // test1 test2 test3 不一定按照方法在测试类中的顺序执行。
  14. @Test
  15. public void testSomeMethod1_when_then() throws Exception {
  16. System.out.println("JUnitTest test1");
  17. }
  18. @Test
  19. public void testSomeMethod2_when_then() throws Exception {
  20. System.out.println("JUnitTest test2");
  21. }
  22. @Test
  23. public void testSomeMethod3_when_then() throws Exception {
  24. System.out.println("JUnitTest test3");
  25. }
  26. @After
  27. public void tearDown() {
  28. // clear after test method
  29. System.out.println("JUnitTest After tearDown");
  30. }
  31. @AfterClass
  32. public static void tearDownClass() {
  33. // clear after test class
  34. System.out.println("JUnitTest AfterClass tearDown");
  35. }
  36. }
  37. print result:
  38. ----------------------------
  39. JUnitTest BeforeClass setUp
  40. JUnitTest Before setUp
  41. JUnitTest test1
  42. JUnitTest After tearDown
  43. JUnitTest Before setUp
  44. JUnitTest test2
  45. JUnitTest After tearDown
  46. JUnitTest Before setUp
  47. JUnitTest test3
  48. JUnitTest After tearDown
  49. JUnitTest AfterClass tearDown
  50. ----------------------------

当测试类有继承关系的情况下,在 JUnit 以前的版本中,当执行子类的测试方法前,是会先后执行父类的 @BeforeClass @Before 以及 @After 与 @AfterClass 方法的,现在似乎已经不执行了,这块需要继续再研究。

2.2.3.2 JUnit 单测方法体基本结构
  1. public class CityServiceTest {
  2. // rest ommitted for clarity
  3. // BDD 风格:Given-When-Then
  4. @Test
  5. public void testGetById_entityExists_getSuccess() throws Exception {
  6. // Given these pre-conditions
  7. Integer id = 1;
  8. City city = City.builder().id(id).build();
  9. given(cityMapper.getById(id)).willReturn(city);
  10. // When this method is executed
  11. City actualCity = cityService.getById(id);
  12. // Then this should be the result
  13. assertThat(actualCity.getId(), is(id));
  14. }
  15. // TDD 风格:Arrange-Act-Assert
  16. @Test
  17. public void testGetById_entityExists_getSuccess_with3A() throws Exception {
  18. // Arrange
  19. Integer id = 1;
  20. City city = City.builder().id(id).build();
  21. when(cityMapper.getById(id)).thenReturn(city);
  22. // Act
  23. City actualCity = cityService.getById(id);
  24. // Assert
  25. assertThat(actualCity.getId(), is(id));
  26. }
  27. // rest ommitted for clarity
  28. }

方法体内遵循三段式(无论是 BDD 风格还是 TDD 风格)我们每次进行单元测试的时候,不妨关注一下这

注:BDD (Behavior-Driven Development) 指行为驱动开发;TDD (Test-Driven Development) 指测试驱动开发。
2.2.3.3 JUnit 单测命名规范

测试类命名:在被测试类名后加上 Test

测试方法命名:在测试方法名上描述被测试方法的名字、期望的输入或者状态和期望的行为

  1. public class CityServiceTest {
  2. @Test
  3. public void testGetById_entityExists_getSuccess() throws Exception {...}
  4. @Test(expected = EntityNotFoundException.class)
  5. public void testGetById_entityNotExists_getFailure() throws Exception {...}
  6. }

2.3 Mockito (todo)

2.4 PowerMock (todo)

2.5 Code Coverage (todo)

三、集成测试(todo)

3.1 Spring Test Framework

3.2 in-memory db

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注