挣扎于如何通过几个步骤测试方法

Struggling with how test a method with several steps

我有一个带有用户注册功能的 MVC 网站,但我有一层无法解决如何测试的问题。基本上该方法是这样做的...

1) 检查数据库以查看用户是否已经注册

2) 将视图模型映射到 entity framework 模型

3) 将用户保存到数据库

4) 向用户发送一封确认邮件

5) 向第三方 post 执行 Web 服务 API

6) 使用来自第 3 方 return 的值更新用户(在步骤 #3 中创建)

我正在为如何或应该如何测试它而苦苦挣扎。我已将所有步骤抽象为单独的服务,并且我对这些进行了测试,因此对该方法的真正测试将是测试流程。有效吗?

在 TDD 世界中,我正试图以这种方式思考,我应该有这样的方法吗?还是存在我没有发现的设计问题?

我可以编写测试并且我了解如何模拟,但是当我为步骤 #6 编写测试时,我有模拟设置 return 步骤 #1、#2 和 #5 的数据以确保代码到此为止,并确保在步骤 #6 中保存的对象具有正确的状态。我的测试设置很快变长。

如果这是它应该的样子,那就太好了!但我觉得我错过了我的灯泡时刻。

我的灯泡时刻 我喜欢 Keith Payne 的回答,看着他的界面让我从一个新的角度看待事物。我还观看了 TDD Play by Play 课程 (http://www.pluralsight.com/courses/play-by-play-wilson-tdd),这确实帮助我理解了这个过程。我正在考虑从内到外的过程,而不是从外到内。

这绝对是一种新的软件开发思维方式。

在我看来,您的想法是正确的。尽管您将所有不同的任务封装在单独的模块中,但您仍需要一段代码来协调所有这些内容。

这些负责评估复杂流程的测试真的是一场噩梦,因为您最终会有一堆模拟和设置。我不认为你有很多逃脱的方法。

由于测试行为非常脆弱,如果它严重依赖于内部实现,我的建议是不要花太多时间为此方法编写测试。

当我遇到这种情况时,我会尝试为更相关的场景添加测试并省略明显的场景以降低测试套件的复杂性。避免为此进行 100 次测试,因为您可能需要在某个时候更改流程,这将导致更改 100 次复杂的测试。

这并不理想,但我认为这是一个权衡的决定。

In a TDD world, and I'm trying to think that way, should I have a method like this? Or is there a design issue I am not seeing?

你的方法很好,TDD没什么好说的。更多的是关于设计。在编写了 面向单一职责的 组件(正如您似乎已经完成的那样)之后,您必须同时使用它们来实现一个用例。这样做你通常会得到 facade 类 就像你的一样(但他们应该是少数)。

至于测试,没有简单的方法(如果有的话)。您的设置 可能 比平时长。它通常有助于区分哪些依赖项将作为 stubs(为测试方法提供数据 - 设置),哪些作为 mocks(您将断言反对)。如您所见,第 1、2、5 步将仅用于设置。

为了使您的工作更轻松并使测试更具可读性,请考虑将某些设置配置包装在方法中:

[Test] public void UserIsSavedToDatabase()
{
    UserIsNotRegistered();
    ViewModelIsMappedToEntity();

    ...
}

困难的测试设置是代码的味道,我想你已经看到了这一点。答案是更多的 cowbell(抽象)。

这是控制器方法中的一个常见错误,它既充当 UI 的控制器又协调业务流程。步骤 5 和 6 可能属于一起,步骤 1、3 和 4 同样应该被抽象为另一种方法。实际上,控制器方法唯一应该做的就是从视图接收数据,将其传递给应用程序层或业务层服务,并将结果编译到新视图中以显示回用户(映射)。

编辑:

您在评论中提到的 AccountManager class 是迈向良好抽象的良好一步。它与 MVC 代码的其余部分位于相同的命名空间中,不幸的是,这使得交叉依赖关系变得更加容易。例如,将视图模型传递给 AccountManager 是 "wrong" 方向上的依赖项。

想象一下 Web 应用程序的这种理想架构:

Application Layer

  1. UI (JavaScript/HTML/CSS)
  2. Model-View-Controller (Razor/ViewModel/Navigation)
  3. Application Services (Orchestration/Application Logic)

Business Layer

  1. Domain Services (Domain [EF] Models/Unit Of Work/Transactions)
  2. WCF/Third Party API's (Adapters/Client Proxies/Messages)

Data Layer

  1. Database

In this architecture, each item references the item below it.

从您的代码中推断出一些事情,AccountManager 最多是一个应用程序服务(在引用层次结构中)。我不认为它在逻辑上是 MVC 或 UI 组件的一部分。现在,如果这些架构项位于不同的 dll 中,IDE 将不允许您将视图模型传递到 AccountManager 的方法中。它会导致循环依赖。

除了架构问题之外,视图模型显然也不适合传递,因为它总是包含支持对 AccountManager 无用的视图渲染的数据。这也意味着 AccountManager 必须了解视图模型中属性的含义。视图模型 class 和 AccountManager 现在相互依赖。这给代码带​​来了不必要的脆弱性和脆弱性。

更好的选择是传递简单的参数,或者如果您愿意,将它们打包到一个新的数据传输对象 (DTO) 中,该对象将由合同定义在与 AccountManager.

一些示例接口:

namespace MyApp.Application.Services
{
    // This component lives in the Application Service layer and is responsible for orchestrating calls into the
    // business layer services and anything else that is specific to the application but not the overall business domain.

    // For instance, sending of a confirmation email is probably a requirement in some application process flows, but not
    // necessarily applicable to every instance of adding a user to the system from every source. Perhaps there is an admin back-end
    // application which may or may not send the email when an administrator registers a new user. So that back-end 
    // application would have a different orchestration component that included a parameter to indicate whether to 
    // send the email, or to send it to more than one recipient, etc.

    interface IAccountManager
    {
        bool RegisterNewUser(string username, string password, string confirmationEmailAddress, ...);
    }
}

namespace MyApp.Domain.Services
{
    // This is the business-layer component for registering a new user. It will orchestrate the
    // mapping to EF models, calling into the database, and calls out to the third-party API.

    // This is the public-facing interface. Implementation of this interface will make calls
    // to a INewUserRegistrator and IExternalNewUserRegistrator components.

    public interface IUserRegistrationService
    {
        NewUserRegistrationResult RegisterNewUser(string username, string password, ...);
    }

    public class NewUserRegistrationResult
    {
        public bool IsUserRegistered { get; set; }
        public int? NewUserId { get; set; }

        // Add additional properties for data that is available after
        // the user is registered. This includes all available relevant information
        // which serves a distinctly different purpose than that of the data returned
        // from the adapter (see below).
    }

    internal interface INewUserRegistrator
    {
        // The implementation of this interface will add the user to the database (or DbContext)
        // Alternatively, this could be a repository 
        User RegisterNewUser(User newUser) ;
    }

    internal interface IExternalNewUserRegistrator
    {
        // Call the adapter for the API and update the user registration (steps 5 & 6)
        // Replace the return type with a class if more detailed information is required

        bool UpdateUserRegistrationFromExternalSystem(User newUser);
    }

    // Note: This is an adapter, the purpose of which is to isolate details of the third-party API
    // from yor application. This means that what comes out from the adapter is determined not by what
    // is provided by the third party API but rather what is needed by the consumer. Oftentimes these
    // are similar.

    // An example of a difference can be some mundance detail. For instance, say that the API
    // returns -1 for some non-nullable int value when the intent is to indicate lack of a match.
    // The adapter would protect the application from that detail by using some logic to interpret
    // the -1 value and set a bool to indicate that no match was found, and to use int?
    // with a null value instead of propagating the magic number (-1) throughout your application.

    internal interface IThirdPartyUserRegistrationAdapter
    {
        // Call the API and interpret the response from the API.
        // Also perform any logging, exception handling, etc.
        AdapterResult RegisterUser(...);
    }

    internal class AdapterResult
    {
        public bool IsSuccessful { get; set; }

        // Additional properties for the response data that is needed by your application only.
        // Do not include data provided by the API response that is not used.
    }
}

需要记住的一点是,这种一次性设计与 TDD 正好相反。在 TDD 中,当您从外向内测试和编写代码时,对这些抽象的需求变得很明显。我在这里所做的是跳过所有这些,直接跳到根据我脑海中的画面设计内部结构。在几乎所有情况下,这都会导致过度设计和过度抽象,而 TDD 自然会避免这种情况。