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 metodoGetTestSessions
.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 :
- Viene restituito un ViewResult.
- ViewDataDictionary.Model è di tipo
StormSessionViewModel
. - Nel
ViewDataDictionary.Model
sono archiviate due sessioni di brainstorming.
[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.
- Venga chiamato il metodo
È 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:
- Manualmente con un'operazione LINQ
Select
, come avviene nell'app di esempio. Per altre informazioni, vedere LINQ (Language-Integrated Query). - Automaticamente con una libreria, come AutoMapper.
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:
- Il tipo di
ActionResult
èActionResult<List<IdeaDTO>>
. - Result è di tipo NotFoundObjectResult.
[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 tipoList<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:
- BadRequest per un modello non valido.
- NotFound se la sessione non esiste.
- CreatedAtAction quando la sessione viene aggiornata con la nuova idea.
[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 tipoBrainstormSession
. - ActionResult<T>.Result sia di tipo CreatedAtActionResult.
CreatedAtActionResult
è analogo a una risposta 201 - Creato con un'intestazioneLocation
. - ActionResult<T>.Value sia di tipo
BrainstormSession
. - La chiamata fittizia per aggiornare la sessione,
UpdateAsync(testSession)
, sia stata eseguita. La chiamata del metodoVerifiable
viene controllata eseguendomockRepo.Verify()
nelle asserzioni. - Siano stati restituiti due oggetti
Idea
per la sessione. - L'ultimo elemento (
Idea
aggiunta dalla chiamata fittizia aUpdateAsync
) corrisponda allanewIdea
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 metodoGetTestSessions
.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 :
- Viene restituito un ViewResult.
- ViewDataDictionary.Model è di tipo
StormSessionViewModel
. - Nel
ViewDataDictionary.Model
sono archiviate due sessioni di brainstorming.
[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.
- Venga chiamato il metodo
È 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:
- Manualmente con un'operazione LINQ
Select
, come avviene nell'app di esempio. Per altre informazioni, vedere LINQ (Language-Integrated Query). - Automaticamente con una libreria, come AutoMapper.
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:
- Il tipo di
ActionResult
èActionResult<List<IdeaDTO>>
. - Result è di tipo NotFoundObjectResult.
[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 tipoList<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:
- BadRequest per un modello non valido.
- NotFound se la sessione non esiste.
- CreatedAtAction quando la sessione viene aggiornata con la nuova idea.
[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 tipoBrainstormSession
. - ActionResult<T>.Result sia di tipo CreatedAtActionResult.
CreatedAtActionResult
è analogo a una risposta 201 - Creato con un'intestazioneLocation
. - ActionResult<T>.Value sia di tipo
BrainstormSession
. - La chiamata fittizia per aggiornare la sessione,
UpdateAsync(testSession)
, sia stata eseguita. La chiamata del metodoVerifiable
viene controllata eseguendomockRepo.Verify()
nelle asserzioni. - Siano stati restituiti due oggetti
Idea
per la sessione. - L'ultimo elemento (
Idea
aggiunta dalla chiamata fittizia aUpdateAsync
) corrisponda allanewIdea
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
- Test di integrazione in ASP.NET Core
- Creare ed eseguire unit test con Visual Studio
- MyTested.AspNetCore.Mvc - Fluent Testing Library per ASP.NET Core MVC: libreria di unit test fortemente tipizzata, che fornisce un'interfaccia fluente per il test di app MVC e API Web. (Non gestito o supportato da Microsoft).
- JustMockLite: framework fittizio per sviluppatori .NET. (Non gestito o supportato da Microsoft).