测试时不依赖于实现细节

时间:2014-09-07 01:09:10

标签: unit-testing testing tdd bdd acceptance-testing

想象一下以下人为的例子:

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状态代码进行响应。问题是我通过存根IValidate依赖来测试这个。如果实施者决定IValidate抽象不再有用并决定在Login方法中内联验证请求,那么系统的行为没有改变,但是我的测试现在休息。

但是,唯一的另一个替代方案是集成测试,我在其中启动Web服务器并点击登录端点并在响应上断言。问题是这很脆弱,因为我们最终需要在第三方凭证存储中拥有一个有效用户来测试用户登录成功方案。

所以我的问题是,我的理解是不正确的,还是在测试与实施和全面集成测试之间存在中间立场?

3 个答案:

答案 0 :(得分:13)

与我们交易的大多数其他方面一样,涉及权衡

  • 如果您在单位级别进行测试,某些测试可能会太脆弱。
  • 如果您在行为级别进行测试,则无法涵盖所有​​情况。

很多人宣布单元测试和测试驱动开发(TDD)已经死亡,并将行为驱动开发(BDD)视为新的银弹。显然,它们都不是银子弹。

在你的问题中,你已经列出了单元测试的一种问题,所以尽管我想回到那些问题,但我们先来看看BDD。

集成测试的问题

在他的开创性演讲Integration Tests Are a Scam中,J.B。Rainsberger解释了为什么集成测试(包括大多数BDD式测试)存在问题。你真的应该看到录音,但它的本质是整合测试涉及测试用例的组合爆炸。

考虑你自己的琐碎例子。 Login的{​​{1}}方法的循环复杂度为3,因为它有3种方法。如果您只想测试行为,则需要将其与其依赖项的相应实现集成。

通过查看方法签名,我们可以看到,由于LoginController_validator.IsValid都返回_authenticator.IsAuthenticated,因此必须至少通过 2种方法他们每个人。

因此,利用这些乐观数字,积分这三个对象的排列数的上限是 3 * 2 * 2 = 12 。实际数字小于那个,因为你在某些分支机构中提前返回,但数量级是正确的。问题是如果是验证器具有更高的复杂程度,特别是如果它具有自己的依赖关系,则可能的组合数量会爆炸,并迅速达到五位或六位数字。

你无法编写所有这些测试用例。

单元测试的问题

编写单元测试时,可以减少组合数量。您不必乘以所有可能的代码路径组合,而是可以将它们一起添加,以便了解您必须编写的测试用例的数量。这使您可以减少测试次数,并且可以获得更好的覆盖率。实际上,您可以通过单元测试获得完美的覆盖率。

然后,问题正如您所描述的那样。从某种意义上说,您可以测试实现的感觉。它是,但它只是实施的一部分,这就是重点。尽管如此,这意味着当事情发生变化时,单元测试会受到影响,集成测试应该在很小程度上受到影响。

稍微采用Append-Only strategy for tests帮助,但它仍然可能感觉像开销一样。

测试金字塔

所有这些都解释了为什么Mike Cohn推荐Test Pyramid

  • 大量的单位单元测试,以确保您正确构建该东西
  • 集成测试以确保您构建正确的内容

答案 1 :(得分:1)

我遵循BDD方法,首先通过验收测试(即集成测试)测试驱动系统,并在必要时进行单元测试以驱动细节。验收测试与实现无关,因为它们仅通过用户界面与系统交互。单元测试必然依赖于实现,因为每个测试单个类(您的示例实际上是控制器的单元测试),但是当您的验收测试不能涵盖所有行为时,您只需编写它们,所以至少在某些时候你会避免与实现紧密结合的测试。

我特别发现,在经过充分考虑的Web应用程序中,验收测试通常几乎完全覆盖控制器,并且几乎不需要单元测试控制器。控制器委托的模型和其他类需要大量的单元测试,但这些类往往具有更有意义的行为,单元测试更有效率。

这就是您要对外部凭据存储区做些什么。如果没有办法针对真实商店编写验收测试(您在生产实例中没有商店的测试实例或测试帐户),那就是实用且存根。通过将实际联系商店的代码放在自己的类中,没有业务逻辑,并且只对该类进行存根,确保您尽可能多地集成测试代码。您可以为商店适配器类编写一个或两个单元测试,以测试与商店的连接是否正常工作。

答案 2 :(得分:1)

针对实施进行测试并不是一个好主意。使用您的实现建议您进行良好的测试,这可能会揭示错误。在适当的TDD中,您从一个失败的测试用例开始,因此您知道您有一个测试用例,至少有一个错误(不完整)实现可能会失败。

事实上,单元测试和集成测试之间没有清晰的分离。几乎所有有用的类都使用其他类,即使它是语言库提供的基本类(如字符串)。在完美的单元测试和完美的集成测试之间,最好将测试视为一个连续体。您应该努力使代码在完美单元测试结束时进行一些测试,但如果它们不完美则不要被攻击。

如果你编写了一个与其他类 B 合作的 A 类,那么使用a A 进行测试并不总是错误的类 B 的真实对象,而不是模拟对象。如果您确实使用了模拟对象,我建议使用模拟来重现模拟类的相关行为的所有,并强加实际类的所有约束(前置条件检查)。仅仅验证使用特定参数调用特定方法的模拟通常不常用。使用模拟对象的公共接口来验证其 final 状态是否符合预期的模拟测试更好;然后,测试将不依赖于被测系统执行操作的顺序,或者它执行的操作。这些通常是实施细节。

例如,在测试我当前具有3层架构的Web应用程序时,我从不模拟服务层。这是因为表示层使用服务层进行持久更改的如何在每种情况下都是实现细节。但那又怎么样?想象一下,我没有使用过3层架构,但已将服务和表示层的代码组合成一层(不推荐)。然后神奇地我会做纯单元测试,但我的代码实际上会更好地测试和更好地编写吗? 。可以说表示层的测试是表示和服务层的集成测试。我总是使用模拟实现来模拟持久层,该实现将对象存储在地图和列表中而不是数据库中。