.NET Test Automation: A Complete Guide for C# Projects
.NET has a mature test automation ecosystem built around three layers: unit tests (xUnit/NUnit/MSTest + Moq), integration tests (WebApplicationFactory, Testcontainers), and end-to-end tests (Playwright, Selenium). This guide covers setting up all three layers, structuring a test project, running tests in CI, and measuring coverage.
Key Takeaways
Three-layer testing is the standard in .NET. Unit tests for business logic (fast, isolated), integration tests for component wiring (medium, in-process), E2E tests for the deployed application (slow, real browser). Each layer catches different bugs.
dotnet test is the universal command. It works for xUnit, NUnit, and MSTest. Add --collect:"XPlat Code Coverage" for coverage. All CI systems support it.
Test projects are separate from source projects. Never put test code in the same project as production code. The standard naming convention is MyProject.Tests in the same solution.
Coverlet + ReportGenerator for coverage reports. Coverlet collects coverage, ReportGenerator produces HTML reports. Both integrate cleanly with dotnet test.
Playwright.NET for browser automation. Microsoft's Playwright has a first-class .NET SDK. It's faster and more reliable than Selenium for modern web apps.
The .NET Test Automation Landscape
.NET's testing ecosystem has matured significantly with .NET 5+. The standard stack in 2026:
Unit Testing:
- Frameworks: xUnit (default), NUnit, MSTest v2
- Mocking: Moq, NSubstitute
- Assertions: Built-in + FluentAssertions
Integration Testing:
- ASP.NET Core: WebApplicationFactory (in-process HTTP)
- Database: EF Core InMemory, Testcontainers
- HTTP: HttpClient, WireMock.Net
End-to-End Testing:
- Browser automation: Playwright.NET, Selenium
- API testing: RestSharp, HttpClient, Reqnroll (SpecFlow successor)
Cross-Cutting:
- Coverage: Coverlet + ReportGenerator
- CI: GitHub Actions, Azure Pipelines, TeamCity
- Monitoring: HelpMeTest
Project Structure
MyApp/
├── src/
│ ├── MyApp.Core/ # Domain logic, interfaces
│ ├── MyApp.Infrastructure/ # DB, external services
│ └── MyApp.Web/ # ASP.NET Core API/MVC
└── tests/
├── MyApp.Core.Tests/ # Unit tests for Core
├── MyApp.Infrastructure.Tests/ # Integration tests for Infrastructure
├── MyApp.Web.Tests/ # Integration tests for API (WebApplicationFactory)
└── MyApp.E2E.Tests/ # Playwright end-to-end testsCreating Test Projects
# Unit test project
dotnet new xunit -n MyApp.Core.Tests -o tests/MyApp.Core.Tests
dotnet add tests/MyApp.Core.Tests reference src/MyApp.Core
<span class="hljs-comment"># Integration test project
dotnet new xunit -n MyApp.Web.Tests -o tests/MyApp.Web.Tests
dotnet add tests/MyApp.Web.Tests package Microsoft.AspNetCore.Mvc.Testing
dotnet add tests/MyApp.Web.Tests reference src/MyApp.Web
<span class="hljs-comment"># Add all to solution
dotnet sln add tests/MyApp.Core.Tests
dotnet sln add tests/MyApp.Web.TestsUnit Tests
Unit tests verify individual functions and classes in isolation:
// MyApp.Core.Tests/Services/PricingServiceTests.cs
using Xunit;
using Moq;
using MyApp.Core.Services;
public class PricingServiceTests
{
private readonly Mock<IDiscountRepository> _mockDiscounts;
private readonly PricingService _service;
public PricingServiceTests()
{
_mockDiscounts = new Mock<IDiscountRepository>();
_service = new PricingService(_mockDiscounts.Object);
}
[Theory]
[InlineData(100.00, 10, 90.00)] // 10% discount
[InlineData(50.00, 0, 50.00)] // no discount
[InlineData(200.00, 25, 150.00)] // 25% discount
public void ApplyDiscount_ValidPercentage_ReturnsDiscountedPrice(
decimal originalPrice, int discountPercent, decimal expectedPrice)
{
_mockDiscounts.Setup(r => r.GetActiveDiscount("PROMO10"))
.Returns(new Discount { Percentage = discountPercent });
var result = _service.ApplyDiscount(originalPrice, "PROMO10");
Assert.Equal(expectedPrice, result);
}
[Fact]
public void ApplyDiscount_ExpiredCode_ReturnsOriginalPrice()
{
_mockDiscounts.Setup(r => r.GetActiveDiscount("EXPIRED"))
.Returns((Discount?)null);
var result = _service.ApplyDiscount(100.00m, "EXPIRED");
Assert.Equal(100.00m, result);
}
}Integration Tests
Integration tests verify that layers work together:
// MyApp.Web.Tests/Controllers/ProductsControllerTests.cs
public class ProductsControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public ProductsControllerTests(WebApplicationFactory<Program> factory)
{
_client = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
// Replace real DB with in-memory
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(opt =>
opt.UseInMemoryDatabase("TestDb_" + Guid.NewGuid()));
});
}).CreateClient();
}
[Fact]
public async Task GetProducts_ReturnsOkWithPaginatedList()
{
var response = await _client.GetAsync("/api/products?page=1&pageSize=10");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<PagedResult<ProductDto>>();
Assert.NotNull(result);
}
[Fact]
public async Task CreateProduct_MissingRequiredField_Returns400()
{
var payload = new { Price = 9.99m }; // Missing Name (required)
var response = await _client.PostAsJsonAsync("/api/products", payload);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}End-to-End Tests with Playwright.NET
Playwright.NET tests run against your real deployed application in a real browser:
Installing Playwright
dotnet add package Microsoft.Playwright
dotnet add package Microsoft.Playwright.MSTest # or .NUnit or .xUnit
<span class="hljs-comment"># Install browser binaries (one-time)
dotnet build
pwsh bin/Debug/net8.0/playwright.ps1 installWriting Playwright Tests
using Microsoft.Playwright;
using Xunit;
public class ProductFlowTests : IAsyncLifetime
{
private IPlaywright _playwright = null!;
private IBrowser _browser = null!;
private IBrowserContext _context = null!;
private IPage _page = null!;
public async Task InitializeAsync()
{
_playwright = await Playwright.CreateAsync();
_browser = await _playwright.Chromium.LaunchAsync(new()
{
Headless = true
});
_context = await _browser.NewContextAsync();
_page = await _context.NewPageAsync();
}
public async Task DisposeAsync()
{
await _context.DisposeAsync();
await _browser.DisposeAsync();
_playwright.Dispose();
}
[Fact]
public async Task AddToCart_ValidProduct_UpdatesCartCount()
{
await _page.GotoAsync("https://staging.myapp.com/products");
await _page.ClickAsync("[data-testid='add-to-cart-1']");
await _page.WaitForSelectorAsync("[data-testid='cart-badge']");
var cartCount = await _page.InnerTextAsync("[data-testid='cart-badge']");
Assert.Equal("1", cartCount);
}
[Fact]
public async Task Checkout_CompleteFlow_ShowsOrderConfirmation()
{
// Navigate to product page
await _page.GotoAsync("https://staging.myapp.com/products/1");
await _page.ClickAsync("text=Add to Cart");
// Go to cart
await _page.ClickAsync("[data-testid='cart-icon']");
await _page.WaitForURLAsync("**/cart");
// Proceed to checkout
await _page.ClickAsync("text=Proceed to Checkout");
// Fill shipping info
await _page.FillAsync("[name='email']", "test@example.com");
await _page.FillAsync("[name='address']", "123 Test Street");
// Place order
await _page.ClickAsync("text=Place Order");
await _page.WaitForURLAsync("**/order-confirmation/**");
var heading = await _page.InnerTextAsync("h1");
Assert.Contains("Order Confirmed", heading);
}
}Page Object Model
For maintainable E2E tests, use the Page Object Model pattern:
public class LoginPage
{
private readonly IPage _page;
private const string Url = "/login";
public LoginPage(IPage page) => _page = page;
public async Task NavigateAsync() => await _page.GotoAsync(Url);
public async Task LoginAsync(string email, string password)
{
await _page.FillAsync("[name='email']", email);
await _page.FillAsync("[name='password']", password);
await _page.ClickAsync("[type='submit']");
await _page.WaitForURLAsync("**/dashboard");
}
}
public class LoginTests : IAsyncLifetime
{
// setup...
[Fact]
public async Task Login_ValidCredentials_RedirectsToDashboard()
{
var loginPage = new LoginPage(_page);
await loginPage.NavigateAsync();
await loginPage.LoginAsync("user@example.com", "password123");
Assert.Contains("/dashboard", _page.Url);
}
}Running Tests
Basic Commands
# Run all tests in solution
dotnet <span class="hljs-built_in">test
<span class="hljs-comment"># Run specific project
dotnet <span class="hljs-built_in">test tests/MyApp.Core.Tests
<span class="hljs-comment"># Run with verbose output
dotnet <span class="hljs-built_in">test --logger <span class="hljs-string">"console;verbosity=detailed"
<span class="hljs-comment"># Filter by test name
dotnet <span class="hljs-built_in">test --filter <span class="hljs-string">"FullyQualifiedName~PricingService"
<span class="hljs-comment"># Filter by category
dotnet <span class="hljs-built_in">test --filter <span class="hljs-string">"Category=Unit"
<span class="hljs-comment"># Run in parallel
dotnet <span class="hljs-built_in">test -- xunit.parallelizeTestCollections=<span class="hljs-literal">trueCode Coverage
# Collect coverage with Coverlet (included in xunit template)
dotnet <span class="hljs-built_in">test --collect:<span class="hljs-string">"XPlat Code Coverage"
<span class="hljs-comment"># Generate HTML report
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator \
-reports:<span class="hljs-string">"**/coverage.cobertura.xml" \
-targetdir:<span class="hljs-string">"coverage-report" \
-reporttypes:HtmlOpen coverage-report/index.html to view coverage by file and line.
CI/CD Integration
GitHub Actions: Full Pipeline
name: .NET Test Suite
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- run: dotnet restore
- run: dotnet build --no-restore
- name: Run unit tests
run: dotnet test tests/MyApp.Core.Tests --collect:"XPlat Code Coverage" --logger "trx"
- uses: actions/upload-artifact@v4
with:
name: unit-test-results
path: "**/*.trx"
integration-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- run: dotnet restore
- run: dotnet build --no-restore
- name: Run integration tests
env:
ConnectionStrings__Default: "Host=localhost;Database=testdb;Username=test;Password=test"
run: dotnet test tests/MyApp.Web.Tests --logger "trx"
e2e-tests:
runs-on: ubuntu-latest
needs: [unit-tests, integration-tests]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- run: dotnet build tests/MyApp.E2E.Tests
- name: Install Playwright browsers
run: pwsh tests/MyApp.E2E.Tests/bin/Debug/net8.0/playwright.ps1 install --with-deps
- name: Run E2E tests against staging
env:
BASE_URL: ${{ secrets.STAGING_URL }}
run: dotnet test tests/MyApp.E2E.Tests --logger "trx"Azure Pipelines
trigger:
- main
pool:
vmImage: ubuntu-latest
steps:
- task: UseDotNet@2
inputs:
version: '8.0.x'
- task: DotNetCoreCLI@2
displayName: 'Build'
inputs:
command: build
projects: '**/*.csproj'
- task: DotNetCoreCLI@2
displayName: 'Unit Tests'
inputs:
command: test
projects: 'tests/MyApp.Core.Tests/*.csproj'
arguments: '--collect:"XPlat Code Coverage"'
- task: PublishCodeCoverageResults@1
inputs:
codeCoverageTool: Cobertura
summaryFileLocation: '**/coverage.cobertura.xml'Mutation Testing
Mutation testing verifies your tests actually catch bugs. Stryker.NET introduces small changes (mutations) to your code and checks if your tests fail:
dotnet tool install -g dotnet-stryker
cd tests/MyApp.Core.Tests
dotnet strykerStryker generates an HTML report showing which code mutations weren't caught. A 60%+ mutation score is a sign of meaningful test coverage. A 100% line coverage score with 30% mutation score means your assertions are weak.
Acceptance Tests with Reqnroll (SpecFlow Successor)
For behavior-driven development (BDD), Reqnroll provides Gherkin syntax:
dotnet add package Reqnroll.xUnit# Features/Checkout.feature
Feature: Checkout
As a customer
I want to purchase products
So I can receive them at my door
Scenario: Successful checkout
Given I have 2 "Widget" items in my cart
When I complete checkout with card "4111111111111111"
Then I should see "Order Confirmed"
And I should receive a confirmation email[Binding]
public class CheckoutStepDefinitions
{
private readonly HttpClient _client;
[Given(@"I have (\d+) ""(.*)"" items in my cart")]
public async Task GivenIHaveItemsInCart(int quantity, string productName) { }
[When(@"I complete checkout with card ""(.*)""")]
public async Task WhenICompleteCheckout(string cardNumber) { }
[Then(@"I should see ""(.*)""")]
public async Task ThenIShouldSee(string text) { }
}Test Organization Best Practices
One test class per class under test. PricingService → PricingServiceTests.
Arrange/Act/Assert with blank lines. The visual separation makes test structure scannable.
Descriptive test names. MethodName_Scenario_ExpectedResult tells you what failed without reading the body.
Keep tests short. Tests over 30 lines usually contain hidden setup complexity. Extract helpers or fixtures.
Test one behavior per test. Multiple assertions are fine if they all verify the same behavior. Multiple Act blocks in one test is a smell.
Don't test the framework. Don't test that ASP.NET routes correctly or that EF Core saves an entity. Test your code, not your dependencies.
Beyond CI: Continuous Monitoring
CI pipelines catch regressions on merge. But production can break between deployments — from configuration drift, third-party API changes, or infrastructure issues.
HelpMeTest runs your Robot Framework + Playwright tests against production on a schedule (5-minute intervals on free, down to 10 seconds on Enterprise), alerting you the moment something breaks. It complements your dotnet test CI suite with 24/7 live monitoring that doesn't require a code deployment to catch a broken login page.
The free tier supports 10 tests. Pro is $100/month flat with unlimited tests and parallel execution.