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

Viewed 2775

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.

3 Answers

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
}

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!

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 
        }
    };
}
...
Related