想象一下下面这个人为的例子:
public class LoginController {
private readonly IValidate _validator;
private readonly IAuthenticate _authenticator;
public LoginController(IValidate validator, IAuthenticate authenticator) {
_validator = validator;
_authenticator = authenticator;
}
public HttpStatusCode Login(LoginRequest request) {
if (!_validator.IsValid(request)) {
return HttpStatusCode.BadRequest;
}
if (!_authenticator.IsAuthenticated(request.Email, request.Password)) {
return HttpStatusCode.Unauthorized;
}
return HttpStatusCode.OK;
}
}
public class LoginRequest {
public string Email {get; set;}
public string Password {get; set;}
}
public interface IValidate {
bool IsValid(LoginRequest request);
}
public interface IAuthenticate {
bool IsAuthenticated(string email, string password);
}
通常我会编写如下测试:
[TestFixture]
public class InvalidRequest
{
private LoginRequest _invalidRequest;
private IValidate _validator;
private HttpStatusCode _response;
void GivenARequest()
{
_invalidRequest = new LoginRequest();
}
void AndGivenThatRequestIsInvalid() {
_validator = Substitute.For<IValidate>();
_validator.IsValid(_invalidRequest).Returns(false);
}
void WhenAttemptingLogin()
{
_response = new LoginController(_validator, null)
.Login(_invalidRequest);
}
void ThenShouldRespondWithBadRequest()
{
Assert.AreEqual(HttpStatusCode.BadRequest, _response);
}
[Test]
public void Execute()
{
this.BDDfy();
}
}
public class LoginUnsuccessful
{
private LoginRequest _request;
private IValidate _validator;
private IAuthenticate _authenticate;
private HttpStatusCode _response;
void GivenARequest()
{
_request = new LoginRequest();
}
void AndGivenThatRequestIsValid() {
_validator = Substitute.For<IValidate>();
_validator.IsValid(_request).Returns(true);
}
void ButGivenTheLoginCredentialsDoNotExist() {
_authenticate = Substitute.For<IAuthenticate>();
_authenticate.IsAuthenticated(
_request.Email,
_request.Password
).Returns(false);
}
void WhenAttemptingLogin()
{
_response = new LoginController(_validator, _authenticate)
.Login(_request);
}
void ThenShouldRespondWithUnauthorized()
{
Assert.AreEqual(HttpStatusCode.Unauthorized, _response);
}
[Test]
public void Execute()
{
this.BDDfy();
}
}
但是看了下面的视频后Ian Cooper: TDD, where did it all go wrong并做了更多的阅读,我开始认为我的测试与代码的实现过于紧密相关。例如,我首先尝试测试的行为是,如果我们尝试使用无效请求登录,我们将使用错误请求的 http 状态代码进行响应。问题是我正在通过 stub IValidate
依赖项来对此进行测试。如果实现者决定 IValidate
抽象不再有用,并决定在 Login
方法中内联验证请求,那么系统的行为没有改变,但我的测试现在中断了。
但是,唯一的其他选择是集成测试,我在其中启动 Web 服务器并点击登录端点并对响应进行断言。问题是这既脆弱又复杂,因为我们最终需要在第三方凭证存储中拥有一个有效用户来测试用户登录成功的场景。
所以我的问题是,我的理解是否不正确,或者在针对实现的测试和全面的集成测试之间是否存在中间地带?
最佳答案
与我们贸易的大多数其他方面一样,也涉及权衡。
- 如果您在单元级别进行测试,某些测试可能会过于脆弱。
- 如果您在行为级别进行测试,则无法涵盖所有情况。
许多人已宣布单元测试和测试驱动开发 (TDD) 已死,并将行为驱动开发 (BDD) 视为新的 Elixir 。显然,它们都不是 Elixir 。
在您的问题中,您已经概述了单元测试的一种类型的问题,所以虽然我想回到那些问题,但让我们从研究 BDD 开始。
集成测试的问题
在他的开创性演讲中 Integration Tests Are a Scam , J.B. Rainsberger 解释了为什么集成测试(包括大多数 BDD 风格的测试)是有问题的。您确实应该看到录音,但它的本质是集成测试涉及测试用例的组合爆炸。
考虑一下您自己的简单示例。 LoginController
的 Login
方法的圈复杂度为 3,因为有 3 种方式通过它。如果您只想测试行为,则需要将其与其依赖项的适当实现集成。
只要查看方法签名,我们就可以看到,由于 _validator.IsValid
和 _authenticator.IsAuthenticated
都返回 bool
,因此必须至少通过它们中的每一种方式。
因此,使用这些乐观数字,整合这三个对象的排列数上限为 3 * 2 * 2 = 12。实际数字比那个少,因为你在某些分支中提前返回,但数量级是正确的。问题是,如果例如验证器具有更高的复杂度,特别是如果它有自己的依赖项,可能的组合数量会爆炸式增长,并迅速达到五位或六位数字。
您不可能编写所有这些测试用例。
单元测试的问题
编写单元测试时,可以减少组合的数量。不必相乘所有可能的代码路径组合,您可以相加它们,以便了解您必须编写的测试用例的数量。这使您能够减少测试数量,并且可以获得更好的覆盖率。事实上,您可以通过单元测试获得完美覆盖率。
那么问题就和你描述的一样。从某种意义上说,您测试的是实现的感觉。是的,但这只是实现的一部分,这就是重点。不过,这意味着当事情发生变化时,单元测试会受到影响,而集成测试的影响程度应该小得多。
采用 Append-Only strategy for tests有点帮助,但仍然感觉像是开销。
测试金字塔
所有这些都解释了为什么 Mike Cohn 推荐 Test Pyramid :
- 大量单元单元测试以确保您正确地构建东西。
- 集成测试以确保您构建正确的东西。
关于unit-testing - 不依赖实现细节的测试,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/25706203/