How to unit test whether a Core MVC controller action calls ControllerBase.Problem()
Asked Answered
S

4

11

We have a controller that derives from ControllerBase with an action like this:

public async Task<ActionResult> Get(int id)
{
  try
  {
    // Logic
    return Ok(someReturnValue);
  }
  catch
  {
    return Problem();
  }
}

We also have a unit test like this:

[TestMethod]
public async Task GetCallsProblemOnInvalidId()
{
  var result = sut.Get(someInvalidId);

}

But ControllerBase.Problem() throws a Null Reference Exception. This is a method from the Core MVC framework, so I don't realy know why it is throwing the error. I think it may be because HttpContext is null, but I'm not sure. Is there a standardized way to test a test case where the controller should call Problem()? Any help is appreciated. If the answer involves mocking: we use Moq and AutoFixtrue.

Siesta answered 18/3, 2020 at 12:59 Comment(0)
H
12

The null exception is because of a missing ProblemDetailsFactory

In this case the controller needs to be able to create ProblemDetails instance via

[NonAction]
public virtual ObjectResult Problem(
    string detail = null,
    string instance = null,
    int? statusCode = null,
    string title = null,
    string type = null)
{
    var problemDetails = ProblemDetailsFactory.CreateProblemDetails(
        HttpContext,
        statusCode: statusCode ?? 500,
        title: title,
        type: type,
        detail: detail,
        instance: instance);

    return new ObjectResult(problemDetails)
    {
        StatusCode = problemDetails.Status
    };
}

Source

ProblemDetailsFactory is a settable property

public ProblemDetailsFactory ProblemDetailsFactory
{
    get
    {
        if (_problemDetailsFactory == null)
        {
            _problemDetailsFactory = HttpContext?.RequestServices?.GetRequiredService<ProblemDetailsFactory>();
        }

        return _problemDetailsFactory;
    }
    set
    {
        if (value == null)
        {
            throw new ArgumentNullException(nameof(value));
        }

        _problemDetailsFactory = value;
    }
}

Source

that could be mocked and populated when testing in isolation.

[TestMethod]
public async Task GetCallsProblemOnInvalidId() {
    //Arrange
    var problemDetails = new ProblemDetails() {
        //...populate as needed
    };
    var mock = new Mock<ProblemDetailsFactory>();
    mock
        .Setup(_ => _.CreateProblemDetails(
            It.IsAny<HttpContext>(),
            It.IsAny<int?>(),
            It.IsAny<string>(),
            It.IsAny<string>(),
            It.IsAny<string>(),
            It.IsAny<string>())
        )
        .Returns(problemDetails)
        .Verifyable();

    var sut = new MyController(...);
    sut.ProblemDetailsFactory = mock.Object;

    //...

    //Act
    var result = await sut.Get(someInvalidId);

    //Assert
    mock.Verify();//verify setup(s) invoked as expected

    //...other assertions
}
Heathenish answered 19/3, 2020 at 12:6 Comment(0)
H
2

I came to this question via the related issue: https://github.com/dotnet/aspnetcore/issues/15166

Nkosi correctly pointed to the background ProblemDetailsFactory.

Note that the issue has been fixed in .NET 5.x but NOT in LTS .NET 3.1.x as you can see in the source code referenced by Nkosi (by switching the branches/tags in Github)

As Nkosi said, the trick is to set the ProblemDetailsFactory property of your controller in your unit tests. Nkosi suggested to mock the ProblemDetailsFactory, but doing as above, you can't verify the values of the Problem object in your unit tests. An alternative is simply to set a real implementation of the ProblemDetailsFactory, for instance copy the DefaultProblemDetailsFactory from Microsoft (internal class) to your UnitTest projects: https://github.com/dotnet/aspnetcore/blob/main/src/Mvc/Mvc.Core/src/Infrastructure/DefaultProblemDetailsFactory.cs Get rid of the options parameter there. Then just set an instance of it in the controller in your unit test and see the returned object as expected!

Hitandrun answered 28/4, 2021 at 13:6 Comment(0)
R
1

To improve upon EricBDev's answer (to avoid having to create any implementation of ProblemsDetailFactory in your tests) and Nkosi's answer (to allow verifying the values used when creating the Problem), you can mock the ProblemsDetailFactory to return an empty ProblemsDetail (to avoid NRE) and then verify the calls to the mocked factory, to make sure the right status code, details, etc. are passed to it by the code under test.

Example: (using Moq)

// create the mock `ProblemDetailsFactory`
var problemDetailsFactoryMock = new Mock<ProblemDetailsFactory>();
// set it up to return an empty `Problems` object (to avoid the `NullReferenceException`s)
problemDetailsFactoryMock.Setup(p =>
    p.CreateProblemDetails(
        It.IsAny<HttpContext>(),
        It.IsAny<int>(),     // statusCode
        It.IsAny<string>(),  // title
        It.IsAny<string>(),  // type
        It.IsAny<string>(),  // detail
        It.IsAny<string>())  // instance
    ).Returns(new ProblemDetails());

// your other test code here

// verify the arguments passed to `Problem(...)`
_problemDetailsFactoryMock.Verify(p =>
    p.CreateProblemDetails(
        It.IsAny<HttpContext>(),
        (int)HttpStatusCode.Forbidden,  // or whatever StatusCode you expect
        default,                        // or whatever you expect for `Title`
        default,                        // or whatever you expect for `Type`
        It.Is<string>(s => s.Contains("whatever you expect in the Detail", StringComparison.OrdinalIgnoreCase)),
        default                         // or whatever you expect for `Instance`
    ));
Rolypoly answered 27/12, 2022 at 20:28 Comment(0)
K
-1

In your tests, if you first create a ControllerContext, then ProblemDetails should be created as expected while executing controller code.

...
MyController controller;

[Setup]
public void Setup()
{
    controller = new MyController();
    controller.ControllerContext = new ControllerContext
    {
        HttpContext = new DefaultHttpContext
        {
            // add other mocks or fakes 
        }
    };
}
...
Kokand answered 21/7, 2020 at 18:4 Comment(1)
According to /// documentation HttpContext "Encapsulates all HTTP-specific information about an individual HTTP request.". How does this help supplying a ProblemDetailsFactory? For me this did not solve this issue.Topi

© 2022 - 2024 — McMap. All rights reserved.