TDD:我哪里弄错了?


如果你使用测试驱动开发(TDD)进行开发,有可能你用了错误的方法。这种情况的症状非常明显。如果你有一个巨大的测试套件,每当你插入一个新的特性或者对代码做一个改变时,它就会崩溃,最后你不得不重写测试来使它再次通过,那么你就在那里了。我们都去过那里,有时还会去。

我被这震惊了presentation from Ian Cooper在开发网站上询问TDD哪里出了问题。问题是,在某个时候,我们开始把测试的重点放在方法上,而不是放在特性和场景上。为了使发展驱动型发展回归正轨,行为驱动型发展应运而生。实际上,根据伊恩的说法,BDD就是TDD应该有的样子。所有这些“给定,当,然后”帮助我们思考场景,停止思考方法。

Image title

因此,让我们尝试一个例子,使用正确的TDD方式进行开发。让我们假设我们的系统中有一部分负责保存销售的产品和服务的历史记录。我们想保存一个记录,并检索所有历史记录的列表。给定下面的“历史记录”,让我们构建我们的“滞后记录控制器。”

public abstract class HistoricalRecord {
//bunch of properties here
}

public class ProductHistoricalRecord extends HistoricalRecord {
//bunch of properties here
}

public class ServiceHistoricalRecord extends HistoricalRecord{
//bunch of properties here
}


第一个场景必须是最简单的。假设没有添加记录,当我们检索记录列表时,该列表必须为空。这个测试可能看起来是一个无用的需求。但是,有多少次您必须检查null,因为有人认为在没有记录可返回时检索并返回null是个好主意?

TDD有许多实践。其中一个是红绿相间的:首先,你写测试,所以它失败了。然后,您编写最简单的代码来使它通过。

@Test
public void testRetrieveEmptyList() throws Exception {
//GIVEN
final HistoricalRecordController recordController = new HistoricalRecordController();
//WHEN
final List<HistoricalRecord> historicalRecords = recordController.listAll();
//THEN
assertEquals(0, historicalRecords.size());
}


public class HistoricalRecordController {

public List<HistoricalRecord> listAll() {
return Collections.EMPTY_LIST;
}
}

下一个场景是:给定一个历史记录,当我们保存这个记录时,检索到的列表必须只包含一个记录,并且它们必须相等。让我们开始吧。


@Test
public void testRetrieveOneRecord() throws Exception {
//GIVEN
final ProductHistoricalRecord historicalRecord = new ProductHistoricalRecord();
final HistoricalRecordController recordController = new HistoricalRecordController();
recordController.saveRecord(historicalRecord);
//WHEN 
final List<HistoricalRecord> historicalRecords = recordController.listAll();
//THEN
assertEquals(1, historicalRecords.size());
assertEquals(historicalRecord, historicalRecords.get(0));
}

现在我们做最直接的实现,满足两个测试:

public class HistoricalRecordController {

  List<HistoricalRecord> historicalRecords = new ArrayList<>();

public List<HistoricalRecord> listAll() {
return Collections.unmodifiableList(historicalRecords);
}

public void saveRecord(final ProductHistoricalRecord historicalRecord) {
historicalRecords.add(historicalRecord);
}
}

然而,这个实现将记录保存在内存中。我们应该实现一个HistoricalRecordRepository 根据我们的历史记录。我们的HistoricalRecordController 应该保存和检索的列表HistoricalRecords从这个仓库。


public interface HistoricalRecordRepository {
List<HistoricalRecord> listAll();
void create(ProductHistoricalRecord historicalRecord);
}


public class HistoricalRecordController {

private final HistoricalRecordRepository repository;

public HistoricalRecordController(final HistoricalRecordRepository repository) {
this.repository = repository;
}

public List<HistoricalRecord> listAll() {
return repository.listAll();
}

public void saveRecord(final ProductHistoricalRecord historicalRecord) {
repository.create(historicalRecord);
}
}

现在一切又都是红色的。在看伊恩的演示之前,我会试着重写测试,看着它们通过。所以想到我的实现,我会嘲笑我的HistoricalRecordRepository,注射到我的HistoricalRecordController 并检查该方法是否create()恰好被调用了一次:

@Test
public void testSaveRecord() throws Exception {
final HistoricalRecordRepository repository = Mockito.mock(HistoricalRecordRepository.class);
final ProductHistoricalRecord historicalRecord = new ProductHistoricalRecord();
final HistoricalRecordController recordController = new HistoricalRecordController(repository);
recordController.saveRecord(historicalRecord);
Mockito.verify(repository, times(1)).create(historicalRecord);
}


目前,这看起来还可以。然而,我们知道未来会发生什么。更多的特性出现了,我们改变了我们的实现,突然所有的刹车都失去了。我们用模型做的所有测试都失败了。此外,一些测试与其他类的逻辑实现相结合。那些也坏了。我们将不得不重做一切,因为我们测试了我们的实现,而不是测试场景,现在实现发生了变化。举起我们的手,谁有时会面对这个?

然而,如果我们针对所涉及的场景构建测试,只需要做一些小的改变来满足业务需求。

我们现在为HistoricalRecordRepository 来满足依赖性并重新运行我们的测试。

public class HistoricalRecordRepositoryStub implements HistoricalRecordRepository {

private final List<HistoricalRecord> records = new ArrayList<>();

@Override
public List<HistoricalRecord> listAll() {
return Collections.unmodifiableList(records);
}

@Override
public void create(final ProductHistoricalRecord historicalRecord) {
records.add(historicalRecord);
}
}


public class HistoricalRecordControllerTest {

@Test
public void testRetrieveEmptyList() throws Exception {
//GIVEN
final HistoricalRecordController recordController = new HistoricalRecordController(new HistoricalRecordRepositoryStub());
//WHEN
final List<HistoricalRecord> historicalRecords = recordController.listAll();
//THEN
assertEquals(0, historicalRecords.size());
}

@Test
public void testRetrieveOneRecord() throws Exception {
//GIVEN
final ProductHistoricalRecord historicalRecord = new ProductHistoricalRecord();
final HistoricalRecordController recordController = new HistoricalRecordController(new HistoricalRecordRepositoryStub());
recordController.saveRecord(historicalRecord);
//WHEN 
final List<HistoricalRecord> historicalRecords = recordController.listAll();
//THEN
assertEquals(1, historicalRecords.size());
assertEquals(historicalRecord, historicalRecords.get(0));
}
}

我们从中可以得出的结论是,只要我们测试的业务需求是真实的,我们的测试就不会改变。此外,我们可以清楚地了解特定测试中涉及的场景以及实际测试的内容。比较一下testRetrieveOneRecord() 反对testSaveRecord()。经过几个月不断增长的代码基础,你认为哪一个是被测试的需求?你会接受哪一种测试?哪一个可能被打破?