Controller degli unit test nell'API Web ASP.NET 2

Questo argomento descrive alcune tecniche specifiche per i controller di unit test nell'API Web 2. Prima di leggere questo argomento, è possibile leggere l'esercitazione Unit Testing API Web ASP.NET 2, che illustra come aggiungere un progetto di unit test alla soluzione.

Versioni software usate nell'esercitazione

Nota

Ho usato Moq, ma la stessa idea si applica a qualsiasi framework fittizio. Moq 4.5.30 (e versioni successive) supporta Visual Studio 2017, Roslyn e .NET 4.5 e versioni successive.

Uno schema comune negli unit test è "arrange-act-assert":

  • Disponi: configurare tutti i prerequisiti per l'esecuzione del test.
  • Act: eseguire il test.
  • Assert: verificare che il test sia stato completato.

Nel passaggio di disposizione si usano spesso oggetti fittizi o stub. Ciò riduce al minimo il numero di dipendenze, quindi il test è incentrato sul test di un elemento.

Ecco alcuni aspetti che è necessario eseguire unit test nei controller API Web:

  • L'azione restituisce il tipo di risposta corretto.
  • I parametri non validi restituiscono la risposta di errore corretta.
  • L'azione chiama il metodo corretto nel repository o nel livello del servizio.
  • Se la risposta include un modello di dominio, verificare il tipo di modello.

Questi sono alcuni degli aspetti generali da testare, ma le specifiche dipendono dall'implementazione del controller. In particolare, fa una grande differenza se le azioni del controller restituiscono HttpResponseMessage o IHttpActionResult. Per altre informazioni su questi tipi di risultati, vedere Risultati azione nell'API Web 2.

Test di azioni che restituiscono HttpResponseMessage

Di seguito è riportato un esempio di controller le cui azioni restituiscono 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;
    }
}

Si noti che il controller usa l'inserimento delle dipendenze per inserire un oggetto IProductRepository. Ciò rende il controller più testabile, perché è possibile inserire un repository fittizio. Lo unit test seguente verifica che il Get metodo scriva un oggetto Product nel corpo della risposta. Si supponga che repository sia un fittizio 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);
}

È importante impostare Richiesta e configurazione nel controller. In caso contrario, il test avrà esito negativo con un'eccezione ArgumentNullException o InvalidOperationException.

Il Post metodo chiama UrlHelper.Link per creare collegamenti nella risposta. Questa operazione richiede un po' più di configurazione nello unit test:

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

La classe UrlHelper richiede l'URL della richiesta e i dati di route, quindi il test deve impostare i valori per questi. Un'altra opzione è fittizia o stub UrlHelper. Con questo approccio, si sostituisce il valore predefinito di ApiController.Url con una versione fittizia o stub che restituisce un valore fisso.

Riscrivere il test usando il framework Moq . Installare il Moq pacchetto NuGet nel progetto di test.

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

In questa versione non è necessario configurare dati di route, perché urlHelper fittizio restituisce una stringa costante.

Azioni di test che restituiscono IHttpActionResult

Nell'API Web 2 un'azione del controller può restituire IHttpActionResult, analogamente a ActionResult in ASP.NET MVC. L'interfaccia IHttpActionResult definisce un modello di comando per la creazione di risposte HTTP. Anziché creare direttamente la risposta, il controller restituisce un oggetto IHttpActionResult. Successivamente, la pipeline richiama IHttpActionResult per creare la risposta. Questo approccio semplifica la scrittura di unit test, perché è possibile ignorare molte delle configurazioni necessarie per HttpResponseMessage.

Ecco un controller di esempio le cui azioni restituiscono 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);
    }    
}

Questo esempio mostra alcuni modelli comuni che usano IHttpActionResult. Vediamo come eseguire unit test.

L'azione restituisce 200 (OK) con un corpo della risposta

Il Get metodo chiama Ok(product) se viene trovato il prodotto. Nello unit test verificare che il tipo restituito sia OkNegotiatedContentResult e che il prodotto restituito abbia l'ID corretto.

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

Si noti che lo unit test non esegue il risultato dell'azione. È possibile presupporre che il risultato dell'azione crei correttamente la risposta HTTP. Questo è il motivo per cui il framework API Web ha unit test personalizzati.

L'azione restituisce 404 (non trovato)

Il Get metodo chiama NotFound() se il prodotto non viene trovato. Per questo caso, lo unit test controlla solo se il tipo restituito è 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));
}

L'azione restituisce 200 (OK) senza corpo della risposta

Il Delete metodo chiama Ok() per restituire una risposta HTTP 200 vuota. Come nell'esempio precedente, lo unit test controlla il tipo restituito, in questo caso 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));
}

L'azione restituisce 201 (creato) con un'intestazione Location

Il Post metodo chiama CreatedAtRoute per restituire una risposta HTTP 201 con un URI nell'intestazione Location. Nello unit test verificare che l'azione imposti i valori di routing corretti.

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

L'azione restituisce un altro 2xx con un corpo della risposta

Il Put metodo chiama Content per restituire una risposta HTTP 202 (accettata) con un corpo della risposta. Questo caso è simile alla restituzione di 200 (OK), ma lo unit test deve anche controllare il codice di stato.

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

Risorse aggiuntive