Nota
L'accesso a questa pagina richiede l'autorizzazione. È possibile provare ad accedere o modificare le directory.
L'accesso a questa pagina richiede l'autorizzazione. È possibile provare a modificare le directory.
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.
Test unitari dei controller
Configurare i test unitari delle azioni del controller per concentrarsi 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 nel loro insieme rispondono a una richiesta sono 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.
Visualizza o scarica il codice di esempio (come scaricare)
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'iniezione delle dipendenze (DI) fornisca un'istanza di
IBrainstormSessionRepository. - Può essere testato con un servizio
IBrainstormSessionRepositorysimulato usando un framework per oggetti mock, ad esempio Moq. Un oggetto mock è 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.
Il metodo HTTP GET Index non dispone di cicli o diramazioni e chiama un solo metodo. Lo unit test per questa azione:
- Simula il servizio
IBrainstormSessionRepositoryusando il metodoGetTestSessions.GetTestSessionscrea due sessioni di brainstorming fittizie con date e nomi delle sessioni. - Esegue il metodo
Index. - Rende le asserzioni sul risultato restituito dal metodo :
- Viene restituito un ViewResult.
-
ViewDataDictionary.Model è di tipo
StormSessionViewModel. - Nel
ViewDataDictionary.Modelsono 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;
}
Il metodo HTTP POST Index del controller Home verifica che:
- Quando ModelState.IsValid è
false, il metodo di azione restituisce un 400 Bad RequestViewResult con i dati appropriati. - Quando
ModelState.IsValidètrue:- Venga chiamato il metodo
Addsul repository. - Viene 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 il model binding non viene testato. Questi unit test si limitano a verificare il codice nel metodo action.
Il secondo test verifica che, quando il ModelState è valido:
- Venga aggiunto un nuovo elemento
BrainstormSession(tramite il repository). - Il metodo restituisce un
RedirectToActionResultcon 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 tramite la chiamata a mockRepo.Verify, che fa fallire il test se il metodo atteso non è stato chiamato.
Note
La libreria Moq usata in questo esempio consente la combinazione di simulazioni verificabili o "strict" con simulazioni non verificabili (dette anche simulazioni "loose" o stub "loose"). Altre informazioni sulla personalizzazione del comportamento dei mock con Moq.
SessionController nell'app di esempio visualizza informazioni correlate a una sessione di brainstorming specifica. Il controller include una logica per gestire valori id non validi (nell'esempio seguente sono presenti due scenari return per coprire questi casi). L'istruzione finale return restituisce un nuovo oggetto StormSessionViewModel alla vista (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);
}
}
I test unitari 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
ForSessionrestituisce un elenco di idee (IdeaDTO) associate a una sessione di brainstorming. - Il metodo
Createaggiunge 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à del dominio aziendale attraverso le API. Entità di dominio:
- Spesso includono più dati di quelli richiesti dal cliente.
- Non accoppiare inutilmente il modello di dominio interno dell'app all'API esposta al pubblico.
La mappatura tra le entità di dominio e i tipi restituiti al client può essere eseguita:
- Manualmente con una LINQ
Select, come usa l'app di esempio. Per altre informazioni, vedere LINQ (Language-Integrated Query). - Automaticamente, usando una libreria come AutoMapper.
Successivamente, l'app di esempio illustra gli unit test per i metodi Create e ForSession dell'API del controller Ideas.
L' app di esempio contiene due test ForSession. Il primo test determina se ForSession restituisce un NotFoundObjectResult (HTTP Not Found) 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 di sessione (<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 il/la ModelState non è valido/a, l'app di esempio aggiunge un errore del modello al controller come parte del test. Non provare a verificare la convalida del modello o il binding del modello negli unit test: limitaTi a verificare il comportamento del metodo di azione quando si trova di fronte a un ModelState non valido:
[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. Il mock viene chiamato con Verifiable e quindi viene chiamato il metodo Verify del repository fittizio per confermare che il metodo verificabile sia stato eseguito. Non è responsabilità di un test unitario verificare che il metodo UpdateAsync abbia salvato i dati: questo può essere verificato 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);
}
Test ActionResult<T>
ActionResult<T> (ActionResult<TValue>) può restituire un tipo derivato da ActionResult o un tipo specifico.
L'app di esempio include un metodo che restituisce un List<IdeaDTO> per una determinata sessione id. Se la sessione id 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;
}
Sono inclusi due test del controller ForSessionActionResult nel ApiIdeasControllerTests.
Il primo test conferma che il controller restituisce un ActionResult ma non un elenco inesistente di idee per una sessione inesistente id:
- Il tipo di
ActionResultèActionResult<List<IdeaDTO>>. - Il Result è un 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 una sessione valida id, il secondo test verifica che il metodo restituisca:
- Un
ActionResultcon un tipoList<IdeaDTO>. -
ActionResult<T>.Value è un tipo
List<IdeaDTO>. - Il primo elemento nell'elenco è 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);
}
Tre test di CreateActionResult sono inclusi nel/nella ApiIdeasControllerTests.
Il primo testo verifica che per un modello non valido venga restituito un 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 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 una sessione valida id, il test finale verifica che:
- Il metodo restituisca un
ActionResultdi tipoBrainstormSession. -
ActionResult<T>.Result è un CreatedAtActionResult.
CreatedAtActionResultè analogo a una risposta 201 Created con un'intestazioneLocation. -
ActionResult<T>.Value è di tipo
BrainstormSession. - La chiamata fittizia per aggiornare la sessione,
UpdateAsync(testSession), è stata invocata. La chiamata del metodoVerifiableviene controllata eseguendomockRepo.Verify()nelle asserzioni. - Per la sessione vengono restituiti due oggetti
Idea. - L'ultimo elemento (
Ideaaggiunto dalla chiamata fittizia aUpdateAsync) corrisponde allanewIdeaaggiunta 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 (come scaricare)
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 esegue il test unitario della logica del controller, si testa solo il contenuto di una singola azione, non il comportamento delle sue dipendenze né quello del framework stesso.
Configurare unit test delle azioni del controller per concentrare l'attenzione sul comportamento del controller. Un test unitario del controller esclude scenari come filtri, routing e binding del modello. 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'iniezione delle dipendenze (DI) fornisca un'istanza di
IBrainstormSessionRepository. - Può essere testato con un servizio
IBrainstormSessionRepositorysimulato usando un framework per oggetti mock, ad esempio Moq. Un oggetto mock è un oggetto creato con un set predeterminato di comportamenti di proprietà e metodi usati per il testing. Per altre informazioni, vedi Introduction to integration tests.
Il metodo HTTP GET Index non dispone di cicli o rami e chiama un solo metodo. Il test unitario per questa azione:
- Simula il servizio
IBrainstormSessionRepositoryusando il metodoGetTestSessions.GetTestSessionscrea due sessioni di brainstorming fittizie con date e nomi di sessione. - Esegue il metodo
Index. - Esegue asserzioni sul risultato restituito dal metodo:
- Viene restituito un ViewResult.
-
ViewDataDictionary.Model è di tipo
StormSessionViewModel. - Sono archiviate due sessioni di brainstorming nel
ViewDataDictionary.Model.
[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;
}
Il metodo del controller HomeHTTP POST Index verifica che:
- Quando ModelState.IsValid è
false, il metodo di azione restituisce una 400 Bad RequestViewResult con i dati appropriati. - Quando
ModelState.IsValidètrue:- Venga chiamato il metodo
Addsul 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 previsto 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 test unitari verificano solo il codice nel metodo action.
Il secondo test verifica che, quando ModelState è valido,##
- Venga aggiunto un nuovo elemento
BrainstormSession(tramite il repository). - Il metodo restituisce un
RedirectToActionResultcon le proprietà previste.
Le chiamate fittizie che non vengono eseguite sono in genere ignorate, ma chiamare Verifiable al termine della chiamata setup consente la convalida del mock nel test. Questa operazione viene eseguita tramite la chiamata a mockRepo.Verify, che fa fallire il test se il metodo atteso non è stato chiamato.
Note
La libreria Moq usata in questo esempio consente la combinazione di simulazioni verificabili o "strict" con simulazioni non verificabili (dette anche simulazioni "loose" o stub "loose"). Altre informazioni sulla personalizzazione del comportamento dei mock 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 per coprire questi scenari). 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 return scenario nell'azione del controller Session Index:
[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
ForSessionrestituisce un elenco di idee (IdeaDTO) associate a una sessione di brainstorming. - Il metodo
Createaggiunge nuove idee alla 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. Entità di dominio:
- Spesso includono più dati di quelli richiesti dal client.
- Associare 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 test determina se ForSession restituisce un NotFoundObjectResult (HTTP Not Found) 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 di sessione (<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 il 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 di altro tipo) e costruire una query che restituisca questo risultato. Il test può essere effettuato in una singola istruzione, come mostra il 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. L'oggetto fittizio viene chiamato con Verifiable, e il metodo Verify del repository simulato viene chiamato per confermare che il metodo verificabile venga eseguito. Non è responsabilità dell’unit test assicurarsi che il metodo UpdateAsync abbia salvato i dati: è un’operazione che può essere eseguita 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);
}
Test 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 una determinata sessione id. Se la sessione id 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;
}
Due test del controller ForSessionActionResult sono inclusi nel ApiIdeasControllerTests.
Il primo test verifica che il controller restituisca un ActionResult ma non un elenco non esistente di idee per una sessione inesistente id:
- Il tipo
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 una sessione valida id, il secondo test verifica che il metodo restituisca:
- Un
ActionResultcon un tipoList<IdeaDTO>. -
ActionResult<T>.Value è di tipo
List<IdeaDTO>. - Il primo elemento nell'elenco è 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);
}
Tre test di CreateActionResult sono inclusi nel ApiIdeasControllerTests.
Il primo testo conferma che per un modello non valido venga restituito 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 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
ActionResultdi tipoBrainstormSession. -
ActionResult<T>.Result sia di tipo CreatedAtActionResult.
CreatedAtActionResultè analogo a una risposta 201 Created con un headerLocation. -
ActionResult<T>.Value è un tipo
BrainstormSession. - La chiamata simulata per aggiornare la sessione,
UpdateAsync(testSession), è stata invocata. La chiamata del metodoVerifiableviene controllata eseguendomockRepo.Verify()nelle asserzioni. - Vengono restituiti due oggetti
Ideaper la sessione. - L'ultimo elemento (il
Ideaaggiunto dalla chiamata fittizia aUpdateAsync) corrisponde alnewIdeaaggiunto 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);
}
Contenuti correlati
- 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 mantenuto né supportato da Microsoft.)
- JustMockLite: framework di mocking per sviluppatori .NET. (Non gestito o supportato da Microsoft.)