.NET Test Automation: A Complete Guide for C# Projects

.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 tests

Creating 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.Tests

Unit 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 install

Writing 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">true

Code 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:Html

Open 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 stryker

Stryker 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. PricingServicePricingServiceTests.

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.

Read more