How do I test a controller method returning ActionResult<T> or Task<ActionResult<T>> in 2.1?
Thanks
⚠Do not edit this section. It is required for docs.microsoft.com ➟ GitHub issue linking.
Hello @mberginbh ... I've updated the sample tracking update issue to see what we can do about the gap in coverage when this topic and the sample app are addressed on that issue. In the meantime (and in addition to my remarks below :point_down:), I recommend that you engage with the devs on a support forum, such as Stack Overflow, support chat, such as Slack or Gitter, and access blogs on the subject until we look at how to cover it.
RE: Integration Tests
The integration tests topic was recently updated to use the latest testing approach offered by the engineers, WebApplicationFactory
and the Microsoft.AspNetCore.Mvc.Testing
package. Although we don't cover it at the moment, you should be able to deserialize the response content into a type that you can assert against.
RE: Unit Tests
I created some _RexHacks_:tm: :smile: just so I could play with it for a sec.
I put an action in this test app (converted to netcoreapp2.1
so that ActionResult<T>
is available). I just have it return a new BrainstormSession
...
public ActionResult<BrainstormSession> Test()
{
var session = new BrainstormSession()
{
DateCreated = DateTimeOffset.Now,
Name = "MY SESSION"
};
return session;
}
I set up a test on that action, and all I did there is check the type and then read the session name. It passed.
// Act
var result = controller.Test();
// Assert
var brainStormSession = Assert.IsType<ActionResult<BrainstormSession>>(result);
Assert.Equal("MY SESSION", brainStormSession.Value.Name);
@scottaddie Do u have any additional tips for testing ActionResult<T>
-based actions?
@guardrex The sample you provided above reflects what I would've recommended.
Ty @scottaddie I'll get something along those lines surfaced when I perform the sample upgrade on #5495.
Thank you both. Might be worth adding an example unit test for a failure case e.g. BadRequestResult
too.
@mberginbh As far as I can tell, a BadRequestResult
won't be returned ... the ActionResult<T>
resolves to NotFoundObjectResult
when the controller is executed and returns a NotFound
on whatever non-existent ID is given to it. What we'll do is have the engineers (who really know how to write software :smile:) tell me if the test I'm setting up is sane.
Regarding the Task<ActionResult<T>>
test, which was your original suggestion :point_up:, that seems fine the way I have it set up. I think the engineers will be ok with it, but we'll find out soon enough.
I'll have this PR submitted soon ... I still need to update the topic text for the action and tests and make sure the 2.1 updates are all good. I'll ping u on the PR.
Do unit tests really change that much when using
ActionResult<T>
instead of
IActionResult
?
Doesn't
// Assert
var redirectToActionResult = Assert.IsType<RedirectToActionResult>(result);
Assert.Null(redirectToActionResult.ControllerName);
Assert.Equal("Index", redirectToActionResult.ActionName);
just become
// Assert
var redirectToActionResult = Assert.IsType<RedirectToActionResult>(result.Result);
Assert.Null(redirectToActionResult.ControllerName);
Assert.Equal("Index", redirectToActionResult.ActionName);
?
Or am i doing it wrong?
@pascalpfeil
Those would be fine for integration tests. This topic focuses on unit tests. In a unit test, I'd like to confirm the following ...
ActionResult
?ActionResult
's value the correct type?... then when the ActionResult
comes back with no objects, I want to know ...
ActionResult
?ActionResult
a NotFoundObjectResult
?Take a look at the tests I did for the PR: https://github.com/aspnet/Docs/blob/74ba2ecf00796a1ac7589320a8fc5ddf1e053308/aspnetcore/mvc/controllers/testing/sample/TestingControllersSample/tests/TestingControllersSample.Tests/UnitTests/ApiIdeasControllerTests.cs#L134-L170
@guardrex thank you for your response. That cleared things up.
But how would i test the following code?
public async Task<ActionResult<Entity>> PostEntity(/* ... */)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
try
{
var entity = await _repo.CreateEntity(/* ... */);
return CreatedAtAction("GetEntity", new { id = entity.Id }, entity);
}
/* ... */
}
My Test with Microsoft.VisualStudio.TestTools.UnitTesting
looks something like this:
[TestMethod]
public async Task TestPostEntity_OK()
{
//Arrange
var entity= new Entity { /* ... */ };
mockRepo.Setup(repo=> repo.CreateEntity(/* ...*/)).Returns(Task.FromResult(entity));
//Act
var result = await sutController.PostEntity(/* ...*/);
//Assert
//This works
Assert.IsInstanceOfType(result, typeof(ActionResult<Entity>));
//This fails, entity is null
Assert.AreEqual(entity, result.Value);
}
As you can see, the result.Value
property stays null, while your actionResult.Value
in line 148 is populated.
Not sure ... what is result.Result
when result.Value
is null
?
It is a CreatedAtActionResult
whose object Value
property is entity
. This must be because i do return CreatedAtAction(/* ... */)
instead of return entity
, right? What's the right way to unit test this?
I understand you want to return a 201 with your POST. A CreatedAtActionResult
is something that we may need to add to the examples. Thus far, the example just returns a list of IdeoDTO
objects ...
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;
}
I need to step out for a couple of hours, but I'll be back later today. I'll see what I can do with the sample app (and PR) and then report back to you. I'm new to ActionResult<T>
and testing ActionResult<T>
myself, so I'll need to work through it and see what's what. I'll get back to you in a couple of hours.
Thank you very much for your time!
@pascalpfeil I think this worked out fairly well so far. I created a method to receive a POST, lookup a session, add a new idea to it, and then return the session. It's nearly a copy of the existing method (Create
) that was already in the topic sample ...
[HttpPost("createactionresult")]
[ProducesResponseType(201)]
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 session;
}
Then, I based three tests on it ...
[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))
.Returns(Task.FromResult(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 returnValue = Assert.IsType<BrainstormSession>(actionResult.Value);
mockRepo.Verify();
Assert.Equal(2, returnValue.Ideas.Count());
Assert.Equal(testName, returnValue.Ideas.LastOrDefault().Name);
Assert.Equal(testDescription, returnValue.Ideas.LastOrDefault().Description);
}
[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);
}
[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);
}
... and those tests pass.
Before going any further and adding text to the topic in the PR, I'd like to wait until @scottaddie gets back (he's OOF for a bit) so that we can discuss further the 200/201/400 situation.
I added the ProducesResponseType
decorations ...
[ProducesResponseType(200)]
... and ...
[ProducesResponseType(201)]
The question is are those obtainable in the tests when the methods fully execute and return a valid result?
The ActionResult<T>.Result
only appears to receive a value when the method returns a non-200/201.
The tests with the failure assertions get the right types checked with ...
Assert.IsType<NotFoundObjectResult>(actionResult.Result);
... and ...
Assert.IsType<BadRequestObjectResult>(actionResult.Result);
... but I am unclear on when ActionResult<T>.Value
receives a value and the result is a 200/201 how to test for a 200/201 status code (e.g., ControllerBase.Ok
).
I don't think
[HttpPost("createactionresult")]
[ProducesResponseType(201)]
public async Task<ActionResult<BrainstormSession>> CreateActionResult([FromBody]NewIdeaModel model)
{
/* ... */
return session;
}
will actually return 201 but will return 200.
As shown here you'd have to return CreatedAtAction(/* ... */)
to get a 201. Or am i wrong?
Yes, I copied the original test too closely. I'll mirror the one for async ActionResult<T>
that Scott placed here ...
https://docs.microsoft.com/aspnet/core/web-api/action-return-types#asynchronous-action-1
CreatedAtAction
did produce the desired ActionResult<T>.Result
of CreatedAtActionResult
and ...
Assert.IsType<CreatedAtActionResult>(actionResult.Result);
... passes. However, now ActionResult<T>.Value
is null
. Again, .Value
and .Result
never seem to be populated simultaneously.
@guardrex
Yes, this is what i oberserved, too. So in unit tests, you would just check the CreatedAtActionResult.Value
property, right?
_Yes!_ Now, we're cook'in. This works ...
// 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);
... and now, I can set the other response types on the method ...
[ProducesResponseType(400)]
[ProducesResponseType(404)]
... and check for the status codes. For example for Not Found ...
// Assert
var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
var createdAtActionResult = Assert.IsType<NotFoundObjectResult>(actionResult.Result);
Assert.Equal(404, createdAtActionResult.StatusCode);
_"I love it when a plan comes together!"_ - John "Hannibal" Smith (George Peppard), The A-Team, ©1983-87 Universal Television Stephen J. Cannell
Shouldn't it be
// Assert
var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result);
var notFoundObjectResult= Assert.IsType<NotFoundObjectResult>(actionResult.Result);
Assert.Equal(404, notFoundObjectResult.StatusCode);
?
And aren't those two lines redundant?
var createdAtActionResult = Assert.IsType<NotFoundObjectResult>(actionResult.Result);
Assert.Equal(404, createdAtActionResult.StatusCode);
Also nice meme :ok_hand:
aren't those two lines redundant?
Probably ... I just wanted to see the status code. There are two approaches, and the first one I had is better (shorter) ...
Assert.IsType<NotFoundObjectResult>(actionResult.Result);
I note that I didn't have to have the ProducesResponseType
s to get those to pass either. Anyway, I'll leave the ProducesResponseType
s on the method.
Still wish I could get an OkObjectResult
out of the return
...
// Assert
var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result);
Assert.IsType<OkObjectResult>(actionResult.Result);
var returnValue = Assert.IsType<List<IdeaDTO>>(actionResult.Value);
var idea = returnValue.FirstOrDefault();
Assert.Equal("One", idea.Name);
ActonResult<T>.Result
is null
, so that doesn't work.
[EDIT] Just tacking on to the discussion here that the engineer said that OkObjectResult
can't be tested under this scenario where the method succeeds and returns the list. In this case, we'll just check the type and a test item. If the test succeeds, we assume a 200 Ok is the HTTP response.
_Yes!_ Now, we're cook'in. This works ...
// 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);
@guardrex your solution just saved me from hours of struggle. Thanks :)
Still wish I could get an
OkObjectResult
out of thereturn
...// Assert var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result); Assert.IsType<OkObjectResult>(actionResult.Result); var returnValue = Assert.IsType<List<IdeaDTO>>(actionResult.Value); var idea = returnValue.FirstOrDefault(); Assert.Equal("One", idea.Name);
ActonResult<T>.Result
isnull
, so that doesn't work.[EDIT] Just tacking on to the discussion here that the engineer said that
OkObjectResult
can't be tested under this scenario where the method succeeds and returns the list. In this case, we'll just check the type and a test item. If the test succeeds, we assume a _200 Ok_ is the HTTP response.
I was able to gain access to the OkObjectResult.Value
in .NET Core 2.2 using xUnit. Not sure which testing framework you are using or if that matters here. Anyways, I was having trouble accessing the returned IEnumerable<T>
until I set var okObjectResult = Assert.IsType<OkObjectResult>(...)
.
The below is an example of a working test that accesses the value of the OkObjectResult
:
// Arrange
var controller = new AircraftStatusController(_context);
// Act
var actionResult = await controller.GetAircraftStatuses();
// Assert
Assert.IsType<ActionResult<IEnumerable<ObstAircraftStatus>>>(actionResult);
var okObjectResult = Assert.IsType<OkObjectResult>(actionResult.Result);
var returnValue = Assert.IsAssignableFrom<IEnumerable<ObstAircraftStatus>>(okObjectResult.Value).ToList();
Assert.NotEmpty(returnValue);
// Included this for fun...
Assert.True(returnValue.Count == _context.ObstAircraftStatus.ToList().Count);
I found some tests I wrote in a .NET Core 1.1 project that also make use of the OkObjectResult.Value
. Dont judge, it was early in my testing education :)
Other example of accessing this value:
// Arrange
var controller = _fixture.GetAircraftController(true);
// Act
var objectResult = controller.GetAll().Result as OkObjectResult;
var enumerable = objectResult?.Value as IEnumerable<SharedModels.Aircraft>;
var result = enumerable?.ToList();
// Assert
Assert.NotNull(result);
Assert.True(_fixture.AircraftCdi.Any());
Assert.True(result.Count == _fixture.Aircraft.Count);
Most helpful comment
@guardrex your solution just saved me from hours of struggle. Thanks :)