Kontrolery testów jednostkowych we wzorcu ASP.NET Web API 2

W tym temacie opisano niektóre konkretne techniki kontrolerów testowania jednostkowego w internetowym interfejsie API 2. Przed przeczytaniem tego tematu warto przeczytać samouczek Testowanie jednostkowe ASP.NET internetowego interfejsu API 2, który pokazuje, jak dodać projekt testowy jednostkowy do rozwiązania.

Wersje oprogramowania używane w samouczku

Uwaga

Użyłem Moq, ale ten sam pomysł ma zastosowanie do każdej szyderczej struktury. Program Moq 4.5.30 (i nowsze) obsługuje programy Visual Studio 2017, Roslyn i .NET 4.5 i nowsze.

Typowym wzorcem testów jednostkowych jest "arrange-act-assert":

  • Rozmieść: skonfiguruj wszelkie wymagania wstępne dotyczące przebiegu testu.
  • Działanie: przeprowadź test.
  • Potwierdzenie: sprawdź, czy test zakończył się pomyślnie.

W kroku rozmieszczania często będziesz używać makiety lub obiektów wycinkowych. To minimalizuje liczbę zależności, więc test koncentruje się na testowaniu jednej rzeczy.

Oto kilka rzeczy, które należy przetestować jednostkowym na kontrolerach internetowego interfejsu API:

  • Akcja zwraca prawidłowy typ odpowiedzi.
  • Nieprawidłowe parametry zwracają poprawną odpowiedź o błędzie.
  • Akcja wywołuje poprawną metodę w repozytorium lub warstwie usługi.
  • Jeśli odpowiedź zawiera model domeny, sprawdź typ modelu.

Są to niektóre ogólne kwestie do przetestowania, ale specyfika zależy od implementacji kontrolera. W szczególności ma to dużą różnicę, czy akcje kontrolera zwracają httpResponseMessage czy IHttpActionResult. Aby uzyskać więcej informacji na temat tych typów wyników, zobacz Wyniki akcji w internetowym interfejsie API 2.

Akcje testowania zwracające komunikat HttpResponseMessage

Oto przykład kontrolera, którego akcje zwracają komunikat 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;
    }
}

Zwróć uwagę, że kontroler używa iniekcji zależności do wstrzykiwania IProductRepositoryelementu . To sprawia, że kontroler jest bardziej testowalny, ponieważ można wstrzyknąć makiety repozytorium. Poniższy test jednostkowy sprawdza, czy Get metoda zapisuje element w Product treści odpowiedzi. Załóżmy, że repository jest wyśmiewane 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);
}

Ważne jest ustawienie żądania i konfiguracji na kontrolerze. W przeciwnym razie test zakończy się niepowodzeniem z argumentemNullException lub InvalidOperationException.

Metoda Post wywołuje UrlHelper.Link , aby utworzyć linki w odpowiedzi. Wymaga to nieco więcej konfiguracji w teście jednostkowym:

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

Klasa UrlHelper wymaga adresu URL żądania i danych trasy, dlatego test musi ustawić wartości dla tych elementów. Inną opcją jest wyśmiewane lub wycinkowe UrlHelper. Dzięki temu podejściu zastąp wartość domyślną elementu ApiController.Url pozorną lub wersją wycinkową zwracającą stałą wartość.

Napiszmy ponownie test przy użyciu platformy Moq . Moq Zainstaluj pakiet NuGet w projekcie testowym.

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

W tej wersji nie trzeba konfigurować żadnych danych trasy, ponieważ makieta UrlHelper zwraca stały ciąg.

Akcje testowania zwracające IHttpActionResult

W internetowym interfejsie API 2 akcja kontrolera może zwrócić wartość IHttpActionResult, która jest analogiczna do elementu ActionResult w ASP.NET MVC. Interfejs IHttpActionResult definiuje wzorzec polecenia do tworzenia odpowiedzi HTTP. Zamiast bezpośrednio tworzyć odpowiedź, kontroler zwraca obiekt IHttpActionResult. Później potok wywołuje element IHttpActionResult , aby utworzyć odpowiedź. Takie podejście ułatwia pisanie testów jednostkowych, ponieważ można pominąć wiele konfiguracji, które są potrzebne dla protokołu HttpResponseMessage.

Oto przykładowy kontroler, którego akcje zwracają wartość 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);
    }    
}

W tym przykładzie przedstawiono kilka typowych wzorców korzystających z funkcji IHttpActionResult. Zobaczmy, jak je przetestować.

Akcja zwraca 200 (OK) z treścią odpowiedzi

Metoda Get wywołuje metodę Ok(product) w przypadku znalezienia produktu. W teście jednostkowym upewnij się, że zwracany typ to OkNegotiatedContentResult , a zwrócony produkt ma prawidłowy identyfikator.

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

Zwróć uwagę, że test jednostkowy nie wykonuje wyniku akcji. Możesz założyć, że wynik akcji poprawnie tworzy odpowiedź HTTP. (Dlatego struktura internetowego interfejsu API ma własne testy jednostkowe!)

Akcja zwraca wartość 404 (nie znaleziono)

Metoda Get wywołuje metodę NotFound() , jeśli produkt nie zostanie znaleziony. W tym przypadku test jednostkowy sprawdza, czy zwracany typ to 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));
}

Akcja zwraca 200 (OK) bez treści odpowiedzi

Metoda Delete wywołuje metodę Ok() , aby zwrócić pustą odpowiedź HTTP 200. Podobnie jak w poprzednim przykładzie test jednostkowy sprawdza typ powrotu, w tym przypadku 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));
}

Akcja zwraca wartość 201 (Utworzono) z nagłówkiem Lokalizacja

Metoda Post wywołuje metodę CreatedAtRoute , aby zwrócić odpowiedź HTTP 201 z identyfikatorem URI w nagłówku Lokalizacja. W teście jednostkowym sprawdź, czy akcja ustawia prawidłowe wartości routingu.

[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"]);
}

Akcja zwraca kolejne 2xx z treścią odpowiedzi

Metoda Put wywołuje Content odpowiedź HTTP 202 (Zaakceptowana) z treścią odpowiedzi. Ten przypadek jest podobny do zwracania 200 (OK), ale test jednostkowy powinien również sprawdzić kod stanu.

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

Dodatkowe zasoby