单元测试中的Mock
Mock和Stub是单元测试工作中经常听到的名词。那么什么是Mocking?简而言之,就是创建一个受自己控制的实例(instance)或实现(implemention),替换被测代码中的依赖部分。
所谓受控的实例,是指依赖的行为是按自己所预期那样设计的,因而可以消除运行时的不确定性,解决测试用例运行时的环境依赖等问题,只关注核心代码逻辑的测试。
用一个实际业务系统来举例子,大多数的应用分为三层,分别是用户接口(User Interface),业务层(Business Layer)和数据接入层(Data Access Layer)。如下图所示。
在图中可以看到,业务层有3个依赖,分别是数据接入层和两个其他的服务。设想这是一个地图App,那么它们包括:
- 一个比如Mysql的数据库或其他NoSql的数据库,用来存储数据。
- 一个外部的服务,例如定位服务,提供经纬度信息。
- 一个外部的服务,例如交通信息服务,提供实时交通信息。
若要用单元测试来测试这样一个地图App的业务逻辑,除非先部署好这3个依赖,否则测试用例就运行不起来。
采用Mock方法可以应对这种情况,不管依赖服务是否已经运行起来。因为我们将使用自己设定的结果替换掉依赖的服务。
测试替身(Test doubles)的分类
Mock本质上是一种“测试替身”——这是一个技术术语。“测试双”本质上是指一个被等效的实际对象实例或依赖所替代的对象。
测试替身有多种分类,具体来说:
1. Facks(伪替身)
伪依赖是一个和真实依赖一样能运行的实例,唯一的不同是Fack位于本地系统。
例如,在测试中使用一个简单的集合数据结构,或者内存数据,来代替实际依赖的生产数据库。
2. Stubs(打桩)
打桩是使用一个预设的返回值代替调用实际依赖组件而返回的结果。
3. Spies(监视)
顾名思义,Spy是在调用实际依赖函数的时候,提供一种监视机制。这样就可以验证代码是否调到了某个函数,并拿到传入的参数进行验证。
4. Mocks(模拟)
Mock是一种特殊的对象实例,集合了打桩、监视的功能。既可以设定打桩,或者预设的返回值,也可以在事后验证mock对象的方法是否被正确调用到了。
例如,有一个报表生成器的函数,它在运行过程中向指定的地址发送电子邮件。由于我们不想发送实际的电子邮件,在测试期间,EmailService会被一次又一次地mock(发送电子邮件的电子邮件方法在被调用时被配置为不执行任何操作)。在测试结束时,我们只需要验证邮件服务确实调用到了我们mock的方法,并传进去了正确的邮箱地址。
Mock 框架
几乎所有的语言都提供了不同类型的mocking 框架。这里以Java的Mockito来举例。
假设我们要对一个应用程序进行单元测试,该应用程序计算一个学生在所有科目中的总分,并将其写入数据库。函数如下。
public void calculateSumAndStore(String studentId, int[] scores) {
int total = 0;
for (int score : scores) {
total = total + score;
} // write total to DB
databaseImpl.updateScores(studentId, total);
}
我们要为方法calculateSumAndStore编写单元测试,但我们可能没有一个真正的数据库实现来存储总数。在这种情况下,我们将永远无法对该函数进行单元测试。
但是有了Mock之后,我们可以简单地传递一个用于数据库服务的Mock,并验证其余的逻辑。
测试代码的样例如下:
@Test
public void calculateSumAndStore_withValidInput_shouldCalculateAndUpdateResultInDb() {
// Arrange
studentScores = new StudentScoreUpdates(mockDatabase);
int[] scores = { 60, 70, 90 };
Mockito.doNothing().when(mockDatabase).updateScores("student1", 220);
// Act
studentScores.calculateSumAndStore("student1", scores);
// Assert
Mockito.verify(mockDatabase, Mockito.times(1)).updateScores("student1", 220);
}
我们在被测代码的父类中提供了一个mock的数据库对象mockDatabase,并在第6行通过打桩设定了它的返回结果:
(Mockito.doNothing().when(mockDatabase).updateScores(“student1”, 220);)
其中的要点是:
- 需要给mock对象里可能被调用的函数设置打桩结果。
- 创建打桩时的输入参数可以是特定的,也可以是通用化的。在本例中,打桩函数的入参就是特定的student1” & 220,因为我们明确知道代码调用它时的参数。
- 在验证结果时,我们检验:
- mockDatabase.updateScores方法被调用了
- 调用时传入的参数分别是student1” 和220
- uodataScores方法总共被调用了1次
我们接着改变一下测试代码,将打桩的入参从特定的“student1”改为anyString()和anyInteger(). anyxxx()是Mockito提供的匹配器,表示该类型任意的值。
测试代码如下:
@Test
public void calculateSumAndStore_withValidInput_shouldCalculateAndUpdateResultInDb() {
// Arrange
studentScores = new StudentScoreUpdates(mockDatabase);
int[] scores = {60, 70, 90};
Mockito.doNothing().when(mockDatabase).updateScores(anyString(), anyInt());
// Act
studentScores.calculateSumAndStore("student1", scores);
// Assert
Mockito.verify(mockDatabase, Mockito.times(1)).updateScores("student1", 220);
}
这个时候,测试用例依旧是能通过的。
我们再改变一下代码,这次将验证语句中的值改掉。将220改成230.
Test
public void calculateSumAndStore_withValidInput_shouldCalculateAndUpdateResultInDb() {
// Arrange
studentScores = new StudentScoreUpdates(mockDatabase);
int[] scores = {60, 70, 90};
Mockito.doNothing().when(mockDatabase).updateScores(anyString(), anyInt());
// Act
studentScores.calculateSumAndStore("student1", scores);
// Assert
Mockito.verify(mockDatabase, Mockito.times(1)).updateScores("student1", 230);
}
这时测试用例就通不过了,报异常信息:
Argument(s) are different! Wanted:
mockDatabase.updateScores(“student1”, 230);
-> at com.mocking.sampleMocks.StudentScoreUpdatesUnitTests.calculateSumAndStore_withValidInput_shouldCalculateAndUpdateResultInDb(StudentScoreUpdatesUnitTests.java:37)
Actual invocation has different arguments:
mockDatabase.updateScores(“student1”, 220);
告诉我们“student1”, 230这样的组合参数没有被调到。
小结
以上通过一个简单而直观的例子展示了使用Mockito进行mock的方法。
虽然简单,但只要知道了Mockito,其他语言、其他大多数通过mock进行的单元测试,流程和原理都是类似的。Mockito为广泛的mock需求提供了大量高级配置/支持,使用依赖注入注入模拟实例,提供了Spies来监视真正的方法调用并验证调用结果。
示例完整代码
接口 IDatabase.java
public interface IDatabase {
public void updateScores(String studentId, int total);
}
被测代码 StudentScoreUpdates.java
public class StudentScoreUpdates {
public IDatabase databaseImpl;
public StudentScoreUpdates(IDatabase databaseImpl) {
this.databaseImpl = databaseImpl;
}
public void calculateSumAndStore(String studentId, int[] scores) {
int total = 0;
for (int score : scores) {
total = total + score;
}
// write total to DB
databaseImpl.updateScores(studentId, total);
}
}
测试用例类 – StudentScoreUpdatesUnitTests.java
public class StudentScoreUpdatesUnitTests {
@Mock
public IDatabase mockDatabase;
public StudentScoreUpdates studentScores;
@BeforeEach
public void beforeEach()
{
MockitoAnnotations.initMocks(this);
}
@Test
public void calculateSumAndStore_withValidInput_shouldCalculateAndUpdateResultInDb()
{
// Arrange
studentScores = new StudentScoreUpdates(mockDatabase);
int[] scores = {60,70,90};
Mockito.doNothing().when(mockDatabase).updateScores(anyString(), anyInt());
// Act
studentScores.calculateSumAndStore("student1", scores);
// Assert
Mockito.verify(mockDatabase, Mockito.times(1)).updateScores("student1", 230);
}
}