【问题标题】:Testing without relying on implementation details不依赖实现细节的测试
【发布时间】:2014-09-07 01:09:10
【问题描述】:

想象下面这个人为的例子:

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 服务器并点击登录端点并断言响应。问题是这既脆弱又复杂,因为我们最终需要在第三方凭证存储中拥有一个有效用户来测试用户登录成功的场景。

所以我的问题是,我的理解是否不正确,或者在针对实现的测试和全面的集成测试之间是否存在中间立场?

【问题讨论】:

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


    【解决方案1】:

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

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

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

    在您的问题中,您已经概述了单元测试的一类问题,所以尽管我想回到这些问题,让我们从 BDD 开始。

    集成测试的问题

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

    考虑你自己的小例子。 LoginControllerLogin 方法的循环复杂度为 3,因为有 3 种方法通过它。如果您只想测试行为,则需要将其与其依赖项的适当实现集成。

    仅通过查看方法签名,我们可以看到,由于 _validator.IsValid_authenticator.IsAuthenticated 都返回 bool,因此必须有至少两种方式通过它们。 p>

    因此,有了这些乐观的数字,整合这三个对象的排列数的上限是3 * 2 * 2 = 12。实际数量比这要少,因为您在某些分支中提前返回,但数量级大约是正确的。问题是,如果例如验证器具有更高的复杂性,特别是如果它有自己的依赖项,可能的组合数量会激增,并迅速达到五位数或六位数。

    你不可能编写所有这些测试用例。

    单元测试的问题

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

    那么,问题正是您所描述的。从某种意义上说,您测试了实现的感觉。确实如此,但它只是实现的一部分,这就是重点。尽管如此,这意味着当事情发生变化时,单元测试会受到影响,而集成测试应该受到的影响要小得多。

    采用Append-Only strategy for tests 有点帮助,但仍然感觉像是开销。

    测试金字塔

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

    • 大量单元单元测试可确保您正确构建事物
    • 集成测试可确保您构建正确的东西

    【讨论】:

      【解决方案2】:

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

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

      剩下的就是如何处理您的外部凭证存储。如果无法针对真实商店编写验收测试(您没有商店的测试实例或生产实例中的测试帐户),请切实可行并存根。通过将实际与商店联系的代码放在它自己的类中,没有业务逻辑,并且只对那个类进行存根,确保您对尽可能多的代码进行集成测试。您可以为商店适配器类编写一两个单元测试,以测试与商店的连接是否有效。

      【讨论】:

        【解决方案3】:

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

        实际上,单元测试和集成测试之间没有明确的分离。几乎所有有用的类都使用了其他类,即使是语言库提供的基本类(如字符串)。最好将测试视为一个连续体,介于完美的单元测试和完美的集成测试之间。你应该努力让代码的一些测试接近完美的单元测试结束,但如果它们不完美,不要气馁。

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

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

        【讨论】:

        • 你可以在单元测试中使用像字符串这样的库类,因为无论如何你都必须信任它们。
        猜你喜欢
        • 2011-02-17
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2014-11-13
        • 1970-01-01
        • 2010-12-15
        相关资源
        最近更新 更多