@yishuailuo
2017-08-04T19:29:33.000000Z
字数 11691
阅读 689
今天我们来谈谈 java 平台上的单元测试与集成测试,其他语言平台上的笔者不熟悉,暂且不论。既然要谈论,那么得先明确谈论的对象,即到底什么是单元测试,什么是集成测试。
首先我们从软件开发生命周期中的测试谈起,常见的软件开发生命周期模型有“瀑布模型”、“迭代模型”、“螺旋模型”、“ V 模型”、“大爆炸模型”等等,这里以瀑布模型与迭代模型为例。
在瀑布模型的软件开发生命周期中,关键活动有需求分析、系统设计、编码、测试、维护;在迭代模型的软件开发生命周期中,关键活动有设计、编码、测试、验证。在两个模型中,测试都有相当重要的位置。模型的中测试一般包括单元测试、集成测试、系统测试、验收测试。我们可以看到,单元测试与集成测试是最先执行的测试,也是发现软件缺陷最重要的两个测试。我们先来简单地描述一下这四个测试:
单元测试:对程序中的单元(函数、方法或者模块)进行测试的过程
集成测试:在单元测试的基础之上,将单元或模块组装成子系统再进行测试,主要是测试单元或者模块之间的接口
系统测试:针对整个系统的测试,目标是验证系统是否满足需求规格说明,功能及性能是否满足规约
验收测试:目标是验证软件满足用户的需求,确保软件就绪可部署
在初步了解什么是单元测试与集成测试之后,下面的章节我们逐一地详细谈谈。
在企业级软件开发中,单元测试在保证软件质量与保持开发速度上起到的作用似乎已经无需赘言,然而,也许只有少数团队能够理解单元测试真正的价值,并且发挥其价值于软件开发实践中。在这里,我们不妨重申一下单元测试的价值,来回答许多开发人员(特别是对代码有极高自信 的极客)对于为什么要做单元测试的疑问。
总的来讲,自动化的单元测试能发现错误,保护回归,帮助设计,从而改善生产力,使我们获得并保持开发速度。
单元测试主要有以下价值:
1. 帮助我们捕获错误
2. 帮助我们针对实际使用来塑造设计
3. 价值不在于结果,而在于编写单测的学习
我们对单元测试的代码质量的追求,应该如同对待生产代码一样,从代码的执行速度、可读性、可靠性、可信赖性与可维护性上提升单元测试代码的质量。
从上图我们可以看出,单元测试的代码质量(执行速度、可读性、可靠性、可信赖性)通过影响开发的反馈环长度与调试间接地影响开发者的生产力。
我们先来谈谈反馈环长度与调试时间:
接着简单解析一下图中的含义:
在了解了单元测试的价值之后,接下来的章节我们谈谈如何做单元测试。
要做单元测试,首先得了解单元测试的工具,本文主要讲 Java 平台下的单元测试,那么只介绍 Java 平台常用的单元测试的工具。
@Service
public class CityServiceImpl implements CityService {
@Autowired
private CityMapper cityMapper;
@Override
public 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 {
@InjectMocks
private CityServiceImpl cityService;
@Mock
private CityMapper cityMapper;
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
}
@Test
public void testGetById_entityExists_getSuccess() throws Exception {
// Given these pre-conditions
Integer id = 1;
City city = City.builder().id(id).build();
given(cityMapper.getById(id)).willReturn(city);
// When this method is executed
City actualCity = cityService.getById(id);
// Then this should be the result
assertThat(actualCity.getId(), is(id));
}
@After
public 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 {
@Override
public void start() {
}
}
然后针对 start
方法做单元测试,如果不使用测试替身,则难以观测到引擎是否启动了,比如下面那样,当 Car
实例启动之后,引擎 engine
到底启动了没有呢?方法是没有返回值,不方便做检验。我们可能一下子会想到在 Engine
的 start
方法中 print
一下来观测,不过这会侵入生产代码,似乎不太好。
public class CarTest {
@Test
public void testStart_engineStart_startSuccess_withoutSpy() {
// Given these pre-conditions
Engine engine = new EngineImpl(); // real dependency
Car car = new Car(engine);
// When this method is executed
car.start();
// Then this should be the result
// how to assert? print something in engine's start method?
}
}
那么如何解决呢,这时候我们想到了测试间谍 spy。下面我们不妨看看测试间谍是如何窃取引擎是否启动的信息来给开发者做汇报的。
public class CarTest {
@Test
public void testStart_engineStart_startSuccess_withSpy() {
// Given these pre-conditions
EngineSpy 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;
@Override
public 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 {
@Override
public void log(LoggerLevel loggerLever, String message) {
System.out.println("log something"); // 简单的 print 输出作为 log 测试桩
}
@Override
public 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<>();
@Override
public void save(User user) {
if(findById(user.getId()) != null ) {
users.add(user);
}
}
@Override
public User findById(Integer id) {
for(User user : users) {
if (user.getId() == id) {
return user;
}
}
return null;
}
}
测试间谍 Spy(窃取隐藏信息)
public class CarTest {
@Test
public void testStart_engineStart_startSuccess_withSpy() {
// Given these pre-conditions
EngineSpy 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;
@Override
public void start() {
isRunning = true;
}
public boolean isRunning() {
return isRunning;
}
}
模拟对象 Mock(反对惊喜)
public class CityServiceTest {
// rest ommitted for clarity
@Test
public void testGetById_entityExists_getSuccess() throws Exception {
// Given these pre-conditions
Integer 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-conditions
Integer 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 为每个类前后做初始和清理工作
@BeforeClass
public static void setUpClass() {
// init before test class
System.out.println("JUnitTest BeforeClass setUp ");
}
@Before
public void setUp() {
// init before test method
System.out.println("JUnitTest Before setUp");
}
// test1 test2 test3 不一定按照方法在测试类中的顺序执行。
@Test
public void testSomeMethod1_when_then() throws Exception {
System.out.println("JUnitTest test1");
}
@Test
public void testSomeMethod2_when_then() throws Exception {
System.out.println("JUnitTest test2");
}
@Test
public void testSomeMethod3_when_then() throws Exception {
System.out.println("JUnitTest test3");
}
@After
public void tearDown() {
// clear after test method
System.out.println("JUnitTest After tearDown");
}
@AfterClass
public static void tearDownClass() {
// clear after test class
System.out.println("JUnitTest AfterClass tearDown");
}
}
print result:
----------------------------
JUnitTest BeforeClass setUp
JUnitTest Before setUp
JUnitTest test1
JUnitTest After tearDown
JUnitTest Before setUp
JUnitTest test2
JUnitTest After tearDown
JUnitTest Before setUp
JUnitTest test3
JUnitTest After tearDown
JUnitTest AfterClass tearDown
----------------------------
当测试类有继承关系的情况下,在 JUnit 以前的版本中,当执行子类的测试方法前,是会先后执行父类的 @BeforeClass @Before 以及 @After 与 @AfterClass 方法的,现在似乎已经不执行了,这块需要继续再研究。
public class CityServiceTest {
// rest ommitted for clarity
// BDD 风格:Given-When-Then
@Test
public void testGetById_entityExists_getSuccess() throws Exception {
// Given these pre-conditions
Integer id = 1;
City city = City.builder().id(id).build();
given(cityMapper.getById(id)).willReturn(city);
// When this method is executed
City actualCity = cityService.getById(id);
// Then this should be the result
assertThat(actualCity.getId(), is(id));
}
// TDD 风格:Arrange-Act-Assert
@Test
public void testGetById_entityExists_getSuccess_with3A() throws Exception {
// Arrange
Integer id = 1;
City city = City.builder().id(id).build();
when(cityMapper.getById(id)).thenReturn(city);
// Act
City actualCity = cityService.getById(id);
// Assert
assertThat(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 {
@Test
public void testGetById_entityExists_getSuccess() throws Exception {...}
@Test(expected = EntityNotFoundException.class)
public void testGetById_entityNotExists_getFailure() throws Exception {...}
}