Testing Data Access and Controllers
Two parts of an MVC app resist plain unit testing: code that talks to the database, and code that talks to the HTTP pipeline. Both have honest testing strategies β and popular dishonest ones worth recognizing.
Testing data access: three tiers of realism
Repository and query code ultimately produces SQL, and the bugs live in that translation β so the closer your test database is to real SQL Server, the more bugs the test can catch.
- EF Core InMemory provider is fastest but least honest: it's not relational. No foreign key enforcement, no relational query translation β LINQ that InMemory accepts can throw against SQL Server. Fine for "service logic that happens to touch a context," wrong for validating queries.
- SQLite in-memory is a real relational engine with transactions and constraints, still
fast and file-free. A good middle tier β but it isn't SQL Server (different types, no
datetimeoffset, dialect gaps). - Real SQL Server integration tests β against LocalDB or a throwaway Docker container β are the truth tier. Reserve them for query-heavy repositories, migrations, and anything using raw SQL or SQL Server-specific features. Keep each test isolated: recreate the database per run, or wrap each test in a rolled-back transaction.
Don't mock DbSet directly β it's fragile, and it lies about query translation. The pragmatic
split: business rules get unit tests with a mocked repository interface; the repository
itself gets a handful of real-database tests.
Controller tests: thin, and testing YOUR logic
A controller is instantiable like any class: new it up with mocked services, call the action, assert on the result:
var result = controller.Details(42) as ViewResult;
Assert.IsType<CandidateDetailsViewModel>(result.Model);
Worthwhile assertions: the right result type (ViewResult vs RedirectToActionResult vs 404),
the model handed to the view, and branching β invalid input redisplays the form, success
redirects (the PRG pattern from the MVC module). Not worthwhile: re-verifying every mock call
the action makes. If there's nothing to assert but mock interactions, the controller is
appropriately thin β stop testing it.
The ModelState pitfall
The single most common controller-test mistake: model binding and validation don't run when
you call an action directly, so ModelState.IsValid is always true β even for a
ViewModel violating every [Required] attribute. To test the invalid branch, poison
ModelState yourself:
controller.ModelState.AddModelError("Ssn", "Required");
var result = controller.Create(emptyModel);
Assert.IsType<ViewResult>(result); // redisplays, doesn't save
Then verify the service was never called. Testing that the attributes themselves are present is a separate, cheaper check β read them via reflection, or lean on an integration test that posts a real form through the pipeline. Know which of the three you're testing: your attributes, your branch logic, or the framework's binding β and don't write a test that accidentally tests none of them.