当我们为应用程序编写自动化测试(单元测试或集成测试)时,我们应该很快注意到
- 许多测试用例使用相同的配置来创建重复的代码。
- 测试中使用的构建对象会创建重复代码。
- 编写断言会创建重复的代码。
首先想到的是消除重复的代码。 众所周知, 请勿重复(DRY)原则指出:
每条知识都必须在系统中具有单一,明确,权威的表示形式。
因此,我们通过创建一个或多个基类来开始工作并删除重复的代码,该基类可配置我们的测试并为其子类提供有用的测试实用工具方法。
不幸的是,这是一个非常幼稚的解决方案 。 继续阅读,我将介绍为什么我们不应该在测试中使用继承的三个原因。
- 继承不是重用代码的正确工具
DZone发表了对Misko Hevery的非常好的采访 ,他在其中解释了为什么继承不是重用代码的正确工具:
继承的重点是利用多态行为而不是重用代码 ,人们错过了这一点,他们将继承视为向类添加行为的一种廉价方法。 在设计代码时,我喜欢考虑选项。 当我继承时,我减少了选择。 我现在是该类的子类,不能成为其他东西的子类。 我将自己的构造永久地固定为超类的构造,并且我对超类不断变化的API有所保留。 我的更改自由在编译时固定。
尽管Misko Hevery在谈论编写可测试的代码,但我认为该规则也适用于测试。 但是在解释为什么会这样之前,让我们仔细看一下多态的定义 :
多态是为不同类型的实体提供单个接口。
这就是为什么我们在测试中使用继承的原因。 我们使用继承是因为它是重用代码或配置的简便方法 。 如果我们在测试中使用继承,则意味着
- 如果我们想确保只有相关的代码对我们的测试类可见,我们可能必须创建一个“复杂的”类层次结构,因为将所有内容都放在一个超类中并不是很“干净”。 这使得我们的测试很难阅读。
- 我们的测试类受其父类的支配,我们对此类父类所做的任何更改都会影响其每个子类。 这使得我们的测试“难以”编写和维护。
那么,为什么这很重要呢? 这很重要,因为测试也是代码! 这就是为什么该规则也适用于测试代码的原因。
顺便说一句,您是否知道在我们的测试中使用继承的决定也会产生实际后果?
- 继承会对测试套件的性能产生负面影响
如果我们在测试中使用继承,则它会对测试套件的性能产生负面影响。 为了了解其原因,我们必须了解JUnit如何处理类层次结构:
- 在JUnit调用测试类的测试之前,它会查找使用@BeforeClass注释进行注释的方法。 它使用反射遍历整个类的层次结构。 到达java.lang.Object后 ,它将调用所有带有@BeforeClass注释(首先是父对象)的方法。
- 在JUnit调用使用@Test注释进行注释的方法之前,它对使用@Before注释进行注释的方法执行相同的操作 。
- JUnit执行测试后,它将查找以@After注释进行注释的方法,并调用所有找到的方法。
- 在执行测试类的所有测试之后,JUnit再次遍历类层次结构,并查找使用@AfterClass注释注释的方法(并调用这些方法)。
换句话说,我们通过两种方式浪费CPU时间:
- 测试类层次结构的遍历浪费了CPU时间。
- 如果我们的测试不需要安装和拆卸方法,则它们会浪费CPU时间。
我从一本名为《 有效单元测试 》的书中学到了这一点。
您可能当然会争辩说,这不是一个大问题,因为每个测试用例仅花费几毫秒。 但是,很有可能您尚未测量实际需要多长时间。
还是你?
例如,如果每个测试用例仅花费2毫秒,并且我们的测试套件具有3000个测试,则我们的测试套件要比实际速度慢6秒。 听起来可能不会很长,但是当我们在自己的计算机上运行测试时,感觉就像是永恒 。
保持反馈循环尽可能快是我们的最大利益,浪费CPU时间并不能帮助我们实现该目标。
而且,浪费的CPU时间并不是减慢反馈循环速度的唯一原因。 如果我们在测试类中使用继承,则还必须付出一定的心理代价。
- 使用继承使测试更难阅读
自动化测试的最大好处是:
- 测试记录了我们的代码现在的工作方式。
- 测试可确保我们的代码正常运行。
我们想要使我们的测试易于阅读,因为
- 如果我们的测试易于阅读,那么很容易理解我们的代码是如何工作的。
- 如果我们的测试易于阅读,则测试失败很容易找到问题。 如果不使用调试器就无法找出问题所在,则我们的测试还不够清楚。
很好,但这并不能真正解释为什么使用继承会使我们的测试难以阅读。 我将通过一个简单的例子来说明我的意思。
假设我们必须为TodoCrudServiceImpl类的create()方法编写单元测试。 TodoCrudServiceImpl类的相关部分如下所示:
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class TodoCrudServiceImpl implements TodoCrudService { private TodoRepository repository; @Autowired public TodoCrudService(TodoRepository repository) { this.repository = repository; } @Transactional @Overrides public Todo create(TodoDTO todo) { Todo added = Todo.getBuilder(todo.getTitle()) .description(todo.getDescription()) .build(); return repository.save(added); } //Other methods are omitted. }
当我们开始编写此测试时,我们会记住DRY原理,并决定创建两个抽象类,以确保我们不会违反该原理。 毕竟,完成此测试后,我们必须编写其他测试,并且合理地使用尽可能多的代码是有意义的。
首先 ,我们创建AbstractMockitoTest类。 此类确保MockitoJUnitRunner调用从其子类找到的所有测试方法。 其源代码如下:
import org.junit.runner.RunWith; import org.mockito.runners.MockitoJUnitRunner; @RunWith(MockitoJUnitRunner.class) public abstract class AbstractMockitoTest { }
其次 ,我们创建AbstractTodoTest类。 此类为其他测试类提供了有用的实用程序方法和常量,这些其他类测试与todo条目相关的方法。 其源代码如下:
import static org.junit.Assert.assertEquals; public abstract class AbstractTodoTest extends AbstractMockitoTest { protected static final Long ID = 1L; protected static final String DESCRIPTION = "description"; protected static final String TITLE = "title"; protected TodoDTO createDTO(String title, String description) { retun createDTO(null, title, description); } protected TodoDTO createDTO(Long id, String title, String description) { TodoDTO dto = new DTO(); dto.setId(id); dto.setTitle(title); dto.setDescrption(description); return dto; } protected void assertTodo(Todo actual, Long expectedId, String expectedTitle, String expectedDescription) { assertEquals(expectedId, actual.getId()); assertEquals(expectedTitle, actual.getTitle()); assertEquals(expectedDescription, actual.getDescription()); } }
现在,我们可以为TodoCrudServiceImpl类的create()方法编写单元测试。 我们的测试类的源代码如下所示:
import org.junit.Before; import org.junit.Test; import org.mockito.Mock; import static org.mockito.Matchers.isA; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; public TodoCrudServiceImplTest extends AbstractTodoTest { @Mock private TodoRepository repositoryMock; private TodoCrudServiceImpl service; @Before public void setUp() { service = new TodoCrudServiceImpl(repository); } @Test public void create_ShouldCreateNewTodoEntryAndReturnCreatedEntry() { TodoDTO dto = createDTO(TITLE, DESCRIPTION); when(repositoryMock.save(isA(Todo.class))).thenAnswer(new Answer<Todo>() { @Override public Todo answer(InvocationOnMock invocationOnMock) throws Throwable { Todo todo = (Todo) invocationOnMock.getArguments()[0]; todo.setId(ID); return site; } }); Todo created = service.create(dto); verify(repositoryMock, times(1)).save(isA(Todo.class)); verifyNoMoreInteractions(repositoryMock); assertTodo(created, ID, TITLE, DESCRIPTION); } }
我们的单元测试真的很容易阅读吗? 最奇怪的是,如果我们仅快速浏览一下,它看起来就很干净。 但是,当我们仔细研究它时,我们开始询问以下问题:
- 看来TodoRepository是一个模拟对象。 此测试必须使用MockitoJUnitRunner 。 在哪里配置测试运行器?
- 单元测试通过调用createDTO()方法创建新的TodoDTO对象。 在哪里可以找到这种方法?
- 从此类中找到的单元测试使用常量。 这些常量在哪里声明?
- 单元测试通过调用assertTodo()方法来声明返回的Todo对象的信息。 在哪里可以找到这种方法?
这些似乎是“小”问题。 但是,找出这些问题的答案需要花费时间,因为我们必须阅读AbstractTodoTest和AbstractMockitoTest类的源代码。
如果我们无法通过阅读源代码来理解像这样的简单单元,那么很显然,试图理解更复杂的测试用例将非常痛苦 。
更大的问题是,像这样的代码使我们的反馈循环比必要的时间长得多。
我们应该做什么?
我们刚刚了解了三个我们不应该在测试中使用继承的原因。 显而易见的问题是:
如果我们不应该使用继承来重用代码和配置,我们应该怎么做?
这是一个很好的问题,我将在另一篇博客文章中回答。