【问题标题】:Need help identifying what unit tests to write需要帮助确定要编写哪些单元测试
【发布时间】:2011-03-31 19:07:04
【问题描述】:

我有以下方法,并希望编写有效的单元测试,同时也能很好地覆盖代码路径:

public TheResponse DoSomething(TheRequest request)
{
    if (request == null)
        throw new ArgumentNullException("request");

    BeginRequest(request);

    try
    {
        var result = Service.DoTheWork(request.Data);

        var response = Mapper.Map<TheResult, TheResponse>(result);

        return response;
    }
    catch (Exception ex)
    {
        Logger.LogError("This method failed.", ex);

        throw;
    }
    finally
    {
        EndRequest();
    }
}

该方法使用的 Service 和 Logger 对象被注入到类构造函数中(未显示)。 BeginRequest 和 EndRequest 在基类(未显示)中实现。而 Mapper 是用于对象到对象映射的 AutoMapper 类。

我的问题是,有哪些好的、有效的方法可以为这样的方法编写单元测试,同时提供完整的(或有意义的)代码覆盖?

我是 one-test-one-assertion 原则的信徒,并在 VS-Test 中使用 Moq 作为一个模拟框架(尽管我并没有因为这个讨论而挂断那部分)。虽然一些测试(比如确保在异常中传递 null 结果)是显而易见的,但我发现自己想知道其他想到的是否有意义;尤其是当他们以不同的方式执行相同的代码时。

【问题讨论】:

    标签: c# unit-testing testing tdd


    【解决方案1】:

    从您的帖子/cmets 来看,您似乎已经知道应该编写哪些测试,并且它与我在第一眼看到您的代码后要测试的内容非常匹配。几件显而易见的事情:

    • 空参数异常检查
    • 模拟服务和记录器以检查是否使用正确的数据调用它们
    • 存根映射器(可能还有服务)检查是否实际返回了正确的结果

    现在,困难的部分。根据您的基类是否是您可以访问的东西(例如,可以轻松更改它),您可以尝试称为 Extract & Override 的方法:

    1. 在基类中将 BeginRequest/EndRequest 标记为虚拟
    2. 在派生类中什么都不做
    3. 引入新的、可测试的类,该类派生自您要测试的类;覆盖基类(BeginRequest/EndRequest)中的方法,使它们例如。更改一些内部值,您以后可以轻松验证这些值

    代码看起来或多或少是这样的:

    Base 
    {
        protected virtual void BeginRequest(TheRequest request) { ... }
        protected virtual void EndRequest() { ... }
    }
    
    Derived : Base // class you want to test
    {
        // your regular implementation goes here
        // virtual methods remain the same
    }
    
    TestableDerived : Derived // class you'll actually test
    {
        // here you could for example expose some properties 
        // determining whether Begin/EndRequest were actually called,
        // calls were made in correct order and so on - whatever makes
        // it easier to verify later
    
        protected override void BeginRequest(TheRequest request) { ... }
        protected override void EndRequest() { ... }  
    }
    

    您可以在Art of Unit Testing 书籍以及Working Effectively with Legacy Code 中找到有关此技术的更多信息。即使我相信可能有更优雅的解决方案,这个解决方案应该能让您测试流程并验证 DoSomething 方法中交互的一般正确性。

    当您无权访问基类并且无法修改其代码时,问题当然会出现。不幸的是,我没有针对这种情况的“书外”解决方案,但也许您可以在 Derived 中围绕 Begin/EndRequest 创建虚拟包装器,并仍然使用提取和覆盖 TestableDerived。

    【讨论】:

    • 是的,我似乎知道,但实际上每条评论都是在寻求验证或批评。正如我在另一条评论中提到的,我没想到 BeginRequest/EndRequest 方法会引起如此轰动,但我会根据上述建议研究另一种实现该行为的方法。如果可以的话,我宁愿远离创造假物体。我曾经遵循 Extract & Override 方法(我也喜欢单元测试艺术这本书),但如果可能的话,我更喜欢使用使用 Moq 等模拟框架生成的模拟和存根。听起来更好的设计可能会否定这个问题。
    • 为了你的原点,为了说明我的困惑,我应该如何测试 Logger 是否被正确调用?为了执行 Logger.LogError 语句,我必须设置我的模拟服务以引发异常。这样做实际上间接验证了服务是否被正确调用,所以在这方面它不是一个多余的测试吗?
    • 我仍然会模拟 Logger 并检查它是否真的被调用了。就目前而言,模拟服务和验证它可能看起来就足够了,你永远不知道将来是否有人会说“我们为什么需要在这里登录?”并删除该 1 行。您的测试仍然静默通过,但您的代码不再按照预期进行。有明确的测试验证日志确实发生了可以节省你未来的麻烦;测试会爆炸,一切都很清楚。我认为它可以缩小到现在对你/我/其他人来说很明显的范围,以后可能对其他人来说并不明显。
    • 让我以另一种方式提出我的问题,因为我仍然不确定这是否是好的做法。我创建了一个模拟 Service 并验证 DoTheWork 是否被正确调用的测试。我有另一个模拟 Service 并设置模拟以在调用 DoTheWork 时引发异常的测试。我还模拟 Logger 并验证 LogError 是否按预期调用。但是,第二个测试依赖于按预期调用 Service.DoTheWork 的方法。如果这不正确,那么即使第二个测试的目的是验证是否记录了异常,两个测试都会失败。我的犹豫有意义吗?
    • 为什么从 .DoTheWork 抛出第二次测试会失败?您假设它在第二次测试中是正确的(因此将其存根),并且您唯一测试的是是否调用了 .LogError 。您想一次测试一件事,假设此时其他事情只是完成它们的工作-您在其他地方为它们进行了测试。按照 AoOUT 方法,您的场景可能是“ServiceThrows”,预期结果为“ErrorIsLogged”。仅此而已 - 您假设服务确实抛出,并测试您的代码是否正确响应它(记录错误)。我的解释有意义吗? :p
    【解决方案2】:

    该问题被标记为 TDD,但该问题本身非常经过测试,这是您难以协调两者的问题的一部分。如果您进行了 TDD,可能会出现一个不同的、更可测试的设计。为什么 BeginRequest 和 EndRequest 必须是继承的一部分。它们可以成为被注入的 TransactionManager 的一部分吗?

    无论如何,如果 BeginRequest 和 EndRequest 严格需要成为该类的一部分,您也许可以将它们设为虚拟并使用捕获这些方法调用的子类进行测试。

    您还可以注入某种替代的 TransactionManager(假设这是有意义的),仅在测试中进行委托,并且在实际代码中只是调用自身。

    但是当我需要这些东西时,我想知道测试是否真正告诉我这些方法需要与这段代码解耦。

    【讨论】:

    • 它被标记为 TDD 因为我想知道该方法应该/可以如何在真正的 TDD 之后实现。不幸的是,这是遗留代码,我不是遵循 TDD 的原始开发的一部分,但我想立即进行测试并进行所需的任何重构,以便代码可以作为未来开发的模型(好像它是使用 TDD 创建的)。
    • 我喜欢 TransactionManager 的想法,并将研究是否有某种方法可以将 BeginRequest/EndRequest 方法提供的行为外部化。
    • @SonOfPirate,将测试引入遗留代码本身就是一种技术。我推荐 Michael Feather 的书《有效地处理遗留代码》。它概述了以增量、测试驱动的方式从这里到那里的技术。
    【解决方案3】:

    我会添加调用 BeginRequest 和 EndRequest 的断言,因为它们可能非常重要。除此之外,这里可能没有其他什么太重要了。你不想测试那些不那么重要的东西,比如记录错误。您的大多数测试可能会关注服务正在做什么。

    【讨论】:

    • 不确定如何测试,因为它们是在基类中实现的,而不是我可以模拟的外部依赖项。有什么建议吗?
    • 另外,考虑到当其他开发人员稍后进行代码更改时,单元测试也用作回归测试,如果这是应用程序的业务需求,我不想确保记录异常吗?此外,如果策略是向 UI 冒泡异常以进行处理,我是否不想通过测试该方法未抑制异常来验证该方法是否符合要求?
    • 如果您有像您提到的那样的特定需求,您可以通过查找具有反射的实现并调用方法来断言正确的活动来自动化这些需求。至于您的第一条评论,您绝对可以使用 Rhino 或其他一些模拟框架在基类上做出这些断言。
    【解决方案4】:

    这只是我在一两年前写的一篇较大论文的一小部分。我的公司部分地将其用作单元测试的标准。希望对您有所帮助。

    单元测试黄金法则

    “对于 应用程序的业务层在那里 应该至少有 1 个单元测试。为了 应用程序中的每个类 应该至少有 1 个测试类。”

    要进行单元测试的项目

    一般来说,与类相关的方法有四种;

    • 修饰符:更改一个或多个

      相关的值(或对象) 对象的属性
    • 访问器: 返回一个值(或 对象)取决于状态 对象的
    • 构造函数:当 对象已创建
    • 析构函数:在 对象被销毁。

    在编写单元测试时,开发人员的主要关注点应该是在业务层本身的类中使用的所有公共方法(您将在 Web 层中使用的方法)。通过这样做,开发人员还将测试任何私有方法,以及 DAL 中被单元测试的类使用的类的任何公共方法。

    由于类的固有性质和单元测试的自然过程,构造函数和析构函数固有地内置于每个单元测试中。但是在某些情况下,根据架构和开发人员的偏好,这两个类可以包含自定义代码,并且可以重载,在这些情况下,所有可能的重载场景也需要进行单元测试。在类的构造函数存在多个重载的情况下,每个公共方法都需要使用每个重载的构造函数测试一次。

    如何配置/使用单元测试项目

    在每个解决方案中都应该有一个单元测试项目,可以在用于共享代码的任何架构中保存和共享。通过这样做,并让每个开发特定项目的开发人员更新并保存他们的单元测试,它将允许其他开发人员(当前和未来)使用相同的单元测试。 通过彻底记录每个单元测试应该如何运行以及在什么情况下运行,它创建了一个文档,使不熟悉应用程序的开发人员能够熟悉每个对象的结构和预期用途。 创建单元测试时(Visual Studio)会自动在测试项目中为用户选择的每个方法创建一个测试类。这使单元测试有条理,并与其他类的其他单元测试分开。

    如何成功进行单元测试

    不包括类的构造函数和析构函数,另外两种方法类型Modifiers和Accessors可以进一步拆分为七个子类型。

    • 修饰符

    o 插入

    o 更新

    o 删除

    o 多态

    • 访问器

    o 奇异检索

    o 组检索

    o 批量检索

    插入、更新、删除和变形

    插入 插入包含添加数据的任何情况。这不仅限于 SQL 数据库,还可以应用于其他数据源,包括 XML、Arrays 和 Arraylists、内存中的关系数据源,甚至是外部文本文档。

    更新 更新与插入类似,只是它们不是添加新数据,而是修改现有数据。与插入一样,它们不限于 SQL。

    删除 删除是删除或禁用数据。它们还可以针对与插入和更新相同的数据类型。

    多态 简单地说,多态性是能够在不同的上下文中为某物赋予不同的含义或用法的特性——具体来说,就是允许变量、函数或对象等实体具有多种形式。

    单数、群和批量检索

    奇异检索 单一检索涉及单个值或记录的返回。这可以像一个根据用户 ID 的输入返回用户名的方法一样简单。

    组检索 当需要更大组的子集时,使用组检索。组检索用于过滤之类的事情。

    大规模检索 大规模检索涉及返回整组数据。这可能是数据库中的整个表、整个 XML 文档或值数组。

    方法子类型和单元测试

    使用修饰符方法时,应遵循以下五个规则。请记住,每个公共方法都应该进行测试,但这并不意味着每个公共方法都需要隔离到自己的测试方法中。

    1) 插入、更新或删除的每个实例之前和之后都应进行检索,所需的检索类型取决于正在修改的数据的类型/格式。

    2) 每个插入实例都应该跟一个删除。这样可以确保删除任何插入的数据。

    3) 更新之前和之后都应该进行检索,并且还应该配对以确保所做的任何数据更改都被撤消。

    4) 虽然检索可以在修饰符测试期间进行测试,但它们也应该独立测试。

    5) 测试检索时,无需验证实际数据,只需返回该数据即可。

    【讨论】:

    • 哇,这方面做了一些工作,嗯?唯一缺少的是使用依赖注入和模拟来支持真正的单元测试。您描述的大部分内容听起来更像是功能、系统和/或集成测试。例如,任何命中 db 的东西都不是真正的单元测试,因为它依赖于数据访问技术和 db 才能通过。使用模拟数据访问层允许您隔离被测“单元”中的代码 - 加上它消除了在测试插入等之后删除的需要。只需验证插入命令(或其他)是否按预期调用。跨度>
    • 使用上面的代码作为示例的部分原因是我对测试服务、记录器或映射器代码不感兴趣,因为被测试的“单元”是显示的方法,我只是有兴趣验证该方法的逻辑/规则/流程。所以我可以模拟这些属性引用的接口的实现,并隔离我的代码以确保它按预期工作。
    【解决方案5】:

    在我看来,测试

    var result = Service.DoTheWork(request.Data);
    

    对于请求的各种咒语。数据是高价值的部分。剩下的就是基础设施,我会考虑将其抽象为更清洁的(见下文非常粗略和现成的方法),更可测试的风格来处理 Begin + End Request 的表观价值,这会单独测试。

    正如你所说,没有必要测试 Mapper 和 logger 做他们的事情。

    您最不想要的是大量脆弱的测试,当您决定重新设计对象的内部结构时会让您感到痛苦

    public TheResponse DoSomething(TheRequest request)
    {
        Guard.NotNull( () => request );
    
        BeginRequest( (request) =>
        {
            var result = Service.DoTheWork(request.Data);
    
            var response = Mapper.Map<TheResult, TheResponse>(result);
    
           return response;
        });
    }
    // This is a base class method and can be tested elsewhere
    void base::BeginRequest(Action<Request> execute)
    {
        BeginRequest();
        try                 { execute(request);
                              return Mapper.Map<TheResult, TheResponse>(result); }
        catch(Exception ex) { Logger.Log(ex);
                              throw; }
        finally             { EndRequest(); }
    }
    

    【讨论】:

    • 我没有意识到 BeginRequest/EndRequest 方法会如此受关注,但可以说虽然很聪明,但您的建议假设很多。首先,并不是每个方法都会像执行、映射、返回一样简单。其次,这些方法的目的是执行前后操作步骤。一个这样的步骤是从 TheRequest 中提取本地化信息并设置当前线程。所以这样的概括对我不起作用,但它仍然是一个有用的建议。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2010-12-05
    • 1970-01-01
    • 2011-08-12
    • 2023-03-15
    • 2012-08-25
    • 1970-01-01
    • 2011-12-19
    相关资源
    最近更新 更多