ASP.NET Web API 2 中的單元測試控制器

本主題描述 Web API 2 中單元測試控制器的一些特定技術。 閱讀本主題之前,您可能想要閱讀單元測試 ASP.NET Web API 2教學課程,其中示範如何將單元測試專案新增至您的解決方案。

教學課程中使用的軟體版本

注意

我使用了 Moq,但相同的想法適用于任何模擬架構。 Moq 4.5.30 (及更新版本) 支援 Visual Studio 2017、Roslyn 和 .NET 4.5 和更新版本。

單元測試中的常見模式是「arrange-act-assert」:

  • 排列:設定測試要執行的任何必要條件。
  • 動作:執行測試。
  • 判斷提示:確認測試成功。

在排列步驟中,您通常會使用模擬或存根物件。 這樣可將相依性數目降到最低,因此測試著重于測試一件事。

以下是您應該在 Web API 控制器中進行單元測試的一些事項:

  • 動作會傳回正確的回應類型。
  • 不正確參數會傳回正確的錯誤回應。
  • 動作會在存放庫或服務層上呼叫正確的方法。
  • 如果回應包含領域模型,請確認模型類型。

這些是測試的一些一般事項,但細節取決於您的控制器實作。 特別是,您的控制器動作會傳回 HttpResponseMessageIHttpActionResult,都有很大的差異。 如需這些結果類型的詳細資訊,請參閱 Web Api 2 中的動作結果

測試傳回 HttpResponseMessage 的動作

以下是動作傳回 HttpResponseMessage的控制器範例。

public class ProductsController : ApiController
{
    IProductRepository _repository;

    public ProductsController(IProductRepository repository)
    {
        _repository = repository;
    }

    public HttpResponseMessage Get(int id)
    {
        Product product = _repository.GetById(id);
        if (product == null)
        {
            return Request.CreateResponse(HttpStatusCode.NotFound);
        }
        return Request.CreateResponse(product);
    }

    public HttpResponseMessage Post(Product product)
    {
        _repository.Add(product);

        var response = Request.CreateResponse(HttpStatusCode.Created, product);
        string uri = Url.Link("DefaultApi", new { id = product.Id });
        response.Headers.Location = new Uri(uri);

        return response;
    }
}

請注意,控制器會使用相依性插入來插入 IProductRepository 。 這可讓控制器更容易測試,因為您可以插入模擬存放庫。 下列單元測試會驗證 方法是否 Get 將 寫入 Product 回應本文。 repository假設 是模擬 IProductRepository

[TestMethod]
public void GetReturnsProduct()
{
    // Arrange
    var controller = new ProductsController(repository);
    controller.Request = new HttpRequestMessage();
    controller.Configuration = new HttpConfiguration();

    // Act
    var response = controller.Get(10);

    // Assert
    Product product;
    Assert.IsTrue(response.TryGetContentValue<Product>(out product));
    Assert.AreEqual(10, product.Id);
}

請務必在控制器上設定要求和設定。 否則,測試將會失敗,並顯示 ArgumentNullExceptionInvalidOperationException

方法 Post 會呼叫 UrlHelper.Link ,以在回應中建立連結。 這需要在單元測試中設定更多:

[TestMethod]
public void PostSetsLocationHeader()
{
    // Arrange
    ProductsController controller = new ProductsController(repository);

    controller.Request = new HttpRequestMessage { 
        RequestUri = new Uri("http://localhost/api/products") 
    };
    controller.Configuration = new HttpConfiguration();
    controller.Configuration.Routes.MapHttpRoute(
        name: "DefaultApi", 
        routeTemplate: "api/{controller}/{id}",
        defaults: new { id = RouteParameter.Optional });

    controller.RequestContext.RouteData = new HttpRouteData(
        route: new HttpRoute(),
        values: new HttpRouteValueDictionary { { "controller", "products" } });

    // Act
    Product product = new Product() { Id = 42, Name = "Product1" };
    var response = controller.Post(product);

    // Assert
    Assert.AreEqual("http://localhost/api/products/42", response.Headers.Location.AbsoluteUri);
}

UrlHelper類別需要要求 URL 和路由資料,因此測試必須設定這些值。 另一個選項是模擬或存根 UrlHelper。 使用此方法時,您會以傳回固定值的模擬或存根版本取代 ApiController.Url 的預設值。

讓我們使用 Moq 架構重寫測試。 在 Moq 測試專案中安裝 NuGet 套件。

[TestMethod]
public void PostSetsLocationHeader_MockVersion()
{
    // This version uses a mock UrlHelper.

    // Arrange
    ProductsController controller = new ProductsController(repository);
    controller.Request = new HttpRequestMessage();
    controller.Configuration = new HttpConfiguration();

    string locationUrl = "http://location/";

    // Create the mock and set up the Link method, which is used to create the Location header.
    // The mock version returns a fixed string.
    var mockUrlHelper = new Mock<UrlHelper>();
    mockUrlHelper.Setup(x => x.Link(It.IsAny<string>(), It.IsAny<object>())).Returns(locationUrl);
    controller.Url = mockUrlHelper.Object;

    // Act
    Product product = new Product() { Id = 42 };
    var response = controller.Post(product);

    // Assert
    Assert.AreEqual(locationUrl, response.Headers.Location.AbsoluteUri);
}

在此版本中,您不需要設定任何路由資料,因為模擬 UrlHelper 會傳回常數位符串。

測試傳回 IHttpActionResult 的動作

在 Web API 2 中,控制器動作可以傳回 IHttpActionResult,這類似于 ASP.NET MVC 中的 ActionResultIHttpActionResult介面會定義用來建立 HTTP 回應的命令模式。 控制器不會直接建立回應,而是會傳回 IHttpActionResult。 稍後,管線會叫用 IHttpActionResult 來建立回應。 這種方法可讓您更輕鬆地撰寫單元測試,因為您可以略過 HttpResponseMessage所需的許多設定。

以下是動作傳回 IHttpActionResult的範例控制器。

public class Products2Controller : ApiController
{
    IProductRepository _repository;

    public Products2Controller(IProductRepository repository)
    {
        _repository = repository;
    }

    public IHttpActionResult Get(int id)
    {
        Product product = _repository.GetById(id);
        if (product == null)
        {
            return NotFound();
        }
        return Ok(product);
    }

    public IHttpActionResult Post(Product product)
    {
        _repository.Add(product);
        return CreatedAtRoute("DefaultApi", new { id = product.Id }, product);
    }

    public IHttpActionResult Delete(int id)
    {
        _repository.Delete(id);
        return Ok();
    }

    public IHttpActionResult Put(Product product)
    {
        // Do some work (not shown).
        return Content(HttpStatusCode.Accepted, product);
    }    
}

此範例示範使用 IHttpActionResult的一些常見模式。 讓我們看看如何進行單元測試。

動作會以回應本文傳回 200 (OK)

如果找到產品,方法 Get 會呼叫 Ok(product) 。 在單元測試中,確定傳回類型為 OkNegotiatedContentResult ,且傳回的產品具有正確的識別碼。

[TestMethod]
public void GetReturnsProductWithSameId()
{
    // Arrange
    var mockRepository = new Mock<IProductRepository>();
    mockRepository.Setup(x => x.GetById(42))
        .Returns(new Product { Id = 42 });

    var controller = new Products2Controller(mockRepository.Object);

    // Act
    IHttpActionResult actionResult = controller.Get(42);
    var contentResult = actionResult as OkNegotiatedContentResult<Product>;

    // Assert
    Assert.IsNotNull(contentResult);
    Assert.IsNotNull(contentResult.Content);
    Assert.AreEqual(42, contentResult.Content.Id);
}

請注意,單元測試不會執行動作結果。 您可以假設動作結果會正確建立 HTTP 回應。 (這就是 Web API 架構有自己的單元測試的原因!)

動作會傳回 404 (找不到)

如果找不到產品,方法 Get 會呼叫 NotFound() 。 在此情況下,單元測試只會檢查傳回類型是否為 NotFoundResult

[TestMethod]
public void GetReturnsNotFound()
{
    // Arrange
    var mockRepository = new Mock<IProductRepository>();
    var controller = new Products2Controller(mockRepository.Object);

    // Act
    IHttpActionResult actionResult = controller.Get(10);

    // Assert
    Assert.IsInstanceOfType(actionResult, typeof(NotFoundResult));
}

動作會傳回 200 (OK) ,沒有回應本文

方法 Delete 會呼叫 Ok() 以傳回空的 HTTP 200 回應。 如同上一個範例,單元測試會檢查傳回類型,在此案例中 為 OkResult

[TestMethod]
public void DeleteReturnsOk()
{
    // Arrange
    var mockRepository = new Mock<IProductRepository>();
    var controller = new Products2Controller(mockRepository.Object);

    // Act
    IHttpActionResult actionResult = controller.Delete(10);

    // Assert
    Assert.IsInstanceOfType(actionResult, typeof(OkResult));
}

動作會傳回 201 (使用 Location 標頭建立)

方法 Post 會呼叫 CreatedAtRoute 以在 Location 標頭中使用 URI 傳回 HTTP 201 回應。 在單元測試中,確認動作設定正確的路由值。

[TestMethod]
public void PostMethodSetsLocationHeader()
{
    // Arrange
    var mockRepository = new Mock<IProductRepository>();
    var controller = new Products2Controller(mockRepository.Object);

    // Act
    IHttpActionResult actionResult = controller.Post(new Product { Id = 10, Name = "Product1" });
    var createdResult = actionResult as CreatedAtRouteNegotiatedContentResult<Product>;

    // Assert
    Assert.IsNotNull(createdResult);
    Assert.AreEqual("DefaultApi", createdResult.RouteName);
    Assert.AreEqual(10, createdResult.RouteValues["id"]);
}

動作會傳回另一個具有回應本文的 2xx

方法 Put 會呼叫 Content 以傳回 HTTP 202 (回應主體的已接受) 回應。 此案例類似于傳回 200 (OK) ,但單元測試也應該檢查狀態碼。

[TestMethod]
public void PutReturnsContentResult()
{
    // Arrange
    var mockRepository = new Mock<IProductRepository>();
    var controller = new Products2Controller(mockRepository.Object);

    // Act
    IHttpActionResult actionResult = controller.Put(new Product { Id = 10, Name = "Product" });
    var contentResult = actionResult as NegotiatedContentResult<Product>;

    // Assert
    Assert.IsNotNull(contentResult);
    Assert.AreEqual(HttpStatusCode.Accepted, contentResult.StatusCode);
    Assert.IsNotNull(contentResult.Content);
    Assert.AreEqual(10, contentResult.Content.Id);
}

其他資源