ASP.NET Web API 2 の単体テスト コントローラー

このトピックでは、Web API 2 のコントローラーを単体テストするための特定の手法について説明します。 このトピックを読む前に、単体テスト プロジェクトをソリューションに追加する方法を示す、ASP.NET Web API 2 の単体テストのチュートリアルをお読みください。

チュートリアルで使用するソフトウェアのバージョン

Note

ここでは Moq を使用しましたが、同じアイデアが任意のモック フレームワークに適用されます。 Moq 4.5.30 (以降) では、Visual Studio 2017、Roslyn、.NET 4.5 以降のバージョンがサポートされています。

単体テストの一般的なパターンは、"arrange-act-assert" です。

  • Arrange: テストを実行するための前提条件を設定します。
  • Act: テストを実行します。
  • Assert: テストが成功したことを確認します。

arrange ステップでは、多くの場合、モック オブジェクトまたはスタブ オブジェクトを使用します。 それにより依存関係の数が最小限に抑えられるため、テストでは 1 つのことのテストに集中します。

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);
}

コントローラーで RequestConfiguration を設定することが重要です。 そうしない場合、テストは ArgumentNullException または InvalidOperationException で失敗します。

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 とルート データが必要であるため、テストではこれらの値を設定する必要があります。 もう 1 つのオプションは、モックまたはスタブ 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 の ActionResult に似ています。 IHttpActionResult インターフェイスは、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 であり、返される製品の ID が正しいことを確認します。

[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 (Not Found) を返す

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));
}

アクションが Location ヘッダーと 201 (Created) を返す

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 (Accepted) 応答を返します。 このケースは 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);
}

その他のリソース