ASP.NET Testing: A Practical Guide
Build a practical ASP.NET Core testing strategy with unit, integration, and browser tests, including runnable WebApplicationFactory examples and troubleshooting.
Use unit tests for isolated application logic, integration tests for important behavior across components and infrastructure, and browser automation when a user-facing flow depends on a real browser. In ASP.NET Core, WebApplicationFactory<TEntryPoint> provides an in-memory test host and HTTP client for integration tests. Keep the integration suite focused: it costs more to run and maintain than unit tests.
1. Choose the right testing layer
| Layer | Question it answers | Typical scope | When to use it |
|---|---|---|---|
| Unit | Does this unit of application logic behave correctly? | A method, handler, or small unit under your control | Validation, branching, calculations, and other routine behavior |
| Integration | Do components work together correctly? | HTTP pipeline, routing, serialization, authentication, database access, or other infrastructure boundaries | A small set of important representative infrastructure scenarios |
| Browser / end-to-end | Can a user complete the flow in a browser? | Rendered application and browser interactions | SPA or other flows whose correctness depends on browser behavior |
Microsoft advises limiting integration tests to the most important infrastructure scenarios and choosing a unit test when either layer can verify the behavior. Unit tests should focus on code under the developer’s control. Use fakes or mocks for infrastructure dependencies when that keeps a unit test isolated and useful.
A practical coverage plan is to unit-test routine logic, then integration-test a representative read, write, update, and delete path where those operations cross infrastructure boundaries. Add browser automation for a small number of high-value user journeys. Don’t duplicate every unit-test case at every layer.
2. Pick a test framework and platform
A test framework is the tool used to write tests; a test platform is the engine that runs them and communicates with the CLI or IDE. Microsoft lists MSTest, NUnit, TUnit, and xUnit.net as frameworks, and VSTest and Microsoft.Testing.Platform as platform choices. TUnit is built on Microsoft.Testing.Platform and does not support VSTest; the overview describes MSTest, NUnit, and xUnit.net documentation as supporting both platforms.
There is no universally best framework established by those options. Choose based on your target .NET version, platform compatibility, team conventions, IDE and command-line workflow, runner packages, integrations, and migration cost. Check the framework’s current official documentation for compatibility before pinning package versions. The examples below use xUnit.
3. Add an integration-test project
Create a test project that references the ASP.NET Core application and Microsoft.AspNetCore.Mvc.Testing. The test project should use the Web SDK. One layout is:
src/MyApp/MyApp.csproj
tests/MyApp.Tests/MyApp.Tests.csproj
tests/MyApp.Tests/HealthEndpointTests.cs
For example, the test project file can look like this. Replace the target framework with one supported by your application and installed SDK, and check current package versions before use.
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/MyApp/MyApp.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
</Project>
These version values are an illustrative starting point, not a claim that they are the newest compatible releases. Align package versions with the application’s .NET target and the current package guidance. In Microsoft’s described setup, xunit.runner.visualstudio 2.4.2 or later also requires Microsoft.NET.Test.Sdk.
Add the application project reference and packages using the CLI if preferred:
dotnet new xunit -n MyApp.Tests -o tests/MyApp.Tests
dotnet add tests/MyApp.Tests/MyApp.Tests.csproj reference src/MyApp/MyApp.csproj
dotnet add tests/MyApp.Tests/MyApp.Tests.csproj package Microsoft.AspNetCore.Mvc.Testing
Review the generated project file after these commands: ensure it uses Microsoft.NET.Sdk.Web as its SDK and add or update runner packages as appropriate.
4. Expose the application entry point
WebApplicationFactory<TEntryPoint> needs the application entry point, usually Program. With minimal hosting, the generated Program type may not be visible to the test assembly. At the end of the application’s Program.cs, make it public for test access:
public partial class Program { }
An alternative is to grant the test assembly access with InternalsVisibleTo. Use one approach consistently. The partial declaration is often the simplest for a small application.
5. Write an HTTP integration test
This example assumes the application maps /health and returns a successful response. It starts the app through the test factory, sends an HTTP request, and checks the response:
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
public sealed class HealthEndpointTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public HealthEndpointTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task Health_endpoint_returns_success()
{
using var response = await _client.GetAsync("/health");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}
Run the suite from the repository root:
dotnet test tests/MyApp.Tests/MyApp.Tests.csproj
The test host runs the application pipeline with TestServer; the client makes in-process HTTP requests. A normal assertion can inspect status, headers, or the response body. For example, to assert a JSON response:
using System.Net.Http.Json;
using var response = await _client.GetAsync("/api/items/42");
response.EnsureSuccessStatusCode();
var item = await response.Content.ReadFromJsonAsync<ItemDto>();
Assert.NotNull(item);
Assert.Equal(42, item.Id);
Define ItemDto to match the application contract. Use explicit assertions about the public response rather than reaching into private implementation details.
6. Configure services and test settings
Customize the factory when a test needs a different database, authentication setup, configuration, or other dependency. The factory can replace registrations before the host is built. This example illustrates replacing an application service; substitute the service and fake with types from your application:
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
public sealed class TestApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureServices(services =>
{
services.RemoveAll<IClock>();
services.AddSingleton<IClock>(new FixedClock());
});
}
}
Use TestApplicationFactory in the fixture in place of WebApplicationFactory<Program>. If the production registration is added later than your replacement, inspect the service collection ordering and remove or replace the effective registration. Keep test configuration and data separate from production. The documented default for an unset SUT environment is Development; set a deliberate test environment and verify it cannot select production services or credentials.
For database integration tests, choose a test database strategy that matches what you intend to verify. An in-memory substitute is useful for isolated handler behavior, but it may not reproduce the behavior of a production database provider. If provider-specific behavior matters, exercise that boundary with a representative test setup. Ensure tests do not share mutable state accidentally, and clean up data they create.
7. Unit-test a Minimal API handler
Minimal API endpoints can be organized so that application behavior lives in a handler that is straightforward to call from a unit test. If a handler returns IResult, test the result behavior directly, or test the handler’s underlying logic separately. Microsoft’s example uses xUnit and an in-memory database to replace an external database dependency. Keep in mind that an in-memory database test does not by itself prove compatibility with a production database provider.
using Microsoft.AspNetCore.Http;
using Xunit;
public sealed class CreateItemHandlerTests
{
[Fact]
public async Task Handle_returns_created_result_for_valid_item()
{
var repository = new FakeItemRepository();
var handler = new CreateItemHandler(repository);
var result = await handler.Handle(new CreateItemRequest("Notebook"));
Assert.IsType<IResult>(result);
Assert.Single(repository.Items);
Assert.Equal("Notebook", repository.Items[0].Name);
}
}
CreateItemHandler, its request type, repository, and fake are application-specific placeholders; implement them according to the app’s design. The example demonstrates the test shape, not a framework-provided handler API. For stronger checks of HTTP status and serialized body, exercise the mapped endpoint through WebApplicationFactory.
8. Add browser automation when the browser matters
Use browser automation for flows that depend on rendering, browser events, or SPA behavior. Microsoft’s ASP.NET Core integration-testing guidance points to Playwright for .NET as an option. Keep these checks focused on important user journeys; they have a different scope from in-process HTTP tests. The sources consulted here do not prescribe a complete Playwright architecture or setup, so follow the current Playwright for .NET documentation for installation and browser configuration.
For visual inspection or screenshot capture of pages, a screenshot API can complement browser-based assertions. ScreenshotNeo is a website screenshot API and MCP server for developers; it can capture rendered pages as PNG, JPEG, WebP, or PDF. It does not replace assertions about application behavior.
9. Or skip the browser setup
For a page capture, make one GET request. See the ScreenshotNeo API documentation for options and request details.
cURL
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
open("shot.webp", "wb").write(r.content)
Node.js
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
const bytes = new Uint8Array(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', bytes));
ScreenshotNeo accepts cookie and consent banners as a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each step can be turned off. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers report page verdict and billing status. Its MCP server provides take_screenshot, get_page_info, and capture_pdf for AI agents and MCP clients. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots.
Sign up for 1,000 free screenshots a month, with no card required.
10. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Program is inaccessible from the test project |
The minimal-hosting entry point is internal to the application assembly | Add public partial class Program { } or configure InternalsVisibleTo. |
| Test project does not discover tests | Missing test SDK or runner, mismatched package versions, or project not marked as a test project | Check IsTestProject, the framework runner, and Microsoft.NET.Test.Sdk. For the documented xUnit runner setup, version 2.4.2 or later requires the test SDK. |
| Request returns 404 in the test | The route or HTTP method differs from the test assumption, or the endpoint was not mapped | Compare the tested path and verb with endpoint registration; verify the app’s startup path and environment-specific configuration. |
| Dependency still uses the real implementation | The test replacement did not remove the existing registration or was applied too late | Replace the registration in ConfigureWebHost and confirm which service is resolved by the host. |
| Tests pass alone but fail together | Shared mutable state, reused database records, or order-dependent setup | Isolate data per test or fixture, reset state, and avoid depending on execution order. |
| Test accidentally reaches a real service | Test environment or configuration still points at external or production resources | Set a deliberate test environment, replace external dependencies, and use separate test credentials and data. |
| In-memory database test passes but production database behavior fails | The substitute does not reproduce provider-specific behavior | Add a focused integration test using the relevant provider for behavior that depends on it. |
| Browser test cannot see a page change | The flow relies on client-side rendering or asynchronous browser behavior that an HTTP-only test does not execute | Use browser automation for that flow and wait on a meaningful application state, following the browser tool’s current documentation. |
11. Performance, reliability, and cost
- Keep the fast feedback loop small. Unit tests avoid database, file, and network dependencies and are generally quicker than integration tests.
- Spend integration time on boundaries. Integration tests exercise production components and require more setup, code, and data processing. Cover the important infrastructure behavior instead of copying the entire unit suite.
- Use deterministic test conditions. Set the environment, configuration, dependencies, and data deliberately. Avoid external network dependencies unless the test specifically exists to cover that boundary.
- Separate suites when useful. Separate unit and integration test projects if it helps keep infrastructure dependencies out of unit tests or lets the team control which suite runs.
- Budget browser tests for user-visible risk. Browser automation checks a different layer; reserve it for user journeys where browser behavior is part of the requirement.
- Manage screenshot capture costs explicitly. ScreenshotNeo offers 1,000 shots/month free, then plans at $5 for 3,000, $15 for 15,000, $39 for 60,000, $99 for 250,000, and $249 for 1,000,000. Yearly billing gives two months free; every listed feature is on every plan. Cache hits and failed or non-clean captures are not billed.
12. Frequently asked questions
Should unit and integration tests live in separate projects?
They can. Separate projects are useful when they keep infrastructure packages out of the unit suite or let you run the suites independently. A single project can also work when the distinction remains clear and the team’s workflow does not need separate execution.
Can I test Minimal APIs with WebApplicationFactory?
Yes. The factory boots the application entry point and exposes an HTTP client, so tests can call mapped endpoints through the application pipeline. Make the generated Program type accessible to the test assembly.
Does an in-memory database prove the production database works?
No. It can help isolate handler or application logic, but provider-specific queries and behavior need coverage against the relevant database provider.
Do I need browser automation for every endpoint?
No. Use HTTP integration tests for endpoint behavior and browser automation where rendering and browser interaction affect the outcome.
Which test framework should I choose?
Choose one compatible with your target framework and test platform that fits your team’s existing workflow. The official overview lists multiple options and does not establish a universal winner.


