测试替补——假人、模拟人和替补队员。
伪造的
存根
命令查询分隔符
嘲笑
本文最初发表于实用主义者博客。
在自动化测试中,通常会使用外观和行为与生产环境中的对应对象相似,但实际上经过简化的对象。这降低了复杂性,使得代码验证可以独立于系统的其他部分,有时甚至需要执行自验证测试。测试替身(Test Double)是用于指代这些对象的通用术语。
尽管测试替身有很多种类型(Gerard Meszaros 在本文中介绍了五种),但人们通常使用“Mock”这个术语来指代不同类型的测试替身。对测试替身实现方式的误解和混用可能会影响测试设计,增加测试的脆弱性,从而阻碍我们实现无缝重构。
在本文中,我将介绍测试替身的三种实现变体:Fake、Stub 和 Mock,并举例说明何时使用它们。
伪造的
伪对象是指那些具有可运行实现但与生产环境实现不同的对象。它们通常会采用一些简化版本,使用生产环境代码的简化版。
这种快捷方式的一个例子是内存中数据访问对象或存储库的实现。这种模拟实现不会直接访问数据库,而是使用一个简单的集合来存储数据。这样,我们就可以在不启动数据库和执行耗时请求的情况下对服务进行集成测试。
@Profile("transient")
public class FakeAccountRepository implements AccountRepository {
Map<User, Account> accounts = new HashMap<>();
public FakeAccountRepository() {
this.accounts.put(new User("john@bmail.com"), new UserAccount());
this.accounts.put(new User("boby@bmail.com"), new AdminAccount());
}
String getPasswordHash(User user) {
return accounts.get(user).getPasswordHash();
}
}
除了测试之外,模拟实现对于原型设计和快速迭代开发也非常有用。我们可以快速实现并运行带有内存存储的系统,从而推迟数据库设计方面的决策。另一个例子是模拟支付系统,该系统始终返回支付成功的结果。
存根
存根对象保存预定义的数据,并在测试期间用于响应调用。当我们无法或不想使用会返回真实数据或产生不良副作用的对象时,可以使用存根对象。
例如,某个对象需要从数据库中获取一些数据来响应方法调用。我们用一个存根对象代替了实际对象,并定义了应该返回哪些数据。
public class GradesService {
private final Gradebook gradebook;
public GradesService(Gradebook gradebook) {
this.gradebook = gradebook;
}
Double averageGrades(Student student) {
return average(gradebook.gradesFor(student));
}
}
我们不直接从成绩册数据库获取学生的真实成绩,而是预先配置一个包含待返回成绩的存根。我们只定义了足够测试平均分计算算法的数据。
public class GradesServiceTest {
private Student student;
private Gradebook gradebook;
@Before
public void setUp() throws Exception {
gradebook = mock(Gradebook.class);
student = new Student();
}
@Test
public void calculates_grades_average_for_student() {
when(gradebook.gradesFor(student)).thenReturn(grades(8, 6, 10)); //stubbing gradebook
double averageGrades = new GradesService(gradebook).averageGrades(student);
assertThat(averageGrades).isEqualTo(8.0);
}
}
命令查询分隔符
返回结果但不改变系统状态的方法称为查询。例如avarangeGrades,返回学生平均成绩的方法就是一个很好的例子:Double getAverageGrades(Student student);
它返回一个值,并且没有副作用。正如我们在学生评分示例中所看到的,为了测试这类方法,我们使用桩函数(Stub)。我们用桩函数替换实际功能,为方法提供执行其工作所需的值。然后,方法返回的值可以用于断言。
还有另一类方法叫做命令方法。这类方法会执行一些操作,改变系统状态,但我们不期望它有任何返回值:void sendReminderEmail(Student student);
一个好的做法是将对象的方法分为这两个不同的类别。
这种做法被 Bertrand Meyer 在他的著作《面向对象软件构造》中命名为“命令查询分离” 。
对于查询类型方法的测试,我们应该优先使用桩对象(Stub),因为我们可以验证方法的返回值。但是对于命令类型方法,例如发送电子邮件的方法,该如何测试呢?因为它们不返回任何值。答案是使用模拟对象(Mock)——这是我们要介绍的最后一种测试对象。
嘲笑
模拟对象是会记录其接收到的调用的对象。在测试断言中,我们可以验证模拟对象是否执行了所有预期的操作。
当我们不想调用生产代码,或者没有简便的方法来验证预期代码是否已执行时,我们会使用模拟对象。模拟对象没有返回值,也没有简便的方法来检查系统状态的变化。例如,一个调用电子邮件发送服务的功能。
我们不希望每次运行测试时都发送电子邮件。此外,在测试中也很难验证是否发送了正确的电子邮件。我们唯一能做的就是验证测试中执行的功能的输出。换句话说,验证电子邮件发送服务是否被调用。
以下示例也呈现了类似的情况:
public class SecurityCentral {
private final Window window;
private final Door door;
public SecurityCentral(Window window, Door door) {
this.window = window;
this.door = door;
}
void securityOn() {
window.close();
door.close();
}
}
我们不想为了测试安全方法是否有效而关闭真正的门,对吧?相反,我们在测试代码中放置门窗的模拟对象。
public class SecurityCentralTest {
Window windowMock = mock(Window.class);
Door doorMock = mock(Door.class);
@Test
public void enabling_security_locks_windows_and_doors() {
SecurityCentral securityCentral = new SecurityCentral(windowMock, doorMock);
securityCentral.securityOn();
verify(doorMock).close();
verify(windowMock).close();
}
}
方法执行完毕后securityOn,门窗模拟对象记录了所有交互。这使我们能够验证门窗对象是否被指示自行关闭。从SecurityCental透视角度来看,这就是我们需要测试的全部内容。
你可能会问,如果我们使用模拟对象,如何判断门窗是否真的关闭了呢?答案是无法判断。但我们并不在意这一点。这不是模拟对象的职责。门窗会在收到正确信号时自行关闭,这是模拟对象SecurityCentral的职责。我们可以在不同的单元测试中独立测试这一点。DoorWindow
延伸阅读
测试替身- Martin Fowler
测试替身- xUnit 模式
模拟对象并非存根- Martin Fowler
命令查询分离- Martin Fowler
你喜欢这篇文章
您可以在Pragmatists 博客上找到更多关于 TDD 和测试的文章。
文章来源:https://dev.to/milipski/test-doubles---fakes-mocks-and-stubs


