与所有工程问题一样,TDD 也不例外。答案总是“视情况而定”。总会有取舍。
在 TDD 的情况下,您首先通过行为预期来开发测试。根据我的经验,行为预期是一个单位。
举个例子,假设您想获取以姓氏“A”开头的所有用户,并且他们在系统中处于活动状态。因此,您将编写一个测试来创建一个控制器操作,以获取以“A”public ActionResult GetAllActiveUsersThatStartWithA() 开头的活跃用户。
最后,我可能会有这样的事情:
public ActionResultGetAllActiveUsersThatStartWithA()
{
var users = _repository.GetAllUsers();
var activeUsersThatStartWithA = users.Where(u => u.IsActive && u.Name.StartsWith('A');
return View(activeUsersThatStartWithA);
}
这对我来说是一个单位。然后我现在可以重构(通过使用以下方法添加 service 类来更改我的实现而不改变行为)
public IEnumerable<User> GetActiveUsersThatStartWithLetter(char startWith)
{
var users = _repository.GetAllUsers();
var activeUsersThatStartWithA = users.Where(u => u.IsActive && u.Name.StartsWith(startsWith);
}
我的控制器的新实现变成了
public ActionResultGetAllActiveUsersThatStartWithA()
{
return View(_service.GetActiveUsersThatStartWithLetter('A');
}
这显然是一个非常人为的例子,但它说明了我的观点。这样做的主要好处是我的测试不依赖于除repository 之外的任何实现细节。然而,如果我在我的测试中模拟了 service,我现在与该实现相关联。如果出于某种原因删除了 service 层,我的所有测试都会中断。我会发现service 层比repository 层更容易更改。
要考虑的另一件事是,如果我在控制器类中模拟 service,我可能会遇到我的所有测试都正常工作的情况,但我发现系统已损坏的唯一方法是通过集成测试(意味着进程外,或装配组件相互交互),或通过生产问题。
例如,如果我将 service 类的实现更改为以下:
public IEnumerable<User> GetActiveUsersThatStartWithLetter(char startsWith)
{
throw new Exception();
}
同样,这是一个非常人为的例子,但这一点仍然是相关的。我的controller 测试无法捕捉到这一点,因此看起来系统在我通过“单元测试”时表现正常,但实际上系统根本无法正常工作。
我的方法的缺点是测试可能会变得非常繁琐。因此,权衡是在测试复杂性与抽象/可模拟实现之间取得平衡。
要记住的关键是,TDD 提供了捕获回归的好处,但它的主要好处是帮助设计系统。换句话说,不要让设计决定你编写的测试。让测试先决定系统的功能,然后再通过重构来关注设计。