65.9K
CodeProject 正在变化。 阅读更多。
Home

Fluent Web API 集成测试

starIconstarIconstarIconstarIconstarIcon

5.00/5 (13投票s)

2021 年 5 月 22 日

CPOL

16分钟阅读

viewsIcon

20035

downloadIcon

151

编写可读的、仅调用 Web API 的集成测试

目录

引言

我正在阅读 Pete O'Hanlon 的文章 《Excelsior! Building Applications Without a Safety Net - Part 1》(现在他有更多部分了,因为我的文章写了很久),并受到启发,终于坐下来写一篇关于 Fluent Web API 集成测试的文章,这是我一直想做的事情!

对我来说,Web API 集成测试不仅仅是调用 API 并验证我是否得到预期结果那么简单,它实际上是一个工作流。例如,不是为进行单个 API 测试而设置所有先决数据,而是利用 API 本身来帮助数据设置。此外,然后测试用户的工作流,这需要连续调用多个终结点。好处是:

  1. 它简化了测试设置过程。
  2. 它更密切地模拟了用户可能的操作或前端应用程序为用户所做的操作。
  3. 它验证了 API 是否真正支持所谓的原子行为,而不是例如一个控制器做很多不同的事情。
    1. 是的,您可能仍然有执行复杂操作的终结点,但关键在于,这些终结点应该基于更“原子”的方法,而这些方法可以被更简单的 API 终结点调用。
  4. 如果使用了模型,它倾向于强制采用一种架构,在这种架构中,模型在一个独立的程序集中维护,该程序集可以在服务实现和集成测试应用程序之间共享。
  5. 这个概念与 Fluent Assertions 整合得很好,我们将在本文中使用它。

概念

这个概念非常简单

  1. 我们有一个集成测试套件(实际上是使用单元测试框架实现的)...
  2. ...它调用我们“fluent”库中的方法...
  3. ...它调用所需的终结点...
  4. ...并且我们可以捕获结果。

最后一部分“我们可以捕获结果”是很有趣的,因为我们想要捕获

  1. 由此产生的 HTTP 状态码和文本。
  2. 返回的 JSON(是的,我假设一切都是 JSON)。
  3. 如果没有调用错误,则反序列化 JSON。
  4. 将反序列化的对象与一个标签关联起来,以便以后引用它。

这需要我们为测试工作流实现一个包装器类,该类管理上述信息。我一直没能给它起个好名字,所以我将直接称它为“工作流包”。

安装

Web API 服务

我们将创建一个新的 VS 2019 项目

并选择

我将把项目命名为“FluentWebApiIntegrationTestDemo”。

Visual Studio 2019 创建了 Web API 的基本模板,包括一个示例的天气预报控制器

我将重命名并清空它,使其看起来像这样

using System;

using Microsoft.AspNetCore.Mvc;

namespace FluentWebApiIntegrationTestDemo.Controllers
{
  [ApiController]
  [Route("[controller]")]
  public class DemoController : ControllerBase
  {
    [HttpGet]
    public object Get()
    {
      throw new NotImplementedException();
    }
  }
}

并删除 WeatherForecast.cs 文件。

为了在浏览器中进行测试,Debug 配置将在控制器名称上打开浏览器

并将启动 IIS,这样我就不必处理端口的麻烦了

然后我们可以运行项目(VS 会在第一次时配置 IIS,这很棒),然后我们看到

太棒了!

演示集成测试 DLL

接下来,我将添加一个 MSTest 测试项目 (.NET Core) - 是的,我正在使用单元测试框架来进行集成测试。

创建集成测试项目导致了一系列错误

我找到的唯一“解决方案”是将 Web API 项目和集成测试项目并排放置

无论 Visual Studio 在与 Web Core API 项目相同文件夹中创建的项目上做了什么……好吧,它做得太多了,因为在我看来,文件夹结构不应该影响 Web Core API 项目的构建方式。

我还升级了包

to

这样最终就可以构建了。

VS 创建的初始 UnitTest1.cs 文件,我已经将其重命名为“DemoTests”,这个存根看起来像这样

using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace IntegrationTests
{
  [TestClass]
  public class DemoTests
  {
    [TestMethod]
    public void GetTest()
    {
    }
  }
}

当然,测试什么也没做,所以我们有一个成功的测试!抱歉 Pete。

Clifton.IntegrationTestWorkflowEngine DLL

由于这些集成测试实际上可以作为“实时”工作流使用,因此我将创建一个单独的 .NET Core 类库项目来管理 Fluent 集成工作流包

目前它只包含一个存根文件

namespace Clifton.IntegrationTestWorkflowEngine
{
  public class WorkflowPacket
  {
  }
}

创建一个只有一个类的 DLL 似乎有点大材小用,但以后我们可能想添加更多功能。这里的重点是,我们想要一个可重用的类,它不允许我们添加“特定于域”的实现,因此我们创建一个单独的 DLL 来防止这种情况。

WorkflowTestMethods DLL

如果上面的内容还不够,是的,我们将创建另一个 .NET Core 类库 DLL 来实际包含工作流方法。这将允许我们在 API 中直接使用工作流方法,如果选择向用户公开工作流的话。最好提前计划而不是以后重构。这个 DLL 是特定于域的 - 它将包含调用我们演示 API 服务中终结点的方法。

它还有一个存根类

namespace WorkflowTestMethods
{
  public class ApiMethods
  {
  }
}

我们的第一个 Fluent 集成测试

设置几个项目引用后,我们就可以编写第一个 Fluent 集成测试了

string baseUrl = "<a href="https:///FluentWebApiIntegrationtestDemo">https:///FluentWebApiIntegrationtestDemo</a>";

new WorkflowPacket(baseUrl)
  .Home("Demo")
  .IShouldSeeOKResponse();

这不会编译,因为我们还没有实现接受基本 URL 的构造函数以及支持方法,但从语法上我们可以推断出

  1. Fluent 方法是对 WorkflowPacket 的扩展
  2. 每个 Fluent 方法都返回 WorkflowPacket 实例。

重构 WorkflowPacket 类

重构 WorkflowPacket

using System.Net;

namespace Clifton.IntegrationTestWorkflowEngine
{
  public class WorkflowPacket
  {
    public HttpStatusCode LastResponse { get; set; }
    public string BaseUrl { get; protected set; }

    public WorkflowPacket(string baseUrl)
    {
      this.BaseUrl = baseUrl;
    }
  }
}

重构 ApiMethods 类

重构 ApiMethods (我还添加了 FluentAssertions 包)类

using FluentAssertions;

using Clifton.IntegrationTestWorkflowEngine;
using System.Net;

namespace WorkflowTestMethods
{
  public static class ApiMethods
  {
    public static WorkflowPacket Home(this WorkflowPacket wp, string controller)
    {
      return wp; 
    }

    public static WorkflowPacket IShouldSeeOKResponse(this WorkflowPacket wp)
    {
      wp.LastResponse.Should().Be(HttpStatusCode.OK, $"Did not expected {wp.LastContent}");

      return wp;
    }

    public static WorkflowPacket IShouldSeeNoContentResponse(this WorkflowPacket wp)
    {
      wp.LastResponse.Should().Be
      (HttpStatusCode.NoContent, $"Did not expected {wp.LastContent}");

      return wp;
    }

    public static WorkflowPacket IShouldSeeBadRequestResponse(this WorkflowPacket wp)
    {
      wp.LastResponse.Should().Be
      (HttpStatusCode.BadRequest, $"Did not expected {wp.LastContent}");

      return wp;
    }
  }
}

现在,FluentAssertions 有点乏味。可以说“我们断言 x 应该等于 y,因为 [某个原因]”,但没有机制说“我们断言 x 应该等于 y,但它不是因为 [失败原因]”。所以“because”参数是“Did not expected...”。唉。

测试结果

我们看到集成测试失败了(显然,因为我们还没有调用终结点)

FluentAssertions 的惊人之处在于它能准确地告诉你问题所在

添加终结点调用

使用 RestSharp(又一个包),我将把它包装在一个 RestService 类中并放入 Clifton.IntegrationTestWorkflowEngine(哈哈!看,我告诉过你会添加更多到这个 DLL!),我们有一个简单的 GET API 调用方法

using System.Net;

using RestSharp;

namespace Clifton.IntegrationTestWorkflowEngine
{
  public static class RestService
  {
    public static (HttpStatusCode status, string content) Get(string url)
    {
      var response = Execute(url, Method.GET);

      return (response.StatusCode, response.Content);
    }

    private static IRestResponse Execute(string url, Method method)
    {
      var client = new RestClient(url);
      var request = new RestRequest(method);
      var response = client.Execute(request);

      return response;
    }
  }
}

然后我们重构 ApiMethods.Home 方法来执行调用

public static WorkflowPacket Home(this WorkflowPacket wp, string controller)
{
  var resp = RestService.Get($"{wp.BaseUrl}/{controller}");
  wp.LastResponse = resp.status;

  return wp; 
}

测试结果

测试仍然失败,但现在我们知道原因了

重构终结点

所以最后一步是重构终结点,使其不再抛出 NotImplementedException 异常,而是返回 OK

[HttpGet]
public object Get()
{
  return Ok();
}

测试结果

最后测试通过了!

评估

我们完成了什么?考虑到这个简单的例子

new WorkflowPacket(baseUrl)
  .Home("Demo")
  .IShouldSeeOKResponse();

我们为以下内容创建了基本框架

  1. 调用终结点
  2. 验证状态返回

现在让我们将其扩展到处理更多“真实”的 API 终结点。

真实的终结点 Fluent 集成测试

这里的目的是能够将一些数据传递给终结点(查询字符串或序列化)并获得结果(反序列化)并测试结果。所以,例如

[TestMethod]
public void FactorialTest()
{
  string baseUrl = "https:///FluentWebApiIntegrationtestDemo";

  new WorkflowPacket(baseUrl)
   .Factorial<FactorialResult>("factResult", 6)
   .IShouldSeeOKResponse()
   .ThenIShouldSee<FactorialResult>("factResult", r => r.Result.Should().Be(720));
}

[TestMethod]
public void BadFactorialTest()
{
  string baseUrl = "https:///FluentWebApiIntegrationtestDemo";

  new WorkflowPacket(baseUrl)
    .Factorial<FactorialResult>("factResult", -1)
    .IShouldSeeBadRequestResponse();
}

请注意,这暗示工作流包现在将结果存储在指定的“容器”中,在本例中是“factResult”。

另外请注意,在第二个测试中,我期望如果我尝试获取小于 1 的数字的阶乘,则会返回 BadRequest 的 HTTP 响应。

我将把 FactorialResult “模型”放入另一个 DLL 中,该 DLL 在集成测试和 API 服务之间共享

namespace FluentWebApiIntegrationTestDemoModels
{
  public class FactorialResult
  {
    public decimal Result { get; set; }
  }
}

因为这些是通用方法,所以我们 将此 DLL 添加到 WorkflowTestMethods 项目中。

添加通用的 Get REST API 调用

添加 Newtonsoft.Json 包,我们实现了

public static (T item, HttpStatusCode status, string content) Get<T>(string url) where T : new()
{
  var response = Execute(url, Method.GET);
  T ret = TryDeserialize<T>(response);

  return (ret, response.StatusCode, response.Content);
}

private static T TryDeserialize<T>(IRestResponse response) where T : new()
{
  T ret = new T();
  int code = (int)response.StatusCode;

  if (code >= 200 && code < 300)
  {
    ret = JsonConvert.DeserializeObject<T>(response.Content);
  }

  return ret;
}

添加 Workflow API 调用方法

在这里,Math 控制器中的 Factorial 方法的终结点是硬编码的,我认为这是完全可以的,因为 API 调用方法的描述应该是具体的,以便集成测试可以通过其方法名而不是其参数来读取。

public static WorkflowPacket Factorial<T>
(this WorkflowPacket wp, string containerName, int n) where T: new()
{
  var resp = RestService.Get<T>($"{wp.BaseUrl}/Math/Factorial?n={n}");
  wp.LastResponse = resp.status;
  wp.Container[containerName] = resp.item;

  return wp;
}

public static WorkflowPacket ThenIShouldSee<T>
(this WorkflowPacket wp, string containerName, Action<T> test) where T : class
{
  T obj = wp.GetObject<T>(containerName);
  test(obj);

  return wp;
}

请注意,我已经将容器的概念添加到了 WorkflowPacket 中,这样我就可以将对象添加到 container 并返回 container 对象,并将其强制转换为指定的类型。

public Dictionary<string, object> Container = new Dictionary<string, object>();
...
public T GetObject<T>(string containerName) where T: class
{
  Container.Should().ContainKey(containerName);
  T ret = Container[containerName] as T;

  return ret;
}

当然,测试失败了,因为我还没有实现那个带有 Factorial 方法的 Math 控制器,所以我们现在把它作为一个存根来实现

[ApiController]
[Route("[controller]")]
public class MathController : ControllerBase
{
  [HttpGet("Factorial")]
  public object Factorial([FromQuery, BindRequired] int n)
  {
    return Ok(new FactorialResult());
  }
}

再次,测试失败了,但我们看到

所以让我们实际实现阶乘计算

[ApiController]
[Route("[controller]")]
public class MathController : ControllerBase
{
  [HttpGet("Factorial")]
  public object Factorial([FromQuery, BindRequired] int n)
  {
    object ret;

    if (n <= 0)
    {
      ret = BadRequest("Value must be >= 1");
    }
    else
    {
      decimal factorial = 1;
      n.ForEach(i => factorial = factorial * i, 1);

      ret = Ok(new FactorialResult() { Result = factorial });
    }

    return ret;
  }
}

(是的,我喜欢我的扩展方法。)注意我如何专门编写了一个测试来确保 n 大于 0

我们看到

评估

给定这个集成测试

new WorkflowPacket(baseUrl)
   .Factorial<FactorialResult>("factResult", 6)
   .IShouldSeeOKResponse()
   .ThenIShouldSee<FactorialResult>("factResult", r => r.Result.Should().Be(720));

并且撇开我们不幸地必须过度指定泛型的事实,我们看到我们可以

  1. 通过查询参数进行 API 终结点调用。
  2. 反序列化结果。
  3. 验证结果。

失败测试

我们还实现了一个简单的集成测试,该测试通过一个简单的流程验证了 API 是否能优雅地处理错误输入

 new WorkflowPacket(baseUrl)
  .Factorial<FactorialResult>("factResult", -1)
  .IShouldSeeBadRequestResponse();

使用 Dynamic 减少通用参数指定

如果我们想使用 C# 的 dynamic 功能(尽管我们会失去 Intellisense),我们可以通过一个稍微不同的工作流方法来编写

.ThenIShouldSee("factResult", r => r.Result.Should().Be(720));

除了我们会从运行时绑定程序中得到一个异常

我们可以像这样实现一个动态的 ThenIShouldSee

public static WorkflowPacket ThenIShouldSee
(this WorkflowPacket wp, string containerName, Func<dynamic, bool> test)
{
  var obj = wp.GetObject(containerName);
  var b = test(obj);
  b.Should().BeTrue();

  return wp;
}

测试的写法如下

.ThenIShouldSee("factResult", r => r.Result == 720M);

但是看看会发生什么

什么?事实证明,var b,即使我们和编译器都知道 b 是 bool 类型,也与 FluentAssertions 不太兼容。我们实际上必须写 bool b 才能使 FluentAssertions 工作!

深入 - POST 和使用 JSON Body

在集成测试中,模拟用户可能执行的几项活动更为常见。在此示例中,我们将创建一些测试,如果存在 UI,将允许用户为每个州输入州和郡,并按州查看郡。一套简单的终结点,我将直接在内存中实现它们——我甚至不会使用内存数据库!诚然,这是一个有些牵强的例子,但它说明了更有趣的集成测试。

一个简单的内存状态-郡模型

我将从测试驱动开发 (TDD) 转向更自然的写作方式,尤其是在编写相当简单的代码时——先编写实现,然后编写测试来验证实现。我称之为“后测编码”——TLC,哈哈哈。这是模型,请注意我如何在模型中编写特定的异常

namespace FluentWebApiIntegrationTestDemoModels
{
  public class StateModelException : Exception
  {
    public StateModelException() { } 
    public StateModelException(string msg) : base(msg) { }
  }

  public class County : List<string> { }

  public class StateModel
  {
    // Public for serialization
    public Dictionary<string, County> 
           StateCounties { get; set; } = new Dictionary<string, County>();

    public IEnumerable<string> GetStates()
    {
      var ret = StateCounties.Select(kvp => kvp.Key);

      return ret;
    }

    public IEnumerable<string> GetCounties(string stateName)
    {
      Assertion.That<StateModelException>
                (StateCounties.ContainsKey(stateName), "State does not exist.");

      return StateCounties[stateName];
    }

    public void AddState(string stateName)
    {
      Assertion.That<StateModelException>
                (!StateCounties.ContainsKey(stateName), "State already exists.");

      StateCounties[stateName] = new County();
    }
    
    public void AddCounty(string stateName, string countyName)
    {
      Assertion.That<StateModelException>
                (StateCounties.ContainsKey(stateName), "State does not exists.");
      Assertion.That<StateModelException>
      (!StateCounties[stateName].Contains(countyName), "County already exists.");

      StateCounties[stateName].Add(countyName);
    }
  }
}

我们希望断言模型的预期条件,而不是控制器的预期条件,以便模型可以重复使用其所有验证。

状态控制器

这是控制器

[ApiController]
[Route("[controller]")]
public class StateController : ControllerBase
{
  public static StateModel stateModel  = new StateModel();

  [HttpGet("")]
  public object GetStates()
  {
    var states = stateModel.GetStates();

    return Ok(states);
  }

  [HttpPost("")]
  public object AddState([FromBody] string stateName)
  {
    object ret = Try<StateModelException>(
      NoContent(), 
      () => stateModel.AddState(stateName));

    return ret;
  }

  [HttpPost("{stateName}/County")]
  public object AddCounty(
  [FromRoute, BindRequired] string stateName,
  [FromBody] string countyName)
  {
    object ret = Try<StateModelException>(
      NoContent(), 
      () => stateModel.AddCounty(stateName, countyName));

    return ret;
  }
}

因为我真的不喜欢重复自己,也不喜欢让我的代码充斥着 try-catch 块,而且如果可能的话,避免使用 if-else 语句,我创建了一个帮助函数,如果模型抛出预期的异常,就返回一个 bad request,否则抛出异常,让框架返回一个内部服务器错误。这段代码说明了大多数 API 方法实际上应该只做非常简单的事情,并且只有有限的异常。虽然更复杂的 API 终结点可能会抛出各种异常,但我在此提出这一点更多是作为谈话/讨论的要点,而不是作为指导。对我来说,在教授软件开发/架构时,关键是让人们思考他们应该问什么问题,而不是仅仅陷入机器人式的编码。

private object Try<T>(object defaultReturn, Action action)
{
  object ret = defaultReturn;

  try
  {
    action();
  }
  catch (Exception ex)
  {
    if (ex.GetType().Name == typeof(T).Name)
    {
      ret = BadRequest(ex.Message);
    }
    else
    {
      throw;
    }
  }

  return ret;
}

新的 Fluent API 终结点方法

我添加了三个更 Fluent 的方法。关于第一个,将保存结果的类定义与方法终结点调用解耦似乎有点荒谬。是的,有时候您只想反序列化返回数据中的特定键值,而有时候则像这样,直接让 Fluent 终结点方法“知道”数据要放入什么模型中。这里就是这种情况。

public static WorkflowPacket GetStatesAndCounties(this WorkflowPacket wp, string containerName)
{
  var resp = RestService.Get<StateModel>($"{wp.BaseUrl}/States");
  wp.LastResponse = resp.status;
  wp.Container[containerName] = resp.item;

  return wp;
}

public static WorkflowPacket AddState(this WorkflowPacket wp, string stateName)
{
  var resp = RestService.Post($"{wp.BaseUrl}/State", new { stateName });
  wp.LastResponse = resp.status;

  return wp;
}

public static WorkflowPacket AddCounty
(this WorkflowPacket wp, string stateName, string countyName)
{
   var resp = RestService.Post($"{wp.BaseUrl}/State/${stateName}/County", new { countyName });
  wp.LastResponse = resp.status;

  return wp;
}

现在,我们需要在我们的 RestService 中添加一个 Post 方法

public static (HttpStatusCode status, string content) Post(string url, object data = null)
{
  var response = Execute(Method.POST, url, data);

  return (response.StatusCode, response.Content);
}

当然,Execute 方法现在必须支持请求中的数据

private static IRestResponse Execute(Method method, string url, object data = null)
{
  var client = new RestClient(url);
  var request = new RestRequest(method);
  data.IfNotNull(() => request.AddJsonBody(data));
  var response = client.Execute(request);

  return response;
}

集成测试

现在我们可以编写正面和负面的集成测试了

[TestMethod]
public void AddStateTest()
{
  string baseUrl = "https:///FluentWebApiIntegrationtestDemo";

  new WorkflowPacket(baseUrl)
    .AddState("NY")
    .IShouldSeeNoContentResponse()
    .AddState("CT")
    .IShouldSeeNoContentResponse()
    .GetStatesAndCounties("myStates")
    .IShouldSeeOKResponse()
    .ThenIShouldSee<StateModel>("myStates", m => m.GetStates().Count().Should().Be(2));
}

[TestMethod]
public void AddDuplicateStateTest()
{
  string baseUrl = "<a href="https:///FluentWebApiIntegrationtestDemo">https:///FluentWebApiIntegrationtestDemo</a>";

  new WorkflowPacket(baseUrl)
    .AddState("NY")
    .IShouldSeeNoContentResponse()
    .AddState("NY")
    .IShouldSeeBadRequestResponse();
}

[TestMethod]
public void AddCountyTest()
{
  string baseUrl = "<a href="https:///FluentWebApiIntegrationtestDemo">https:///FluentWebApiIntegrationtestDemo</a>";

  new WorkflowPacket(baseUrl)
    .AddState("NY")
    .IShouldSeeNoContentResponse()
    .AddCounty("NY", "Columbia")
    .GetStatesAndCounties("myStates")
    .IShouldSeeOKResponse()
    .ThenIShouldSee<StateModel>("myStates", m => m.GetStates().Count().Should().Be(1))
    .ThenIShouldSee<StateModel>("myStates", m => m.GetStates().First().Should().Be("NY"))
    .ThenIShouldSee<StateModel>("myStates", m => m.GetCounties("NY").Count().Should().Be(1))
    .ThenIShouldSee<StateModel>
     ("myStates", m => m.GetCounties("NY").First().Should().Be("Columbia"));
}

[TestMethod]
public void AddCountyNoStateTest()
{
  string baseUrl = "https:///FluentWebApiIntegrationtestDemo";

  new WorkflowPacket(baseUrl)
    .AddCounty("NY", "Columbia")
    .IShouldSeeBadRequestResponse();
}

[TestMethod]
public void AddDuplicateCountyTest()
{
  string baseUrl = "https:///FluentWebApiIntegrationtestDemo";

  new WorkflowPacket(baseUrl)
    .AddState("NY")
    .IShouldSeeNoContentResponse()
    .AddCounty("NY", "Columbia")
    .IShouldSeeNoContentResponse()
    .AddCounty("NY", "Columbia")
    .IShouldSeeBadRequestResponse();
}

运行测试

在最简单的集成测试中,添加一个州,我们遇到了问题。

在 Postman 中查看响应,我们看到

"The JSON value could not be converted to System.String. 
 Path: $ | LineNumber: 0 | BytePositionInLine: 1."

糟糕。原因很明显——我将 body 参数实现为 string,而不是类。因此,更改不仅是添加 state 的终结点,还包括为 state 添加 county,这似乎也是合理的。

public class StateCountyName
{
  public string StateName { get; set; }
  public string CountyName { get; set; }
}

[HttpPost("")]
public object AddState([FromBody] StateCountyName name)
{
  object ret = Try<StateModelException>(
    NoContent(), 
    () => stateModel.AddState(name.StateName));

  return ret;
}

[HttpPost("County")]
public object AddCounty(
  [FromBody] StateCountyName name)
{
  object ret = Try<StateModelException>(
    NoContent(), 
    () => stateModel.AddCounty(name.StateName, name.CountyName));

  return ret;
}

是的,我们可以添加对 null 值和空字符串的验证,但我不会在这里让您厌烦这些细节。

现在当我运行 AddStateTest 时,我得到了这个讨厌的异常

Newtonsoft.Json.JsonSerializationException: Cannot deserialize the current JSON array 
(e.g. [1,2,3]) into type 'FluentWebApiIntegrationTestDemoModels.StateModel' 
because the type requires a JSON object (e.g. {"name":"value"}) to deserialize correctly.

那是因为我做了一些愚蠢的事情。“get states”API 函数返回一个 states 列表,作为 strings,而我们期望的响应是 StateModel。所以我们来修复它

[HttpGet("")]
public object GetStates()
{
  return Ok(stateModel);
}

当然,真正的问题在于,我们不应该暴露 StateModel 的内部字典,而应该将其映射到另一个集合。但这对于本文来说无关紧要。

现在我看到

太棒了!但是……

因为

Expected wp.LastResponse to be NoContent because Did not expected "State already exists.", 
but found BadRequest.

糟糕。为了我们的测试,我们需要重置我们的伪数据库。从技术上讲,这应该在每次测试的清理阶段完成,而每次测试清理实际上都是一个 API 调用

[TestCleanup]
public void CleanupData()
{
  string baseUrl = "https:///FluentWebApiIntegrationtestDemo";

  new WorkflowPacket(baseUrl)
    .CleanupStateTestData()
    .IShouldSeeOKResponse();
}

实现为

[HttpPost("CleanupTestData")]
public object CleanupTestData()
{
  stateModel = new StateModel();

  return Ok();
}

然而,这实际上是错误的,尤其是在调试集成测试时——我们真正想要的是在每次测试运行 之前 清理测试数据!

[TestInitialize]
public void CleanupData()
{
  new WorkflowPacket(baseUrl)
    .CleanupStateTestData()
    .IShouldSeeOKResponse();
}

最后,我们成功了

最后,我厌倦了在每个测试中都出现这行代码

string baseUrl = "https:///FluentWebApiIntegrationtestDemo";

因此,测试类将继承自一个 Setup 类,该类可以扩展以执行其他设置/拆卸功能。

public class Setup
{
  public static string baseUrl = "https:///FluentWebApiIntegrationtestDemo";
}

这个概念可以扩展到参数化 URL,这样就可以使用不同的服务器(本地、测试、QA)了,从而可以在测试/部署过程的每个阶段执行集成测试。我经常使用 Setup 基类来执行登录/身份验证以及(总是通过调用终结点!)复杂的 数据设置,这些设置用于多个集成测试。

评估

在这里,我们做了一些更有趣的事情,因为集成测试需要多个步骤。要添加一个 county,必须先存在 state。这个基本测试

new WorkflowPacket(baseUrl)
  .AddState("NY")
  .IShouldSeeNoContentResponse()
  .AddCounty("NY", "Columbia")
  .GetStatesAndCounties("myStates")
  .IShouldSeeOKResponse()
  .ThenIShouldSee<StateModel>("myStates", m => m.GetStates().Count().Should().Be(1))
  .ThenIShouldSee<StateModel>("myStates", m => m.GetStates().First().Should().Be("NY"))
  .ThenIShouldSee<StateModel>("myStates", m => m.GetCounties("NY").Count().Should().Be(1))
  .ThenIShouldSee<StateModel>
   ("myStates", m => m.GetCounties("NY").First().Should().Be("Columbia"));

可以扩展到测试多个州和每个郡的多个州是否得到正确处理,更新和删除名称是否有效,等等。如果我们使用的是真实数据库,API 终结点返回带有主键字段的记录是合理的,然后可以使用该记录来添加郡,而不是指定州名。如果您有一个一致的主键命名约定(例如“ID”,人们坚持在主键名中包含表名这一点我实在不理解),您可以实现 Fluent API 方法来通过其名称查找对象,这样您就可以编写

.AddState("nyState", "NY")
.AddCounty("columbiaCounty", "nyState", "Columbia")

而实现看起来会像

AddCounty(string countyBucketName, string stateBucketName, string countyName)
{
  int id = (wp.Container[stateBucketName] as IHasId).ID;
  var resp = RestService.Post<County>($"{wp.BaseUrl}/State/{id}/County", new { countyName });
  wp.LastResponse = resp.status;
  wp.LastContent = resp.content;
  wp.Container[countyBucketName] = resp.item;
}

希望这个概念有意义——想法是使用 API 终结点返回的对象在后续调用 Fluent API 方法,假设您在编码模型和终结点时有一些智能。

Fluency 的缺点

Fluent API(而不是终结点 API!)的问题在于,如果发生异常,您实际上不知道自己在方法调用链的哪个位置。为了缓解这个问题,我们可以实现一个方法调用列表,这样当发生故障时,我们可以向开发人员显示故障发生的方法链中的位置。例如,每个 Fluent API 方法都可以自我记录

public static WorkflowPacket Factorial<T>
(this WorkflowPacket wp, string containerName, int n) where T: new()
{
  wp.Log("Factorial");
  ...

如果我们一致这样做,那么我们就可以随时显示日志

public static WorkflowPacket PrintLog(this WorkflowPacket wp)
{
  wp.CallLog.ForEach(item => wp.Write(item));

  return wp;
}

public static WorkflowPacket Write(this WorkflowPacket wp, string msg)
{
  System.Diagnostics.Debug.WriteLine(msg);

  return wp;
}

如果我将 PrintLog 添加到添加 statecounty 的集成测试的末尾,我们就会看到

然而,这还不够。我们真正想要的是在测试清理时打印日志,因此对于测试失败,我们可以看到它在哪里失败。首先,我们重构测试夹具本身,为每个测试实例化 WorkflowPacket

private WorkflowPacket wp;

[TestInitialize]
public void InitializeTest()
{
  wp = new WorkflowPacket(baseUrl)
    .CleanupStateTestData()
    .IShouldSeeOKResponse();
}

[TestCleanup]
public void CleanupTest()
{
  wp.PrintLog();
}

现在每个测试都使用 wp 而不是实例化自己的 WorkflowPacket。所以创建状态的测试看起来是这样的

[TestMethod]
public void AddCountyAndAutoCreateStateTest()
{
  wp
    .AddCounty("NY", "Columbia")
    .IShouldSeeNoContentResponse()
    .GetStatesAndCounties("myStates")
    .IShouldSeeOKResponse()
    .ThenIShouldSee<StateModel>("myStates", m => m.GetStates().Count().Should().Be(1))
    .ThenIShouldSee<StateModel>("myStates", m => m.GetStates().First().Should().Be("NY"))
    .ThenIShouldSee<StateModel>("myStates", m => m.GetCounties("NY").Count().Should().Be(1))
    .ThenIShouldSee<StateModel>
     ("myStates", m => m.GetCounties("NY").First().Should().Be("Columbia"));
}

这当然会失败,我们可以看到测试是在哪个步骤失败的

我们看到它在调用 AddCounty 时失败了。

结论 - 模式是什么?

这种方法模式足够简单,您可以从任何地方开始,如何做事情,当然总是取决于要完成的任务!

一旦您习惯了编写这些集成测试,它就会变得自然而然。我发现我实际上在接触任何代码之前先编写集成测试

  • 第一,证明代码是错误的;
  • 第二,证明修复是有效的;
  • 第三,证明修复没有破坏其他东西。

我发现这种方法比使用实际的网页测试应用程序模拟用户直接在浏览器上的操作要有效得多。通过这种方法,我可以在 UI 实现之前编写 Web API,并证明 Web API 符合规范。同样,如果我的 Web API 集成测试通过了,那么问题就在前端。

现在您可能会说,嗯,所有这些都可以用单元测试来处理。我说不行。在实际实践中,我处理的是复杂的数据依赖关系,代码库不是为单元测试设计的(它从来都不是),而且业务规则分散在各种类实例和触发的事件中。对这些进行离散的测试并不能建立任何信心,当用户单击“保存”按钮时,所有逻辑都会按照预期运行。相反,通过集成测试,我可以(同时测试其他代码部分)通过其他终结点设置各种数据配置,然后调用“Save”API 终结点,该终结点触发所有业务规则。然后,我可以像用户看到的那样请求数据,并验证一切看起来是否正确。

归根结底,以 Fluent 的方式编写集成测试并使用 FluentAssertions 的目的只是我不断收到的反馈:“哇,这个确实可读!”希望您也能有这种体验。我也希望您喜欢阅读我如何创建了一个 Fluent Web API 集成测试“框架”以及其中的步骤和思考过程。

历史

  • 2021 年 5 月 22 日:初始版本
© . All rights reserved.