Unit test della logica dei controller in ASP.NET Core

Di Steve Smith

Gli unit test implicano l'esecuzione di test su una parte di un'app isolandola dall'infrastruttura e dalle dipendenze. Quando si sottopone a unit test la logica del controller, si verificano solo i contenuti di una singola azione e non il comportamento delle relative dipendenze o del framework.

Controller di unit test

Configurare unit test delle azioni del controller per concentrare l'attenzione sul comportamento del controller. Uno unit test del controller evita scenari come filtri, routing e associazione di modelli. I test che verificano le interazioni tra i componenti che collettivamente rispondono a una richiesta vengono gestiti dai test di integrazione. Per altre informazioni sui test di integrazione, vedere Test di integrazione in ASP.NET Core.

Se si scrivono route e filtri personalizzati, sottoporli a unit test in isolamento e non durante i test relativi a una determinata azione del controller.

Per una dimostrazione degli unit test del controller, esaminare il controller seguente nell'app di esempio.

Visualizzare o scaricare il codice di esempio (procedura per il download)

Il Home controller visualizza un elenco di sessioni di brainstorming e consente la creazione di nuove sessioni di brainstorming con una richiesta POST:

public class HomeController : Controller
{
    private readonly IBrainstormSessionRepository _sessionRepository;

    public HomeController(IBrainstormSessionRepository sessionRepository)
    {
        _sessionRepository = sessionRepository;
    }

    public async Task<IActionResult> Index()
    {
        var sessionList = await _sessionRepository.ListAsync();

        var model = sessionList.Select(session => new StormSessionViewModel()
        {
            Id = session.Id,
            DateCreated = session.DateCreated,
            Name = session.Name,
            IdeaCount = session.Ideas.Count
        });

        return View(model);
    }

    public class NewSessionModel
    {
        [Required]
        public string SessionName { get; set; }
    }

    [HttpPost]
    public async Task<IActionResult> Index(NewSessionModel model)
    {
        if (!ModelState.IsValid)
        {
            return BadRequest(ModelState);
        }
        else
        {
            await _sessionRepository.AddAsync(new BrainstormSession()
            {
                DateCreated = DateTimeOffset.Now,
                Name = model.SessionName
            });
        }

        return RedirectToAction(actionName: nameof(Index));
    }
}

Il controller precedente:

  • Segue il principio delle dipendenze esplicite.
  • Si aspetta che l'inserimento delle dipendenze fornisca un'istanza di IBrainstormSessionRepository.
  • Può essere testato con un servizio IBrainstormSessionRepository fittizio tramite il framework di un oggetto fittizio, ad esempio Moq. Un oggetto fittizio è un oggetto creato con un set predeterminato di comportamenti di proprietà e metodi usati per il testing. Per altre informazioni, vedere Introduction to integration tests (Introduzione ai test di integrazione).

Il metodo HTTP GET Index non dispone di cicli o rami e chiama un solo metodo. Lo unit test per questa azione:

  • Simula il servizio IBrainstormSessionRepository usando il metodo GetTestSessions. GetTestSessions crea due sessioni di brainstorming fittizie con date e nomi di sessione.
  • Esegue il metodo Index.
  • Rende le asserzioni sul risultato restituito dal metodo :
[Fact]
public async Task Index_ReturnsAViewResult_WithAListOfBrainstormSessions()
{
    // Arrange
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.ListAsync())
        .ReturnsAsync(GetTestSessions());
    var controller = new HomeController(mockRepo.Object);

    // Act
    var result = await controller.Index();

    // Assert
    var viewResult = Assert.IsType<ViewResult>(result);
    var model = Assert.IsAssignableFrom<IEnumerable<StormSessionViewModel>>(
        viewResult.ViewData.Model);
    Assert.Equal(2, model.Count());
}
private List<BrainstormSession> GetTestSessions()
{
    var sessions = new List<BrainstormSession>();
    sessions.Add(new BrainstormSession()
    {
        DateCreated = new DateTime(2016, 7, 2),
        Id = 1,
        Name = "Test One"
    });
    sessions.Add(new BrainstormSession()
    {
        DateCreated = new DateTime(2016, 7, 1),
        Id = 2,
        Name = "Test Two"
    });
    return sessions;
}

I Home test del metodo del HTTP POST Index controller verificano che:

  • Quando ModelState.IsValid è false, il metodo di azione restituisce una richiesta ViewResult non valida 400 con i dati appropriati.
  • Quando ModelState.IsValid è true:
    • Venga chiamato il metodo Add sul repository.
    • Venga restituito un RedirectToActionResult con gli argomenti corretti.

È possibile eseguire il test di uno stato del modello non valido aggiungendo gli errori con AddModelError come illustrato nel primo test riportato di seguito:

[Fact]
public async Task IndexPost_ReturnsBadRequestResult_WhenModelStateIsInvalid()
{
    // Arrange
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.ListAsync())
        .ReturnsAsync(GetTestSessions());
    var controller = new HomeController(mockRepo.Object);
    controller.ModelState.AddModelError("SessionName", "Required");
    var newSession = new HomeController.NewSessionModel();

    // Act
    var result = await controller.Index(newSession);

    // Assert
    var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
    Assert.IsType<SerializableError>(badRequestResult.Value);
}

[Fact]
public async Task IndexPost_ReturnsARedirectAndAddsSession_WhenModelStateIsValid()
{
    // Arrange
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.AddAsync(It.IsAny<BrainstormSession>()))
        .Returns(Task.CompletedTask)
        .Verifiable();
    var controller = new HomeController(mockRepo.Object);
    var newSession = new HomeController.NewSessionModel()
    {
        SessionName = "Test Name"
    };

    // Act
    var result = await controller.Index(newSession);

    // Assert
    var redirectToActionResult = Assert.IsType<RedirectToActionResult>(result);
    Assert.Null(redirectToActionResult.ControllerName);
    Assert.Equal("Index", redirectToActionResult.ActionName);
    mockRepo.Verify();
}

Quando ModelState non è valido, viene restituito lo stesso ViewResult come per una richiesta GET. Il test non prova a passare un modello non valido. Il passaggio di un modello non valido non è un approccio corretto, in quanto l'associazione di modelli non è in esecuzione (benché un test di integrazione usi invece l'associazione di modelli). In questo caso l'associazione di modelli non viene testata. Questi unit test testano solo il codice del metodo di azione.

Il secondo test verifica che quando ModelState è valido:

  • Venga aggiunto un nuovo elemento BrainstormSession (tramite il repository).
  • Il metodo restituisca un RedirectToActionResult con le proprietà previste.

Le chiamate fittizie che non vengono eseguite sono in genere ignorate, ma la chiamata di Verifiable al termine della chiamata setup consente la convalida fittizia nel test. Questa operazione viene eseguita con la chiamata a mockRepo.Verify, che non supera il test se non è stato chiamato il metodo previsto.

Nota

La libreria Moq usata in questo esempio consente la combinazione di simulazioni verificabili o "rigide" con simulazioni non verificabili (dette anche simulazioni "generiche" o stub "generici"). Altre informazioni sulla personalizzazione del comportamento di simulazione con Moq.

SessionController nell'app di esempio visualizza informazioni correlate a una sessione di brainstorming specifica. Il controller include la logica per gestire i valori id non validi (nell'esempio seguente sono riportati due scenari return in proposito). L'istruzione finale return restituisce un nuovo StormSessionViewModel oggetto alla visualizzazione (Controllers/SessionController.cs):

public class SessionController : Controller
{
    private readonly IBrainstormSessionRepository _sessionRepository;

    public SessionController(IBrainstormSessionRepository sessionRepository)
    {
        _sessionRepository = sessionRepository;
    }

    public async Task<IActionResult> Index(int? id)
    {
        if (!id.HasValue)
        {
            return RedirectToAction(actionName: nameof(Index), 
                controllerName: "Home");
        }

        var session = await _sessionRepository.GetByIdAsync(id.Value);
        if (session == null)
        {
            return Content("Session not found.");
        }

        var viewModel = new StormSessionViewModel()
        {
            DateCreated = session.DateCreated,
            Name = session.Name,
            Id = session.Id
        };

        return View(viewModel);
    }
}

Gli unit test includono un test per ogni scenario return nell'azione Index del controller Session:

[Fact]
public async Task IndexReturnsARedirectToIndexHomeWhenIdIsNull()
{
    // Arrange
    var controller = new SessionController(sessionRepository: null);

    // Act
    var result = await controller.Index(id: null);

    // Assert
    var redirectToActionResult = 
        Assert.IsType<RedirectToActionResult>(result);
    Assert.Equal("Home", redirectToActionResult.ControllerName);
    Assert.Equal("Index", redirectToActionResult.ActionName);
}

[Fact]
public async Task IndexReturnsContentWithSessionNotFoundWhenSessionNotFound()
{
    // Arrange
    int testSessionId = 1;
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync((BrainstormSession)null);
    var controller = new SessionController(mockRepo.Object);

    // Act
    var result = await controller.Index(testSessionId);

    // Assert
    var contentResult = Assert.IsType<ContentResult>(result);
    Assert.Equal("Session not found.", contentResult.Content);
}

[Fact]
public async Task IndexReturnsViewResultWithStormSessionViewModel()
{
    // Arrange
    int testSessionId = 1;
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync(GetTestSessions().FirstOrDefault(
            s => s.Id == testSessionId));
    var controller = new SessionController(mockRepo.Object);

    // Act
    var result = await controller.Index(testSessionId);

    // Assert
    var viewResult = Assert.IsType<ViewResult>(result);
    var model = Assert.IsType<StormSessionViewModel>(
        viewResult.ViewData.Model);
    Assert.Equal("Test One", model.Name);
    Assert.Equal(2, model.DateCreated.Day);
    Assert.Equal(testSessionId, model.Id);
}

Passando al controller Ideas, l'app espone la funzionalità come API Web sulla route api/ideas:

  • Il metodo ForSession restituisce un elenco di idee (IdeaDTO) associate a una sessione di brainstorming.
  • Il metodo Create aggiunge nuove idee a una sessione.
[HttpGet("forsession/{sessionId}")]
public async Task<IActionResult> ForSession(int sessionId)
{
    var session = await _sessionRepository.GetByIdAsync(sessionId);
    if (session == null)
    {
        return NotFound(sessionId);
    }

    var result = session.Ideas.Select(idea => new IdeaDTO()
    {
        Id = idea.Id,
        Name = idea.Name,
        Description = idea.Description,
        DateCreated = idea.DateCreated
    }).ToList();

    return Ok(result);
}

[HttpPost("create")]
public async Task<IActionResult> Create([FromBody]NewIdeaModel model)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }

    var session = await _sessionRepository.GetByIdAsync(model.SessionId);
    if (session == null)
    {
        return NotFound(model.SessionId);
    }

    var idea = new Idea()
    {
        DateCreated = DateTimeOffset.Now,
        Description = model.Description,
        Name = model.Name
    };
    session.AddIdea(idea);

    await _sessionRepository.UpdateAsync(session);

    return Ok(session);
}

Evitare di restituire direttamente le entità di dominio aziendali tramite chiamate API. Le entità di dominio infatti:

  • Spesso includono più dati di quelli richiesti dal client.
  • Associano senza necessità il modello di dominio interno dell'app all'API esposta pubblicamente.

Il mapping tra le entità di dominio e i tipi restituiti al client può essere eseguito:

L'app di esempio esegue quindi una dimostrazione degli unit test per i metodi API Create e ForSession del controller Ideas.

L' app di esempio contiene due test ForSession. Il primo determina se ForSession restituisce un NotFoundObjectResult (HTTP non trovato) per una sessione non valida:

[Fact]
public async Task ForSession_ReturnsHttpNotFound_ForInvalidSession()
{
    // Arrange
    int testSessionId = 123;
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync((BrainstormSession)null);
    var controller = new IdeasController(mockRepo.Object);

    // Act
    var result = await controller.ForSession(testSessionId);

    // Assert
    var notFoundObjectResult = Assert.IsType<NotFoundObjectResult>(result);
    Assert.Equal(testSessionId, notFoundObjectResult.Value);
}

Il secondo test ForSession determina se ForSession restituisce un elenco di idee (<List<IdeaDTO>>) per una sessione valida. I controlli esaminano anche la prima idea per verificare che la relativa proprietà Name sia corretta:

[Fact]
public async Task ForSession_ReturnsIdeasForSession()
{
    // Arrange
    int testSessionId = 123;
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync(GetTestSession());
    var controller = new IdeasController(mockRepo.Object);

    // Act
    var result = await controller.ForSession(testSessionId);

    // Assert
    var okResult = Assert.IsType<OkObjectResult>(result);
    var returnValue = Assert.IsType<List<IdeaDTO>>(okResult.Value);
    var idea = returnValue.FirstOrDefault();
    Assert.Equal("One", idea.Name);
}

Per testare il comportamento del metodo Create quando ModelState non è valido, l'app di esempio aggiunge un errore del modello al controller come parte del test. Non provare a testare la convalida del modello o l'associazione di modelli negli unit test: è sufficiente testare il comportamento del metodo di azione in caso di confronto con un oggetto non valido ModelState:

[Fact]
public async Task Create_ReturnsBadRequest_GivenInvalidModel()
{
    // Arrange & Act
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    var controller = new IdeasController(mockRepo.Object);
    controller.ModelState.AddModelError("error", "some error");

    // Act
    var result = await controller.Create(model: null);

    // Assert
    Assert.IsType<BadRequestObjectResult>(result);
}

Il secondo test di Create dipende dal fatto che il repository restituisca null, pertanto il repository fittizio è configurato per restituire null. Non è necessario creare un database di test (in memoria o con un altro approccio) e creare una query che restituisca questo risultato. Il test può essere eseguito in una singola istruzione, come illustrato dal codice di esempio:

[Fact]
public async Task Create_ReturnsHttpNotFound_ForInvalidSession()
{
    // Arrange
    int testSessionId = 123;
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync((BrainstormSession)null);
    var controller = new IdeasController(mockRepo.Object);

    // Act
    var result = await controller.Create(new NewIdeaModel());

    // Assert
    Assert.IsType<NotFoundObjectResult>(result);
}

Il terzo test di Create, Create_ReturnsNewlyCreatedIdeaForSession, verifica che venga chiamato il metodo UpdateAsync del repository. La simulazione viene chiamata con Verifiable e quindi viene chiamato il metodo Verify del repository fittizio per confermare che il metodo verificabile sia stato eseguito. Non è responsabilità dello unit test assicurarsi che il UpdateAsync metodo abbia salvato i dati, che possono essere eseguiti con un test di integrazione.

[Fact]
public async Task Create_ReturnsNewlyCreatedIdeaForSession()
{
    // Arrange
    int testSessionId = 123;
    string testName = "test name";
    string testDescription = "test description";
    var testSession = GetTestSession();
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync(testSession);
    var controller = new IdeasController(mockRepo.Object);

    var newIdea = new NewIdeaModel()
    {
        Description = testDescription,
        Name = testName,
        SessionId = testSessionId
    };
    mockRepo.Setup(repo => repo.UpdateAsync(testSession))
        .Returns(Task.CompletedTask)
        .Verifiable();

    // Act
    var result = await controller.Create(newIdea);

    // Assert
    var okResult = Assert.IsType<OkObjectResult>(result);
    var returnSession = Assert.IsType<BrainstormSession>(okResult.Value);
    mockRepo.Verify();
    Assert.Equal(2, returnSession.Ideas.Count());
    Assert.Equal(testName, returnSession.Ideas.LastOrDefault().Name);
    Assert.Equal(testDescription, returnSession.Ideas.LastOrDefault().Description);
}

Testare ActionResult<T>

ActionResult<T> (ActionResult<TValue>) può restituire un tipo derivato o ActionResult restituire un tipo specifico.

L'app di esempio include un metodo che restituisce un List<IdeaDTO> per un determinato id di sessione. Se l'id di sessione non esiste, il controller restituisce NotFound:

[HttpGet("forsessionactionresult/{sessionId}")]
[ProducesResponseType(200)]
[ProducesResponseType(404)]
public async Task<ActionResult<List<IdeaDTO>>> ForSessionActionResult(int sessionId)
{
    var session = await _sessionRepository.GetByIdAsync(sessionId);

    if (session == null)
    {
        return NotFound(sessionId);
    }

    var result = session.Ideas.Select(idea => new IdeaDTO()
    {
        Id = idea.Id,
        Name = idea.Name,
        Description = idea.Description,
        DateCreated = idea.DateCreated
    }).ToList();

    return result;
}

In ApiIdeasControllerTests sono inclusi due test del controller ForSessionActionResult.

Il primo test verifica che il controller restituisca un ActionResult ma non un elenco non esistente di idee per un id di sessione inesistente:

[Fact]
public async Task ForSessionActionResult_ReturnsNotFoundObjectResultForNonexistentSession()
{
    // Arrange
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    var controller = new IdeasController(mockRepo.Object);
    var nonExistentSessionId = 999;

    // Act
    var result = await controller.ForSessionActionResult(nonExistentSessionId);

    // Assert
    var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result);
    Assert.IsType<NotFoundObjectResult>(actionResult.Result);
}

Per un id di sessione valido, il secondo test verifica che il metodo restituisca:

  • Il metodo restituisca un ActionResult con un tipo List<IdeaDTO>.
  • ActionResult<T>.Value sia di tipo List<IdeaDTO>.
  • Il primo elemento nell'elenco sia un'idea valida corrispondente all'idea archiviata nella sessione fittizia (ottenuta chiamando GetTestSession).
[Fact]
public async Task ForSessionActionResult_ReturnsIdeasForSession()
{
    // Arrange
    int testSessionId = 123;
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync(GetTestSession());
    var controller = new IdeasController(mockRepo.Object);

    // Act
    var result = await controller.ForSessionActionResult(testSessionId);

    // Assert
    var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result);
    var returnValue = Assert.IsType<List<IdeaDTO>>(actionResult.Value);
    var idea = returnValue.FirstOrDefault();
    Assert.Equal("One", idea.Name);
}

L'app di esempio include anche un metodo per creare una nuova Idea per una determinata sessione. Il controller restituisce:

[HttpPost("createactionresult")]
[ProducesResponseType(201)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<ActionResult<BrainstormSession>> CreateActionResult([FromBody]NewIdeaModel model)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }

    var session = await _sessionRepository.GetByIdAsync(model.SessionId);

    if (session == null)
    {
        return NotFound(model.SessionId);
    }

    var idea = new Idea()
    {
        DateCreated = DateTimeOffset.Now,
        Description = model.Description,
        Name = model.Name
    };
    session.AddIdea(idea);

    await _sessionRepository.UpdateAsync(session);

    return CreatedAtAction(nameof(CreateActionResult), new { id = session.Id }, session);
}

In ApiIdeasControllerTests sono inclusi tre test di CreateActionResult.

Il primo test verifica che per un modello non valido venga restituito un elemento BadRequest.

[Fact]
public async Task CreateActionResult_ReturnsBadRequest_GivenInvalidModel()
{
    // Arrange & Act
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    var controller = new IdeasController(mockRepo.Object);
    controller.ModelState.AddModelError("error", "some error");

    // Act
    var result = await controller.CreateActionResult(model: null);

    // Assert
    var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
    Assert.IsType<BadRequestObjectResult>(actionResult.Result);
}

Il secondo test verifica che venga restituito un elemento NotFound se la sessione non esiste.

[Fact]
public async Task CreateActionResult_ReturnsNotFoundObjectResultForNonexistentSession()
{
    // Arrange
    var nonExistentSessionId = 999;
    string testName = "test name";
    string testDescription = "test description";
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    var controller = new IdeasController(mockRepo.Object);

    var newIdea = new NewIdeaModel()
    {
        Description = testDescription,
        Name = testName,
        SessionId = nonExistentSessionId
    };

    // Act
    var result = await controller.CreateActionResult(newIdea);

    // Assert
    var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
    Assert.IsType<NotFoundObjectResult>(actionResult.Result);
}

Per un id di sessione valido, il test finale verifica che:

  • Il metodo restituisca un ActionResult di tipo BrainstormSession.
  • ActionResult<T>.Result sia di tipo CreatedAtActionResult. CreatedAtActionResult è analogo a una risposta 201 - Creato con un'intestazione Location.
  • ActionResult<T>.Value sia di tipo BrainstormSession.
  • La chiamata fittizia per aggiornare la sessione, UpdateAsync(testSession), sia stata eseguita. La chiamata del metodo Verifiable viene controllata eseguendo mockRepo.Verify() nelle asserzioni.
  • Siano stati restituiti due oggetti Idea per la sessione.
  • L'ultimo elemento (Idea aggiunta dalla chiamata fittizia a UpdateAsync) corrisponda alla newIdea aggiunta alla sessione nel test.
[Fact]
public async Task CreateActionResult_ReturnsNewlyCreatedIdeaForSession()
{
    // Arrange
    int testSessionId = 123;
    string testName = "test name";
    string testDescription = "test description";
    var testSession = GetTestSession();
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync(testSession);
    var controller = new IdeasController(mockRepo.Object);

    var newIdea = new NewIdeaModel()
    {
        Description = testDescription,
        Name = testName,
        SessionId = testSessionId
    };
    mockRepo.Setup(repo => repo.UpdateAsync(testSession))
        .Returns(Task.CompletedTask)
        .Verifiable();

    // Act
    var result = await controller.CreateActionResult(newIdea);

    // Assert
    var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
    var createdAtActionResult = Assert.IsType<CreatedAtActionResult>(actionResult.Result);
    var returnValue = Assert.IsType<BrainstormSession>(createdAtActionResult.Value);
    mockRepo.Verify();
    Assert.Equal(2, returnValue.Ideas.Count());
    Assert.Equal(testName, returnValue.Ideas.LastOrDefault().Name);
    Assert.Equal(testDescription, returnValue.Ideas.LastOrDefault().Description);
}

I controller hanno un ruolo centrale in qualsiasi app ASP.NET Core MVC. Di conseguenza, è importante essere certi che funzionino nel modo previsto. I test automatizzati possono rilevare eventuali errori prima che l'app venga distribuita in un ambiente di produzione.

Visualizzare o scaricare il codice di esempio (procedura per il download)

Unit test della logica dei controller

Gli unit test implicano l'esecuzione di test su una parte di un'app isolandola dall'infrastruttura e dalle dipendenze. Quando si sottopone a unit test la logica del controller, si verificano solo i contenuti di una singola azione e non il comportamento delle relative dipendenze o del framework.

Configurare unit test delle azioni del controller per concentrare l'attenzione sul comportamento del controller. Uno unit test del controller evita scenari come filtri, routing e associazione di modelli. I test che verificano le interazioni tra i componenti che collettivamente rispondono a una richiesta vengono gestiti dai test di integrazione. Per altre informazioni sui test di integrazione, vedere Test di integrazione in ASP.NET Core.

Se si scrivono route e filtri personalizzati, sottoporli a unit test in isolamento e non durante i test relativi a una determinata azione del controller.

Per una dimostrazione degli unit test del controller, esaminare il controller seguente nell'app di esempio. Il Home controller visualizza un elenco di sessioni di brainstorming e consente la creazione di nuove sessioni di brainstorming con una richiesta POST:

public class HomeController : Controller
{
    private readonly IBrainstormSessionRepository _sessionRepository;

    public HomeController(IBrainstormSessionRepository sessionRepository)
    {
        _sessionRepository = sessionRepository;
    }

    public async Task<IActionResult> Index()
    {
        var sessionList = await _sessionRepository.ListAsync();

        var model = sessionList.Select(session => new StormSessionViewModel()
        {
            Id = session.Id,
            DateCreated = session.DateCreated,
            Name = session.Name,
            IdeaCount = session.Ideas.Count
        });

        return View(model);
    }

    public class NewSessionModel
    {
        [Required]
        public string SessionName { get; set; }
    }

    [HttpPost]
    public async Task<IActionResult> Index(NewSessionModel model)
    {
        if (!ModelState.IsValid)
        {
            return BadRequest(ModelState);
        }
        else
        {
            await _sessionRepository.AddAsync(new BrainstormSession()
            {
                DateCreated = DateTimeOffset.Now,
                Name = model.SessionName
            });
        }

        return RedirectToAction(actionName: nameof(Index));
    }
}

Il controller precedente:

  • Segue il principio delle dipendenze esplicite.
  • Si aspetta che l'inserimento delle dipendenze fornisca un'istanza di IBrainstormSessionRepository.
  • Può essere testato con un servizio IBrainstormSessionRepository fittizio tramite il framework di un oggetto fittizio, ad esempio Moq. Un oggetto fittizio è un oggetto creato con un set predeterminato di comportamenti di proprietà e metodi usati per il testing. Per altre informazioni, vedere Introduction to integration tests (Introduzione ai test di integrazione).

Il metodo HTTP GET Index non dispone di cicli o rami e chiama un solo metodo. Lo unit test per questa azione:

  • Simula il servizio IBrainstormSessionRepository usando il metodo GetTestSessions. GetTestSessions crea due sessioni di brainstorming fittizie con date e nomi di sessione.
  • Esegue il metodo Index.
  • Rende le asserzioni sul risultato restituito dal metodo :
[Fact]
public async Task Index_ReturnsAViewResult_WithAListOfBrainstormSessions()
{
    // Arrange
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.ListAsync())
        .ReturnsAsync(GetTestSessions());
    var controller = new HomeController(mockRepo.Object);

    // Act
    var result = await controller.Index();

    // Assert
    var viewResult = Assert.IsType<ViewResult>(result);
    var model = Assert.IsAssignableFrom<IEnumerable<StormSessionViewModel>>(
        viewResult.ViewData.Model);
    Assert.Equal(2, model.Count());
}
private List<BrainstormSession> GetTestSessions()
{
    var sessions = new List<BrainstormSession>();
    sessions.Add(new BrainstormSession()
    {
        DateCreated = new DateTime(2016, 7, 2),
        Id = 1,
        Name = "Test One"
    });
    sessions.Add(new BrainstormSession()
    {
        DateCreated = new DateTime(2016, 7, 1),
        Id = 2,
        Name = "Test Two"
    });
    return sessions;
}

I Home test del metodo del HTTP POST Index controller verificano che:

  • Quando ModelState.IsValid è false, il metodo di azione restituisce una richiesta ViewResult non valida 400 con i dati appropriati.
  • Quando ModelState.IsValid è true:
    • Venga chiamato il metodo Add sul repository.
    • Venga restituito un RedirectToActionResult con gli argomenti corretti.

È possibile eseguire il test di uno stato del modello non valido aggiungendo gli errori con AddModelError come illustrato nel primo test riportato di seguito:

[Fact]
public async Task IndexPost_ReturnsBadRequestResult_WhenModelStateIsInvalid()
{
    // Arrange
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.ListAsync())
        .ReturnsAsync(GetTestSessions());
    var controller = new HomeController(mockRepo.Object);
    controller.ModelState.AddModelError("SessionName", "Required");
    var newSession = new HomeController.NewSessionModel();

    // Act
    var result = await controller.Index(newSession);

    // Assert
    var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
    Assert.IsType<SerializableError>(badRequestResult.Value);
}

[Fact]
public async Task IndexPost_ReturnsARedirectAndAddsSession_WhenModelStateIsValid()
{
    // Arrange
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.AddAsync(It.IsAny<BrainstormSession>()))
        .Returns(Task.CompletedTask)
        .Verifiable();
    var controller = new HomeController(mockRepo.Object);
    var newSession = new HomeController.NewSessionModel()
    {
        SessionName = "Test Name"
    };

    // Act
    var result = await controller.Index(newSession);

    // Assert
    var redirectToActionResult = Assert.IsType<RedirectToActionResult>(result);
    Assert.Null(redirectToActionResult.ControllerName);
    Assert.Equal("Index", redirectToActionResult.ActionName);
    mockRepo.Verify();
}

Quando ModelState non è valido, viene restituito lo stesso ViewResult come per una richiesta GET. Il test non prova a passare un modello non valido. Il passaggio di un modello non valido non è un approccio corretto, in quanto l'associazione di modelli non è in esecuzione (benché un test di integrazione usi invece l'associazione di modelli). In questo caso l'associazione di modelli non viene testata. Questi unit test testano solo il codice del metodo di azione.

Il secondo test verifica che quando ModelState è valido:

  • Venga aggiunto un nuovo elemento BrainstormSession (tramite il repository).
  • Il metodo restituisca un RedirectToActionResult con le proprietà previste.

Le chiamate fittizie che non vengono eseguite sono in genere ignorate, ma la chiamata di Verifiable al termine della chiamata setup consente la convalida fittizia nel test. Questa operazione viene eseguita con la chiamata a mockRepo.Verify, che non supera il test se non è stato chiamato il metodo previsto.

Nota

La libreria Moq usata in questo esempio consente la combinazione di simulazioni verificabili o "rigide" con simulazioni non verificabili (dette anche simulazioni "generiche" o stub "generici"). Altre informazioni sulla personalizzazione del comportamento di simulazione con Moq.

SessionController nell'app di esempio visualizza informazioni correlate a una sessione di brainstorming specifica. Il controller include la logica per gestire i valori id non validi (nell'esempio seguente sono riportati due scenari return in proposito). L'istruzione finale return restituisce un nuovo StormSessionViewModel oggetto alla visualizzazione (Controllers/SessionController.cs):

public class SessionController : Controller
{
    private readonly IBrainstormSessionRepository _sessionRepository;

    public SessionController(IBrainstormSessionRepository sessionRepository)
    {
        _sessionRepository = sessionRepository;
    }

    public async Task<IActionResult> Index(int? id)
    {
        if (!id.HasValue)
        {
            return RedirectToAction(actionName: nameof(Index), 
                controllerName: "Home");
        }

        var session = await _sessionRepository.GetByIdAsync(id.Value);
        if (session == null)
        {
            return Content("Session not found.");
        }

        var viewModel = new StormSessionViewModel()
        {
            DateCreated = session.DateCreated,
            Name = session.Name,
            Id = session.Id
        };

        return View(viewModel);
    }
}

Gli unit test includono un test per ogni scenario return nell'azione Index del controller Session:

[Fact]
public async Task IndexReturnsARedirectToIndexHomeWhenIdIsNull()
{
    // Arrange
    var controller = new SessionController(sessionRepository: null);

    // Act
    var result = await controller.Index(id: null);

    // Assert
    var redirectToActionResult = 
        Assert.IsType<RedirectToActionResult>(result);
    Assert.Equal("Home", redirectToActionResult.ControllerName);
    Assert.Equal("Index", redirectToActionResult.ActionName);
}

[Fact]
public async Task IndexReturnsContentWithSessionNotFoundWhenSessionNotFound()
{
    // Arrange
    int testSessionId = 1;
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync((BrainstormSession)null);
    var controller = new SessionController(mockRepo.Object);

    // Act
    var result = await controller.Index(testSessionId);

    // Assert
    var contentResult = Assert.IsType<ContentResult>(result);
    Assert.Equal("Session not found.", contentResult.Content);
}

[Fact]
public async Task IndexReturnsViewResultWithStormSessionViewModel()
{
    // Arrange
    int testSessionId = 1;
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync(GetTestSessions().FirstOrDefault(
            s => s.Id == testSessionId));
    var controller = new SessionController(mockRepo.Object);

    // Act
    var result = await controller.Index(testSessionId);

    // Assert
    var viewResult = Assert.IsType<ViewResult>(result);
    var model = Assert.IsType<StormSessionViewModel>(
        viewResult.ViewData.Model);
    Assert.Equal("Test One", model.Name);
    Assert.Equal(2, model.DateCreated.Day);
    Assert.Equal(testSessionId, model.Id);
}

Passando al controller Ideas, l'app espone la funzionalità come API Web sulla route api/ideas:

  • Il metodo ForSession restituisce un elenco di idee (IdeaDTO) associate a una sessione di brainstorming.
  • Il metodo Create aggiunge nuove idee a una sessione.
[HttpGet("forsession/{sessionId}")]
public async Task<IActionResult> ForSession(int sessionId)
{
    var session = await _sessionRepository.GetByIdAsync(sessionId);
    if (session == null)
    {
        return NotFound(sessionId);
    }

    var result = session.Ideas.Select(idea => new IdeaDTO()
    {
        Id = idea.Id,
        Name = idea.Name,
        Description = idea.Description,
        DateCreated = idea.DateCreated
    }).ToList();

    return Ok(result);
}

[HttpPost("create")]
public async Task<IActionResult> Create([FromBody]NewIdeaModel model)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }

    var session = await _sessionRepository.GetByIdAsync(model.SessionId);
    if (session == null)
    {
        return NotFound(model.SessionId);
    }

    var idea = new Idea()
    {
        DateCreated = DateTimeOffset.Now,
        Description = model.Description,
        Name = model.Name
    };
    session.AddIdea(idea);

    await _sessionRepository.UpdateAsync(session);

    return Ok(session);
}

Evitare di restituire direttamente le entità di dominio aziendali tramite chiamate API. Le entità di dominio infatti:

  • Spesso includono più dati di quelli richiesti dal client.
  • Associano senza necessità il modello di dominio interno dell'app all'API esposta pubblicamente.

Il mapping tra le entità di dominio e i tipi restituiti al client può essere eseguito:

L'app di esempio esegue quindi una dimostrazione degli unit test per i metodi API Create e ForSession del controller Ideas.

L' app di esempio contiene due test ForSession. Il primo determina se ForSession restituisce un NotFoundObjectResult (HTTP non trovato) per una sessione non valida:

[Fact]
public async Task ForSession_ReturnsHttpNotFound_ForInvalidSession()
{
    // Arrange
    int testSessionId = 123;
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync((BrainstormSession)null);
    var controller = new IdeasController(mockRepo.Object);

    // Act
    var result = await controller.ForSession(testSessionId);

    // Assert
    var notFoundObjectResult = Assert.IsType<NotFoundObjectResult>(result);
    Assert.Equal(testSessionId, notFoundObjectResult.Value);
}

Il secondo test ForSession determina se ForSession restituisce un elenco di idee (<List<IdeaDTO>>) per una sessione valida. I controlli esaminano anche la prima idea per verificare che la relativa proprietà Name sia corretta:

[Fact]
public async Task ForSession_ReturnsIdeasForSession()
{
    // Arrange
    int testSessionId = 123;
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync(GetTestSession());
    var controller = new IdeasController(mockRepo.Object);

    // Act
    var result = await controller.ForSession(testSessionId);

    // Assert
    var okResult = Assert.IsType<OkObjectResult>(result);
    var returnValue = Assert.IsType<List<IdeaDTO>>(okResult.Value);
    var idea = returnValue.FirstOrDefault();
    Assert.Equal("One", idea.Name);
}

Per testare il comportamento del metodo Create quando ModelState non è valido, l'app di esempio aggiunge un errore del modello al controller come parte del test. Non provare a testare la convalida del modello o l'associazione di modelli negli unit test: è sufficiente testare il comportamento del metodo di azione in caso di confronto con un oggetto non valido ModelState:

[Fact]
public async Task Create_ReturnsBadRequest_GivenInvalidModel()
{
    // Arrange & Act
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    var controller = new IdeasController(mockRepo.Object);
    controller.ModelState.AddModelError("error", "some error");

    // Act
    var result = await controller.Create(model: null);

    // Assert
    Assert.IsType<BadRequestObjectResult>(result);
}

Il secondo test di Create dipende dal fatto che il repository restituisca null, pertanto il repository fittizio è configurato per restituire null. Non è necessario creare un database di test (in memoria o con un altro approccio) e creare una query che restituisca questo risultato. Il test può essere eseguito in una singola istruzione, come illustrato dal codice di esempio:

[Fact]
public async Task Create_ReturnsHttpNotFound_ForInvalidSession()
{
    // Arrange
    int testSessionId = 123;
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync((BrainstormSession)null);
    var controller = new IdeasController(mockRepo.Object);

    // Act
    var result = await controller.Create(new NewIdeaModel());

    // Assert
    Assert.IsType<NotFoundObjectResult>(result);
}

Il terzo test di Create, Create_ReturnsNewlyCreatedIdeaForSession, verifica che venga chiamato il metodo UpdateAsync del repository. La simulazione viene chiamata con Verifiable e quindi viene chiamato il metodo Verify del repository fittizio per confermare che il metodo verificabile sia stato eseguito. Non è responsabilità dello unit test assicurarsi che il UpdateAsync metodo abbia salvato i dati, che possono essere eseguiti con un test di integrazione.

[Fact]
public async Task Create_ReturnsNewlyCreatedIdeaForSession()
{
    // Arrange
    int testSessionId = 123;
    string testName = "test name";
    string testDescription = "test description";
    var testSession = GetTestSession();
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync(testSession);
    var controller = new IdeasController(mockRepo.Object);

    var newIdea = new NewIdeaModel()
    {
        Description = testDescription,
        Name = testName,
        SessionId = testSessionId
    };
    mockRepo.Setup(repo => repo.UpdateAsync(testSession))
        .Returns(Task.CompletedTask)
        .Verifiable();

    // Act
    var result = await controller.Create(newIdea);

    // Assert
    var okResult = Assert.IsType<OkObjectResult>(result);
    var returnSession = Assert.IsType<BrainstormSession>(okResult.Value);
    mockRepo.Verify();
    Assert.Equal(2, returnSession.Ideas.Count());
    Assert.Equal(testName, returnSession.Ideas.LastOrDefault().Name);
    Assert.Equal(testDescription, returnSession.Ideas.LastOrDefault().Description);
}

Testare ActionResult<T>

In ASP.NET Core 2.1 o versioni successive ActionResult<T> (ActionResult<TValue>) consente di restituire un tipo che deriva da ActionResult o di restituire un tipo specifico.

L'app di esempio include un metodo che restituisce un List<IdeaDTO> per un determinato id di sessione. Se l'id di sessione non esiste, il controller restituisce NotFound:

[HttpGet("forsessionactionresult/{sessionId}")]
[ProducesResponseType(200)]
[ProducesResponseType(404)]
public async Task<ActionResult<List<IdeaDTO>>> ForSessionActionResult(int sessionId)
{
    var session = await _sessionRepository.GetByIdAsync(sessionId);

    if (session == null)
    {
        return NotFound(sessionId);
    }

    var result = session.Ideas.Select(idea => new IdeaDTO()
    {
        Id = idea.Id,
        Name = idea.Name,
        Description = idea.Description,
        DateCreated = idea.DateCreated
    }).ToList();

    return result;
}

In ApiIdeasControllerTests sono inclusi due test del controller ForSessionActionResult.

Il primo test verifica che il controller restituisca un ActionResult ma non un elenco non esistente di idee per un id di sessione inesistente:

[Fact]
public async Task ForSessionActionResult_ReturnsNotFoundObjectResultForNonexistentSession()
{
    // Arrange
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    var controller = new IdeasController(mockRepo.Object);
    var nonExistentSessionId = 999;

    // Act
    var result = await controller.ForSessionActionResult(nonExistentSessionId);

    // Assert
    var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result);
    Assert.IsType<NotFoundObjectResult>(actionResult.Result);
}

Per un id di sessione valido, il secondo test verifica che il metodo restituisca:

  • Il metodo restituisca un ActionResult con un tipo List<IdeaDTO>.
  • ActionResult<T>.Value sia di tipo List<IdeaDTO>.
  • Il primo elemento nell'elenco sia un'idea valida corrispondente all'idea archiviata nella sessione fittizia (ottenuta chiamando GetTestSession).
[Fact]
public async Task ForSessionActionResult_ReturnsIdeasForSession()
{
    // Arrange
    int testSessionId = 123;
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync(GetTestSession());
    var controller = new IdeasController(mockRepo.Object);

    // Act
    var result = await controller.ForSessionActionResult(testSessionId);

    // Assert
    var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result);
    var returnValue = Assert.IsType<List<IdeaDTO>>(actionResult.Value);
    var idea = returnValue.FirstOrDefault();
    Assert.Equal("One", idea.Name);
}

L'app di esempio include anche un metodo per creare una nuova Idea per una determinata sessione. Il controller restituisce:

[HttpPost("createactionresult")]
[ProducesResponseType(201)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<ActionResult<BrainstormSession>> CreateActionResult([FromBody]NewIdeaModel model)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }

    var session = await _sessionRepository.GetByIdAsync(model.SessionId);

    if (session == null)
    {
        return NotFound(model.SessionId);
    }

    var idea = new Idea()
    {
        DateCreated = DateTimeOffset.Now,
        Description = model.Description,
        Name = model.Name
    };
    session.AddIdea(idea);

    await _sessionRepository.UpdateAsync(session);

    return CreatedAtAction(nameof(CreateActionResult), new { id = session.Id }, session);
}

In ApiIdeasControllerTests sono inclusi tre test di CreateActionResult.

Il primo test verifica che per un modello non valido venga restituito un elemento BadRequest.

[Fact]
public async Task CreateActionResult_ReturnsBadRequest_GivenInvalidModel()
{
    // Arrange & Act
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    var controller = new IdeasController(mockRepo.Object);
    controller.ModelState.AddModelError("error", "some error");

    // Act
    var result = await controller.CreateActionResult(model: null);

    // Assert
    var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
    Assert.IsType<BadRequestObjectResult>(actionResult.Result);
}

Il secondo test verifica che venga restituito un elemento NotFound se la sessione non esiste.

[Fact]
public async Task CreateActionResult_ReturnsNotFoundObjectResultForNonexistentSession()
{
    // Arrange
    var nonExistentSessionId = 999;
    string testName = "test name";
    string testDescription = "test description";
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    var controller = new IdeasController(mockRepo.Object);

    var newIdea = new NewIdeaModel()
    {
        Description = testDescription,
        Name = testName,
        SessionId = nonExistentSessionId
    };

    // Act
    var result = await controller.CreateActionResult(newIdea);

    // Assert
    var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
    Assert.IsType<NotFoundObjectResult>(actionResult.Result);
}

Per un id di sessione valido, il test finale verifica che:

  • Il metodo restituisca un ActionResult di tipo BrainstormSession.
  • ActionResult<T>.Result sia di tipo CreatedAtActionResult. CreatedAtActionResult è analogo a una risposta 201 - Creato con un'intestazione Location.
  • ActionResult<T>.Value sia di tipo BrainstormSession.
  • La chiamata fittizia per aggiornare la sessione, UpdateAsync(testSession), sia stata eseguita. La chiamata del metodo Verifiable viene controllata eseguendo mockRepo.Verify() nelle asserzioni.
  • Siano stati restituiti due oggetti Idea per la sessione.
  • L'ultimo elemento (Idea aggiunta dalla chiamata fittizia a UpdateAsync) corrisponda alla newIdea aggiunta alla sessione nel test.
[Fact]
public async Task CreateActionResult_ReturnsNewlyCreatedIdeaForSession()
{
    // Arrange
    int testSessionId = 123;
    string testName = "test name";
    string testDescription = "test description";
    var testSession = GetTestSession();
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync(testSession);
    var controller = new IdeasController(mockRepo.Object);

    var newIdea = new NewIdeaModel()
    {
        Description = testDescription,
        Name = testName,
        SessionId = testSessionId
    };
    mockRepo.Setup(repo => repo.UpdateAsync(testSession))
        .Returns(Task.CompletedTask)
        .Verifiable();

    // Act
    var result = await controller.CreateActionResult(newIdea);

    // Assert
    var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
    var createdAtActionResult = Assert.IsType<CreatedAtActionResult>(actionResult.Result);
    var returnValue = Assert.IsType<BrainstormSession>(createdAtActionResult.Value);
    mockRepo.Verify();
    Assert.Equal(2, returnValue.Ideas.Count());
    Assert.Equal(testName, returnValue.Ideas.LastOrDefault().Name);
    Assert.Equal(testDescription, returnValue.Ideas.LastOrDefault().Description);
}

Risorse aggiuntive