ASP.NET Core 10 Testing Tutorial: Verify Endpoints, Auth, and EF Core Paths
Lesson 6 of the .NET & ASP.NET Core learning track. Your API now validates input and returns consistent error responses. This lesson turns that behavior into repeatable tests that boot the application in memory, call real endpoints, cross authorization boundaries, and exercise an EF Core-backed path.
Prerequisite: complete Lesson 5: validation and consistent Problem Details. Keep the full .NET & ASP.NET Core learning track open as the roadmap.
Learning outcome
By the end, you can write an integration test that starts your real pipeline with WebApplicationFactory<Program>, asserts an HTTP contract, replaces an external dependency, and uses a SQLite in-memory database rather than pretending a relational provider behaves like an in-memory dictionary.
Why integration tests belong at API boundaries
Unit tests are excellent for focused rules. They do not prove route mapping, middleware order, authentication configuration, JSON serialization, or database registration work together. ASP.NET Core’s integration-testing guidance uses a test host and in-memory server; Microsoft.AspNetCore.Mvc.Testing supplies WebApplicationFactory to bootstrap the application. The same guidance recommends SQLite for in-memory relational testing over the EF Core InMemory provider when relational behavior matters.
Create the test project
dotnet new xunit -n Store.Api.Tests
dotnet add Store.Api.Tests reference Store.Api
dotnet add Store.Api.Tests package Microsoft.AspNetCore.Mvc.Testing
dotnet add Store.Api.Tests package Microsoft.EntityFrameworkCore.Sqlite
dotnet test
Expose the generated top-level Program class to the test assembly by adding public partial class Program { } at the bottom of the API’s Program.cs. This changes no runtime behavior; it gives the test a type to use as its entry point.
Test a public endpoint and validation contract
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
public sealed class ProductEndpointTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task Create_with_missing_name_returns_validation_problem()
{
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/products", new { name = "" });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("application/problem+json",
response.Content.Headers.ContentType?.MediaType);
}
}
Assert the public contract: status, content type, and stable fields that a client relies on. Do not make a test fragile by asserting every generated detail, timestamp, or framework-owned wording. The test above protects the agreement established in Lesson 5 rather than merely checking that “something failed.”
Test the authorization boundary
Use one test for the anonymous outcome and another that substitutes a test authentication handler for an authenticated identity. Do not mint production JWTs or call an external identity provider in this suite. Microsoft’s integration-test guidance demonstrates test authentication through a mock AuthenticationHandler<TOptions> configured in test services.
var authorizedFactory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", _ => { });
});
});
var anonymous = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/orders")).StatusCode);
var authenticated = authorizedFactory.CreateClient();
authenticated.DefaultRequestHeaders.Authorization = new("Test");
Assert.Equal(HttpStatusCode.OK, (await authenticated.GetAsync("/orders")).StatusCode);
Implement TestAuthHandler in the test project so it returns a fixed claims principal only for the test scheme. Keep the test policy and claims realistic: an authenticated user and an administrator are different cases. This builds directly on Lesson 4’s authentication and authorization model.
Exercise a database-backed path
For a test-specific factory, remove the production DbContextOptions registration, open one SQLite connection with DataSource=:memory:, register it, and keep that connection open for the factory’s lifetime. Call Database.EnsureCreated(), seed only data needed by the scenario, then issue the HTTP request. This catches a missing migration mapping, query translation issue, or serialization boundary that a repository mock cannot see.
Common mistakes
- Sharing mutable seeded data between tests and then depending on execution order.
- Using EF Core InMemory for a test that depends on relational constraints or SQL translation.
- Following an auth redirect and asserting the final page instead of the initial 401/403.
- Testing framework internals instead of the API contract your caller consumes.
- Calling real payment, email, or identity systems from deterministic integration tests.
Validation and review steps
- Run
dotnet testrepeatedly and confirm tests pass in any order. - Break a route, authorization policy, and validation rule one at a time; make sure a focused test fails.
- Run the suite in CI with a separate integration-test job and publish test results.
- Keep a small smoke suite for deployments; do not confuse it with the full test suite.
Production guidance
Tests are a safety net, not an availability mechanism. Pair them with the health checks, logs, and dependency budgets used in production. Keep secrets out of test configuration, use isolated test databases for real-provider tests, and make fixtures disposable. Add contract tests where another team owns the dependency.
Next lesson
Lesson 7: Containerize the API with Docker. You will create a small, reproducible image, run it locally with configuration supplied at runtime, and keep development conveniences out of the production image.