Skip to main content

Command Palette

Search for a command to run...

7 Common Mistakes Unit Testing ASP.NET Core Controllers (And How to Fix Them)

Updated
10 min readView as Markdown
7 Common Mistakes Unit Testing ASP.NET Core Controllers (And How to Fix Them)

Unit testing ASP.NET Core controllers looks trivial until your first test passes for the wrong reason. You assert that an action returned Ok, the bar goes green, and six months later a refactor that quietly broke the response body sails through CI untouched. In 13+ years shipping .NET APIs I have reviewed hundreds of controller tests, and the same handful of mistakes show up again and again - most of them silent, all of them cheap to fix once you have seen them.

This guide walks through the seven I run into most, with the broken pattern and the corrected one side by side. If you want the full picture - every one of these tests wired into a running API with a real service layer, Moq mocks, and integration tests sitting right next to them - the complete annotated codebase lives on Patreon, ready to clone and adapt.

Getting these tests right also means knowing exactly where the unit-test boundary sits, which is what Chapter 13 of the Zero to Production course walks through: unit testing handlers and controllers with Moq, then full-pipeline integration testing with WebApplicationFactory against a real endpoint, all in one connected project so the seams are always visible.

ASP.NET Core Web API: Zero to Production

Everything below targets .NET 10, C# 14, xUnit, and Moq at the time of writing. The API surface has been stable since ASP.NET Core 2.1, so the patterns hold on older versions too unless noted.

Should You Unit Test Controllers at All?

Only when the controller actually makes a decision. If an action just forwards a request to a service and returns whatever comes back, a unit test mostly re-asserts the framework and gives you little. Reach for unit tests when the action branches - returns NotFound on a null, maps a result to CreatedAtAction, or picks a status code from a condition. Cover the plumbing (routing, model binding, filters, the real database) with integration tests instead.

A quick rule of thumb:

  • Branching logic in the action (null checks, status selection, mapping) - unit test it.

  • Pass-through actions with no logic - an integration test earns more per line.

  • Model binding, validation filters, auth, routing - integration tests only, never unit tests.

Keep that boundary in mind and the mistakes below mostly disappear.

Mistake 1: Testing the Framework Instead of Your Own Logic

The most common waste of a test is asserting something ASP.NET Core already guarantees. I have seen tests that check a route template, confirm [HttpGet] is present, or verify model binding populated a property. None of that is your code, so none of it belongs in a unit test.

Focus every controller unit test on the one decision the action makes. Given a mocked dependency returning a known value, does the action return the correct result?

[Fact]
public async Task GetById_ReturnsNotFound_WhenProductMissing()
{
    _service.Setup(s => s.GetAsync(1)).ReturnsAsync((ProductDto?)null);

    var result = await _controller.GetById(1);

    Assert.IsType<NotFoundResult>(result.Result);
}

That test exercises your branch - "null from the service means 404" - and nothing else. If you find yourself asserting on attributes or routes, move that intent to an integration test.

Mistake 2: Casting ActionResult Without Going Through .Result

This is the single most frequent bug I see, and it is sneaky because the test compiles and often still passes for the wrong reason. When an action returns ActionResult<T>, the object you get back is not an OkObjectResult. It is an ActionResult<T> wrapper, and the actual result lives on its .Result property.

// Action under test
public async Task<ActionResult<ProductDto>> GetById(int id)
{
    var product = await _service.GetAsync(id);
    return product is null ? NotFound() : Ok(product);
}

The broken test casts the wrapper directly and gets null:

var result = await _controller.GetById(1);
var ok = result as OkObjectResult;   // always null - wrong type
Assert.NotNull(ok);                  // fails, or worse, never runs

The fix is to reach through .Result:

var result = await _controller.GetById(1);
var ok = Assert.IsType<OkObjectResult>(result.Result);

If your action returns a plain IActionResult instead of ActionResult<T>, you cast result directly with no .Result. Knowing which return type you are dealing with is half the battle - our IActionResult vs TypedResults vs Results guide breaks down when to use each and how they surface in tests.

Mistake 3: Checking the Result Type but Never the Payload

A test that only asserts IsType<OkObjectResult> proves the status shape and nothing about the data. The action could return the wrong object, an empty list, or a stale DTO and the test stays green. That is a test that passes for the wrong reason - the worst kind, because it buys false confidence.

Always assert the value you handed back:

var result = await _controller.GetById(1);

var ok = Assert.IsType<OkObjectResult>(result.Result);
var dto = Assert.IsType<ProductDto>(ok.Value);
Assert.Equal("Keyboard", dto.Name);

The payload assertion is the part that actually catches regressions. In production the bugs that bit us were never "the endpoint returned 200" - they were "the endpoint returned 200 with the wrong field mapped." Assert the content, not just the envelope.

Mistake 4: Letting a "Unit" Test Touch a Real Database

If a controller test spins up a DbContext, opens a connection, or calls a live HttpClient, it is an integration test wearing a unit test's name. It runs slower, fails for reasons unrelated to your logic, and turns flaky the moment the environment shifts.

Controllers should depend on an abstraction - a service or repository interface - and the unit test mocks that abstraction:

private readonly Mock<IProductService> _service = new();
private ProductsController CreateController() => new(_service.Object);

Now the test controls exactly what the dependency returns and runs in milliseconds. Save the real DbContext and full pipeline for integration tests with WebApplicationFactory. If you are still deciding which mocking library to standardize on, our Moq vs NSubstitute vs FakeItEasy comparison covers the trade-offs for exactly this kind of test.

Mistake 5: Trying to Unit Test Model Validation

This one trips up almost everyone using [ApiController]. Developers set ModelState manually, call the action, and expect a 400:

_controller.ModelState.AddModelError("Name", "Required");
var result = await _controller.Create(new CreateProductRequest());
// expecting BadRequest... but the action body ran anyway

Here is why that is wrong. With [ApiController], the automatic 400 response is produced by a filter that runs before your action. In a unit test you call the action method directly, so that filter never fires. Your test either sees the action body run past the check or asserts behavior that will never match production.

The fix is to stop unit testing framework validation entirely. Automatic model validation is the pipeline's job - verify it with an integration test that sends a genuinely invalid request. Only unit test ModelState when you wrote an explicit if (!ModelState.IsValid) branch, and even then, prefer moving validation into a dedicated validator you can test in isolation.

Mistake 6: Asserting the Wrong Result Type for 201 Created

Create endpoints should return CreatedAtAction (or CreatedAtRoute) so the response carries a Location header pointing at the new resource. Two mistakes cluster here: returning Ok instead of Created from the action, and asserting the wrong result type in the test.

CreatedAtAction produces a CreatedAtActionResult, not a generic CreatedResult:

// Action
return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
// Test
var result = await _controller.Create(request);

var created = Assert.IsType<CreatedAtActionResult>(result.Result);
Assert.Equal(nameof(_controller.GetById), created.ActionName);
Assert.Equal(product.Id, created.RouteValues!["id"]);

Asserting ActionName and RouteValues is what proves the Location header will actually resolve. A test that only checks the status code lets a broken Location ship silently, which downstream clients discover the hard way.

Mistake 7: Over-Mocking and Never Verifying What Matters

The final pattern has two faces. Some tests never verify a single interaction, so a command endpoint can "succeed" without ever calling the service that persists the data. Others verify everything - every getter, every call, exact argument matchers on incidental parameters - producing brittle tests that break on every harmless refactor.

Verify the one side effect that defines correctness, and nothing more:

await _controller.Create(request);

_service.Verify(s => s.CreateAsync(
    It.Is<CreateProductRequest>(r => r.Name == "Keyboard")),
    Times.Once);

That confirms the action actually delegated the write with the right data. Skip Verify on pure reads where the returned value already proves the call happened, and avoid asserting call order or incidental arguments unless they are genuinely part of the contract. Good mocks assert intent, not implementation trivia.

Quick Reference: The Seven Fixes

  • Test decisions, not the framework - assert your branch, not routing or attributes.

  • Reach through .Result when the action returns ActionResult<T>.

  • Assert the payload, not just the result type.

  • Mock the dependency - no real database in a unit test.

  • Do not unit test automatic validation - that is an integration test.

  • Assert CreatedAtActionResult with ActionName and RouteValues for 201s.

  • Verify the one meaningful side effect, then stop.

Fix these and your controller suite stops passing for the wrong reasons and starts catching the regressions that actually reach users. For the deeper testing story - unit tests, integration tests, and auth-aware test helpers in a single production codebase - the Microsoft guidance on unit testing controller logic and the xUnit documentation are both worth a careful read.

Frequently Asked Questions

Should You Unit Test Controllers in ASP.NET Core?

Unit test controllers only when the action contains real branching logic - null checks, status selection, or result mapping. For thin pass-through actions, an integration test with WebApplicationFactory gives more coverage per line because it also exercises routing, model binding, and filters that unit tests deliberately skip.

How Do You Assert an IActionResult Return Type in xUnit?

Use Assert.IsType<T>(), which both checks the type and returns the cast instance, so you can inspect its properties in one step. For an Ok response, write var ok = Assert.IsType<OkObjectResult>(result) and then assert ok.Value. If the action returns ActionResult<T>, pass result.Result to IsType instead of result.

Why Is My OkObjectResult Cast Always Null?

Because the action returns ActionResult<T>, not a bare IActionResult. The ActionResult<T> type is a wrapper: the real result sits on its .Result property. Cast result.Result to OkObjectResult, not result itself. Casting the wrapper directly always yields null.

What Is the Difference Between Unit and Integration Testing a Controller?

A unit test constructs the controller directly with mocked dependencies and calls the action method, so it verifies only your logic and runs in milliseconds. An integration test uses WebApplicationFactory to send a real HTTP request through the full pipeline - routing, model binding, validation filters, middleware - and asserts the actual response.

How Do You Test a Controller Action That Returns CreatedAtAction?

Cast the result to CreatedAtActionResult (via .Result when the action returns ActionResult<T>), then assert ActionName, the RouteValues used to build the Location header, and the Value payload. Checking only the 201 status code lets a broken Location header ship undetected.


About the Author

I'm Celin Daniel, Co-founder of Coding Droplets. I've been building .NET and ASP.NET Core systems in production for 13+ years - APIs, distributed backends, enterprise platforms. Everything I write here comes from real shipping experience: patterns that held up, trade-offs that bit us, and lessons learned the hard way.

More from this blog

C

Coding Droplets

289 posts

Coding Droplets is your go-to resource for .NET and ASP.NET Core development. Whether you're just starting out or building production systems, you'll find practical guides, real-world patterns, and clear explanations that actually make sense.

From beginner-friendly tutorials to advanced architecture decisions. We publish fresh .NET content every day to help you grow at every stage of your career.