Advanced

Automation Testing Strategies with ASP.NET Core

Unit, integration, UI, E2E (Playwright) and performance (NBomber) testing in a CI/CD pipeline.

Demo application: CarvedRock Fitness (API + Razor Pages UI + EF Core + OpenID Connect) ASP.NET Core 8, Visual Studio 2022


Table of Contents

  1. Overview and Demo Application
  2. Unit Tests and Integration Tests
  3. Test Data Management and Testcontainers
  4. UI Tests and External APIs
  5. End-to-End Tests with Playwright
  6. Performance Testing with NBomber
  7. Test Execution Strategy

1. Overview and Demo Application

CarvedRock Fitness is a fictional eCommerce application selling fitness equipment:

┌─────────────────────────────────────────────────────────────────┐
│               CARVEDROCK FITNESS ARCHITECTURE                   │
│                                                                 │
│  ┌──────────────────────┐    ┌────────────────────────┐    │
│  │   CarvedRock.WebApp   │    │    CarvedRock.Api      │    │
│  │   (Razor Pages UI)    │◄──►│  (ASP.NET Core API)   │    │
│  │                       │    │                        │    │
│  │  - Shopping cart      │    │  - Products endpoint   │    │
│  │  - OpenID Connect     │    │  - EF Core + SQLite    │    │
│  │  - Email via SMTP     │    │  - Auth OAuth2 Bearer  │    │
│  └──────────────────────┘    └────────────────────────┘    │
│                                                             │
│  Complementary services (Docker):                           │
│  - smtp4dev (emails): localhost:3000                        │
│  - Seq (logs): localhost:5341                               │
└─────────────────────────────────────────────────────────────┘

Test Structure

tests/
├── CarvedRock.InnerLoop.Tests/      # Unit + API integration tests
│   ├── ProductControllerTests.cs
│   ├── ProductValidatorTests.cs
│   └── Utilities/
│       ├── CustomApiFactory.cs
│       ├── SharedFixture.cs
│       └── DataCreator.cs
├── CarvedRock.InnerLoop.WebApp.Tests/  # UI + WireMock tests
│   ├── ProductListingTests.cs
│   ├── CartTests.cs
│   └── Utilities/
│       └── CustomWebAppFactory.cs
e2e/
└── CarvedRock.EndToEnd.Tests/       # Playwright tests
perf/
└── CarvedRock.Perf.Tests/           # NBomber tests

2. Unit Tests and Integration Tests

2.1 Unit Tests with xUnit

// Example: ProductValidator test
public class ProductValidatorTests
{
    private readonly ProductValidator _validator;
    private readonly IProductRepository _mockRepository;
    
    public ProductValidatorTests()
    {
        // Mocking with NSubstitute
        _mockRepository = Substitute.For<IProductRepository>();
        _validator = new ProductValidator(_mockRepository);
    }
    
    [Fact]
    public async Task ProductName_WhenEmpty_ShouldFail()
    {
        // Arrange
        var product = new NewProductModel { Name = "" };
        
        // Act
        var result = await _validator.ValidateAsync(product);
        
        // Assert
        result.IsValid.Should().BeFalse();
        result.Errors.Should().Contain(e => e.ErrorMessage == "Name is required.");
    }
}

Bogus library for generating test data:

// Custom Faker for NewProductModel
private static Faker<NewProductModel> GetNewProductFaker()
{
    return new Faker<NewProductModel>()
        .RuleFor(p => p.Name, f => f.Commerce.ProductName().ClampLength(max: 50))
        .RuleFor(p => p.Description, f => f.Commerce.ProductDescription().ClampLength(max: 100))
        .RuleFor(p => p.Category, f => f.PickRandom("Footwear", "Climbing", "Kayaking"))
        .RuleFor(p => p.Price, (f, p) => p.Category switch
        {
            "Footwear" => f.Random.Double(50, 300),
            "Climbing" => f.Random.Double(20, 150),
            _ => f.Random.Double(100, 600)
        })
        .RuleFor(p => p.ImgUrl, f => f.Image.PicsumUrl());
}

// Usage: generate one object
var product = GetNewProductFaker().Generate();

// Or multiple
var products = GetNewProductFaker().Generate(10);

2.2 xUnit Theories

// Theory = same test with multiple data sets
[Theory]
[InlineData("", "Name is required.")]
[InlineData(" ", "Name is required.")]
[InlineData("ThisNameIsFarTooLongToBeValidBecauseItExceedsFiftyCharactersTotal", "Name must not exceed 50 characters.")]
public async Task ProductName_WithInvalidValues_ShouldFailWithMessage(
    string nameValue, string expectedMessage)
{
    // Arrange
    var product = new NewProductModel { Name = nameValue };
    
    // Act
    var result = await _validator.ValidateAsync(product);
    
    // Assert
    result.IsValid.Should().BeFalse();
    result.Errors.Should().Contain(e => e.ErrorMessage == expectedMessage);
}

2.3 Integration Tests with WebApplicationFactory

Prerequisite: add public partial class Program {} at the bottom of the API’s Program.cs.

// Modify the .csproj to use Sdk.Web
<Project Sdk="Microsoft.NET.Sdk.Web">

// Add NuGet reference
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
// Basic integration test
public class ProductControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;
    private readonly ITestOutputHelper _output;
    
    public ProductControllerTests(WebApplicationFactory<Program> factory,
        ITestOutputHelper output)
    {
        _factory = factory;
        _output = output;
    }
    
    [Fact]
    public async Task GetProducts_ReturnsSuccess()
    {
        // Arrange
        var client = _factory.CreateClient();
        
        // Act
        var response = await client.GetAsync("/api/product?category=footwear");
        
        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);
        var products = await response.Content.ReadFromJsonAsync<List<Product>>();
        products.Should().HaveCountGreaterThanOrEqualTo(1);
    }
}

HTTP extensions for readability:

// Utilities/HttpClientExtensions.cs
public static class HttpClientExtensions
{
    public static async Task<T> GetJsonResultAsync<T>(
        this HttpClient client,
        string url,
        HttpStatusCode expectedStatusCode,
        ITestOutputHelper? output = null)
    {
        var response = await client.GetAsync(url);
        return await DeserializeAndCheckResponse<T>(response, expectedStatusCode, output);
    }
    
    private static async Task<T> DeserializeAndCheckResponse<T>(
        HttpResponseMessage response,
        HttpStatusCode expectedStatusCode,
        ITestOutputHelper? output)
    {
        response.StatusCode.Should().Be(expectedStatusCode, 
            because: await response.Content.ReadAsStringAsync());
        
        var content = await response.Content.ReadAsStringAsync();
        
        try
        {
            var result = JsonSerializer.Deserialize<T>(content, 
                new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
            result.Should().NotBeNull();
            return result!;
        }
        catch
        {
            try
            {
                var formatted = JsonDocument.Parse(content).RootElement.ToString();
                output?.WriteLine(formatted);
            }
            catch
            {
                output?.WriteLine(content);
            }
            throw;
        }
    }
}

2.4 Auth Middleware for Tests

// Fake user middleware — replaces real authentication
public class FakeUserMiddleware
{
    private readonly RequestDelegate _next;
    
    public FakeUserMiddleware(RequestDelegate next) => _next = next;
    
    public async Task InvokeAsync(HttpContext context)
    {
        if (context.Request.Headers.TryGetValue("X-Integration-Testing-Auth", out var authValues))
        {
            // Format: "name:role1,role2;idp:Google"
            var parts = authValues.ToString().Split(';');
            var nameAndRoles = parts[0].Split(':');
            var name = nameAndRoles[1];
            var roles = nameAndRoles.Length > 1 ? nameAndRoles[2].Split(',') : Array.Empty<string>();
            
            var idp = parts.Length > 1 ? parts[1].Split(':')[1] : "local";
            
            var claims = new List<Claim>
            {
                new(ClaimTypes.Name, name),
                new("idp", idp),
                new("sub", Guid.NewGuid().ToString())
            };
            claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
            
            var identity = new ClaimsIdentity(claims, "IntegrationTest");
            context.User = new ClaimsPrincipal(identity);
        }
        
        await _next(context);
    }
}
// CustomApiFactory — replaces the API startup for tests
public class CustomApiFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("InnerLoop-Test");
        
        builder.ConfigureServices(services =>
        {
            // Replace the real DB with in-memory SQLite
            var descriptor = services.SingleOrDefault(
                d => d.ServiceType == typeof(DbContextOptions<LocalContext>));
            if (descriptor != null) services.Remove(descriptor);
            
            services.AddDbContext<LocalContext>(options =>
                options.UseSqlite("DataSource=:memory:"));
        });
        
        builder.Configure(app =>
        {
            // Add the fake auth middleware BEFORE routing
            app.UseMiddleware<FakeUserMiddleware>();
        });
    }
}
// Usage in tests
[Theory]
[InlineData("Alice Smith", "admin", HttpStatusCode.Created)]
[InlineData("Bob Johnson", "", HttpStatusCode.Forbidden)]
[InlineData("", "", HttpStatusCode.Unauthorized)]
public async Task CreateProduct_WithDifferentUsers_ReturnsExpectedStatus(
    string name, string role, HttpStatusCode expectedStatus)
{
    // Arrange
    var client = _factory.CreateClient();
    if (!string.IsNullOrEmpty(name))
    {
        client.DefaultRequestHeaders.Add("X-Integration-Testing-Auth", 
            $"name:{name};role:{role};idp:Google");
    }
    
    var product = GetNewProductFaker().Generate();
    
    // Act
    var response = await client.PostAsJsonAsync("/api/product", product);
    
    // Assert
    response.StatusCode.Should().Be(expectedStatus);
}

2.5 Code Coverage

# Run tests with coverage (Coverlet)
dotnet test --collect:"XPlat Code Coverage" --settings ./tests/coverlet.runsettings

# Generate HTML report with ReportGenerator
reportgenerator `
  -reports:"**/coverage.cobertura.xml" `
  -targetdir:"coverage-report" `
  -reporttypes:"HtmlInline_AzurePipelines;Cobertura"

# Open the report
start coverage-report/index.html

Runsettings file to exclude migrations:

<!-- tests/coverlet.runsettings -->
<?xml version="1.0" encoding="utf-8" ?>
<RunSettings>
  <DataCollectionRunSettings>
    <DataCollectors>
      <DataCollector friendlyName="XPlat code coverage">
        <Configuration>
          <Format>cobertura</Format>
          <Exclude>[CarvedRock.Api]CarvedRock.Api.Migrations.*</Exclude>
          <ExcludeByFile>**/*.Designer.cs</ExcludeByFile>
        </Configuration>
      </DataCollector>
    </DataCollectors>
  </DataCollectionRunSettings>
</RunSettings>

3. Test Data Management and Testcontainers

3.1 SQLite In-Memory

// SharedFixture — data shared among all tests in a collection
public class SharedFixture : IAsyncLifetime
{
    public CustomApiFactory ApiFactory { get; private set; } = default!;
    public List<Product> OriginalProducts { get; private set; } = new();
    
    public async Task InitializeAsync()
    {
        ApiFactory = new CustomApiFactory();
        
        // Get a scope to initialize the DB
        using var scope = ApiFactory.Services.CreateScope();
        var context = scope.ServiceProvider.GetRequiredService<LocalContext>();
        
        await context.Database.EnsureDeletedAsync();
        await context.Database.EnsureCreatedAsync();
        
        // Generate 50 products with Bogus
        await context.SeedProductsAsync(50);
        
        // Capture initial state for assertions
        OriginalProducts = await context.Products.ToListAsync();
    }
    
    public async Task DisposeAsync()
    {
        await ApiFactory.DisposeAsync();
    }
}

// Collection fixture
[CollectionDefinition("InnerLoopCollection")]
public class InnerLoopCollection : ICollectionFixture<SharedFixture> { }

// Usage in tests
[Collection("InnerLoopCollection")]
public class ProductListingTests
{
    private readonly SharedFixture _fixture;
    
    public ProductListingTests(SharedFixture fixture) => _fixture = fixture;
    
    [Fact]
    public async Task GetProducts_ReturnsAllProducts()
    {
        var client = _fixture.ApiFactory.CreateClient();
        var products = await client.GetJsonResultAsync<List<Product>>(
            "/api/product", HttpStatusCode.OK);
        
        products.Should().HaveCountGreaterThanOrEqualTo(_fixture.OriginalProducts.Count);
    }
}

DataCreator with Bogus:

// Utilities/DataCreator.cs
public static class DataCreator
{
    public static async Task SeedProductsAsync(this LocalContext context, int count = 10)
    {
        var faker = new Faker<Product>()
            .RuleFor(p => p.Name, f => f.Commerce.ProductName().ClampLength(max: 50))
            .RuleFor(p => p.Description, f => f.Commerce.ProductDescription().ClampLength(max: 100))
            .RuleFor(p => p.Category, f => f.PickRandom("Footwear", "Climbing", "Kayaking"))
            .RuleFor(p => p.Price, (f, p) => Math.Round(f.Random.Decimal(20, 500), 2))
            .RuleFor(p => p.ImgUrl, f => f.Image.PicsumUrl(50, 50));
        
        var products = faker.Generate(count);
        context.Products.AddRange(products);
        await context.SaveChangesAsync();
    }
}

3.2 Testcontainers with PostgreSQL

// Add NuGet package
// Testcontainers.PostgreSql

public class SharedFixture : IAsyncLifetime
{
    private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder()
        .WithDatabase("carvedrock")
        .WithUsername("appuser")
        .WithPassword("testPassword456!")
        .Build();
    
    public string ConnectionString => _dbContainer.GetConnectionString();
    
    public async Task InitializeAsync()
    {
        // Start Postgres container
        await _dbContainer.StartAsync();
        
        ApiFactory = new CustomApiFactory(ConnectionString);
        
        // Migrate and seed
        using var scope = ApiFactory.Services.CreateScope();
        var context = scope.ServiceProvider.GetRequiredService<LocalContext>();
        await context.Database.MigrateAsync();
        await context.SeedProductsAsync(50);
        
        OriginalProducts = await context.Products.ToListAsync();
    }
    
    public async Task DisposeAsync()
    {
        await ApiFactory.DisposeAsync();
        await _dbContainer.DisposeAsync(); // Destroys the container
    }
}
// CustomApiFactory uses the ConnectionString from the container
public class CustomApiFactory : WebApplicationFactory<Program>
{
    private readonly string _connectionString;
    
    public CustomApiFactory(string connectionString)
    {
        _connectionString = connectionString;
    }
    
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            // Replace the app's DbContext to point to the container
            var descriptor = services.SingleOrDefault(
                d => d.ServiceType == typeof(DbContextOptions<LocalContext>));
            if (descriptor != null) services.Remove(descriptor);
            
            services.AddDbContext<LocalContext>(options =>
                options.UseNpgsql(_connectionString));
        });
    }
}

3.3 Testcontainers with SQL Server

// Add NuGet package
// Testcontainers.MsSql

private readonly MsSqlContainer _dbContainer = new MsSqlBuilder()
    .WithPassword("StrongPassword@123!")
    .Build();

public async Task InitializeAsync()
{
    await _dbContainer.StartAsync();
    
    // The connection string is generated automatically
    var connectionString = _dbContainer.GetConnectionString();
    ApiFactory = new CustomApiFactory(connectionString);
    
    using var scope = ApiFactory.Services.CreateScope();
    var context = scope.ServiceProvider.GetRequiredService<LocalContext>();
    await context.Database.MigrateAsync();
    await context.SeedProductsAsync(50);
}

3.4 Custom SQL Server Image

# Dockerfile (preserve Unix line endings)
FROM mcr.microsoft.com/mssql/server:2022-latest

RUN mkdir -p /carvedrock

COPY *.sh /carvedrock/
COPY *.sql /carvedrock/

RUN chmod +x /carvedrock/*.sh

CMD /carvedrock/entrypoint.sh
# entrypoint.sh
#!/bin/bash
/opt/mssql/bin/sqlservr &
sleep 10
/carvedrock/setup.sh
wait
# setup.sh — waits for SQL Server to be ready, then runs SQL scripts
/carvedrock/wait-for-it.sh localhost:1433 -t 60 -- \
    /opt/mssql-tools/bin/sqlcmd -S localhost -U SA -P "$SA_PASSWORD" \
    -i /carvedrock/01_create_db.sql \
    -i /carvedrock/02_migrations.sql \
    -i /carvedrock/03_seed_data.sql
// Use the custom image in Testcontainers
private readonly IContainer _dbContainer = new ContainerBuilder()
    .WithImage("carvedrock-sqlserver:latest")
    .WithPortBinding(1433, 1433)
    .WithEnvironment("SA_PASSWORD", "StrongPassword@123!")
    .WithEnvironment("ACCEPT_EULA", "Y")
    .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(1433))
    .Build();

public string CustomSqlConnectionString =>
    $"Server=localhost,1433;Database=carvedrock;User Id=SA;Password=StrongPassword@123!;" +
    $"TrustServerCertificate=True";

4. UI Tests and External APIs

4.1 AngleSharp for HTML

// New project: CarvedRock.InnerLoop.WebApp.Tests

// Parse HTML with AngleSharp
public static async Task<IDocument> ParseHtmlAsync(this HttpResponseMessage response)
{
    var content = await response.Content.ReadAsStringAsync();
    var context = BrowsingContext.New(Configuration.Default);
    return await context.OpenAsync(req => req.Content(content));
}

// Usage in a test
[Fact]
public async Task HomePage_ReturnsExpectedTitle()
{
    var client = _factory.CreateClient();
    var response = await client.GetAsync("/");
    response.StatusCode.Should().Be(HttpStatusCode.OK);
    
    var doc = await response.ParseHtmlAsync();
    doc.Title.Should().Contain("Carved Rock Fitness");
    
    // Find an element with CSS selector
    var heroText = doc.QuerySelector("h1.hero-text")?.TextContent;
    heroText?.Trim().Should().Contain("GET A GRIP");
}

// Product page tests with AngleSharp
[Theory]
[InlineData("footwear")]
[InlineData("kayaking")]
[InlineData("climbing")]
public async Task ProductListPage_ForCategory_ShowsProducts(string category)
{
    var client = _fixture.WebAppFactory.CreateClient();
    var response = await client.GetAsync($"/listing?category={category}");
    response.StatusCode.Should().Be(HttpStatusCode.OK);
    
    var doc = await response.ParseHtmlAsync();
    
    // Select table rows (product names)
    var productNames = doc.QuerySelectorAll("table tbody tr td:first-child")
        .Select(td => td.TextContent.Trim())
        .ToList();
    
    productNames.Should().NotBeEmpty();
    
    // Check "Add to Cart" buttons
    var addToCartButtons = doc.QuerySelectorAll("table tbody tr button");
    addToCartButtons.Should().HaveCount(productNames.Count);
    addToCartButtons.All(b => b.TextContent.Contains("Add to Cart")).Should().BeTrue();
}

Redirect tests:

[Fact]
public async Task AdminPage_WhenAnonymous_RedirectsToIdentityServer()
{
    // Don't follow redirects
    var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
    {
        AllowAutoRedirect = false
    });
    
    var response = await client.GetAsync("/admin/products");
    
    response.StatusCode.Should().Be(HttpStatusCode.Redirect);
    response.Headers.Location?.ToString().Should().Contain("localhost:5001"); // IDP
}

[Fact]
public async Task AdminPage_WhenNotAdmin_ReturnsForbidden()
{
    var client = _factory.CreateClient();
    client.DefaultRequestHeaders.Add("X-Integration-Testing-Auth", "name:Bob Smith;idp:Microsoft");
    
    var response = await client.GetAsync("/admin/products");
    
    response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}

Tests with cookies:

[Fact]
public async Task CartPage_WithItems_ShowsCorrectTotal()
{
    var (cookie, expectedTotal) = CreateCartCookieAndTotal();
    
    var client = _factory.CreateClient();
    client.DefaultRequestHeaders.Add("X-Integration-Testing-Auth", "name:Alice;idp:local");
    client.DefaultRequestHeaders.Add("Cookie", $"carvedrock-cart={cookie}");
    
    var response = await client.GetAsync("/cart");
    response.StatusCode.Should().Be(HttpStatusCode.OK);
    
    var doc = await response.ParseHtmlAsync();
    var totalText = doc.QuerySelector(".grand-total")?.TextContent;
    totalText?.Should().Contain(expectedTotal.ToString("C"));
}

4.2 WireMock.Net for External APIs

// Install: WireMock.Net

// Configure WireMock in CustomWebAppFactory
public class CustomWebAppFactory : WebApplicationFactory<Program>
{
    public WireMockServer MockApiServer { get; private set; } = default!;
    
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        // Start WireMock on a random port
        MockApiServer = WireMockServer.Start();
        
        builder.ConfigureServices(services =>
        {
            // Point ProductService to WireMock instead of the real API
            services.Configure<ProductServiceSettings>(options =>
            {
                options.BaseUrl = MockApiServer.Url;
            });
        });
    }
    
    protected override void Dispose(bool disposing)
    {
        base.Dispose(disposing);
        MockApiServer.Stop();
    }
}
// Create a WireMock mapping
public static class WireMockMappings
{
    public static void SetupProductsMapping(this WireMockServer server,
        IEnumerable<Product> products)
    {
        server
            .Given(Request.Create()
                .WithPath("/api/product")
                .UsingGet())
            .RespondWith(Response.Create()
                .WithStatusCode(200)
                .WithHeader("Content-Type", "application/json")
                .WithBody(JsonSerializer.Serialize(products)));
    }
    
    public static void SetupProductsMappingFromFile(this WireMockServer server)
    {
        // Load mapping from a JSON file (generated by proxy recorder)
        server.ReadStaticMappings("./Mappings");
    }
}

// Usage in tests
[Fact]
public async Task ProductListPage_UsesRealProductService()
{
    _fixture.MockApiServer.SetupProductsMapping(_fixture.OriginalProducts
        .Where(p => p.Category == "footwear"));
    
    var client = _fixture.WebAppFactory.CreateClient();
    var response = await client.GetAsync("/listing?category=footwear");
    
    var doc = await response.ParseHtmlAsync();
    var productCount = doc.QuerySelectorAll("table tbody tr").Length;
    
    productCount.Should().Be(_fixture.OriginalProducts.Count(p => p.Category == "footwear"));
}

5. End-to-End Tests with Playwright

5.1 Introduction and Codegen

Create the NUnit Playwright project:

# Install browsers after creating the project
pwsh bin/Debug/net8.0/playwright.ps1 install
// First Playwright test
public class HomePageTests : PageTest
{
    [Test]
    public async Task HomePage_HasCorrectTitle()
    {
        await Page.GotoAsync("https://localhost:7224");
        await Expect(Page).ToHaveTitleAsync(new Regex("Carved Rock Fitness"));
        
        var heroText = Page.GetByText("GET A GRIP");
        await Expect(heroText).ToBeVisibleAsync();
    }
}

Generate code with Playwright codegen:

# Launch the recorder
pwsh bin/Debug/net8.0/playwright.ps1 codegen https://localhost:7224 --target csharp-nunit

5.2 Run Settings and Base URL

// BaseTest.cs — configurable URL via environment variable
public class BaseTest : PageTest
{
    protected string WebAppUrl { get; }
    
    public BaseTest()
    {
        WebAppUrl = Environment.GetEnvironmentVariable("E2E_BASE_URL") ?? "https://localhost:7224";
    }
}
<!-- .runsettings — provides the URL via test parameters -->
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
  <RunConfiguration>
    <EnvironmentVariables>
      <E2E_BASE_URL>https://localhost:7224</E2E_BASE_URL>
    </EnvironmentVariables>
  </RunConfiguration>
</RunSettings>

5.3 Debugging, Screenshots and Traces

// Complete test: add to cart
public class CartTests : BaseTest
{
    [Test]
    public async Task AddItemsToCart_ShowsCorrectCount()
    {
        await Page.GotoAsync($"{WebAppUrl}/listing?category=footwear");
        
        // Click "Add to Cart" for the first product
        await Page.Locator("table tbody tr:first-child button").ClickAsync();
        
        // Check cart counter
        await CheckCount(Page, "1");
    }
    
    private static async Task CheckCount(IPage page, string expectedCount)
    {
        var cartLocator = page.Locator("a[href*='cart']");
        await Expect(cartLocator).ToContainTextAsync(expectedCount);
    }
}

// Enable screenshots and traces
public class CancelCheckoutTests : BaseTest
{
    // Setup/Teardown for traces
    [SetUp]
    public async Task SetupTracing()
    {
        await Context.Tracing.StartAsync(new TracingStartOptions
        {
            Title = TestContext.CurrentContext.Test.Name,
            Screenshots = true,
            Snapshots = true,
            Sources = true
        });
    }
    
    [TearDown]
    public async Task StopTracing()
    {
        var tracePath = Path.Combine(
            TestContext.CurrentContext.WorkDirectory,
            "playwright-traces",
            $"{TestContext.CurrentContext.Test.Name}.zip");
        
        await Context.Tracing.StopAsync(new TracingStopOptions { Path = tracePath });
        TestContext.WriteLine($"Trace: {tracePath}");
    }
    
    [Test]
    public async Task LoggedInCheckoutCancellation()
    {
        // Go to the site
        await Page.GotoAsync(WebAppUrl);
        
        // Login
        await Page.ClickAsync("a:has-text('Login')");
        await Page.FillAsync("#Input_Username", "alice");
        await Page.FillAsync("#Input_Password", "Pass456$");
        await Page.ClickAsync("button[type='submit']");
        
        // Add products to cart
        await Page.GotoAsync($"{WebAppUrl}/listing?category=footwear");
        await Page.ClickAsync("table tbody tr:first-child button");
        
        await Page.GotoAsync($"{WebAppUrl}/listing?category=kayaks");
        await Page.ClickAsync("table tbody tr:first-child button");
        
        // Go to cart and cancel
        await Page.GotoAsync($"{WebAppUrl}/cart");
        await Page.ClickAsync("button:has-text('Cancel')");
        
        // Verify cart is empty
        await CheckCount(Page, "0");
        
        // Screenshot
        var screenshotPath = Path.Combine(
            TestContext.CurrentContext.WorkDirectory,
            $"{TestContext.CurrentContext.Test.Name}.png");
        await Page.ScreenshotAsync(new PageScreenshotOptions { Path = screenshotPath });
        TestContext.WriteLine($"Screenshot: {screenshotPath}");
    }
}

View a trace:

pwsh bin/Debug/net8.0/playwright.ps1 show-trace playwright-traces/MyTest.zip

6. Performance Testing with NBomber

6.1 First Load Test

// Program.cs — Console project CarvedRock.Perf.Tests
var httpClient = new HttpClient();

var scenario = Scenario.Create("get_product_list", async context =>
{
    var response = await httpClient.GetAsync(
        "https://localhost:7075/api/product?category=footwear");
    
    return response.IsSuccessStatusCode
        ? Response.Ok()
        : Response.Fail();
})
.WithWarmUpDuration(TimeSpan.FromSeconds(5))
.WithLoadSimulations(
    Simulation.Inject(rate: 100, interval: TimeSpan.FromSeconds(1), 
                     during: TimeSpan.FromSeconds(30))
);

NBomberRunner
    .RegisterScenarios(scenario)
    .Run();

6.2 HTTP Plugin and Metrics

// NBomber.Http makes HTTP requests easier and adds metrics
using var httpClient = new HttpClient();

var scenario = Scenario.Create("get_product_list", async context =>
{
    var request = Http.CreateRequest("GET", "https://localhost:7075/api/product")
        .WithHeader("Accept", "application/json");
    
    return await Http.Send(httpClient, request);
})
.WithWarmUpDuration(TimeSpan.FromSeconds(5))
.WithLoadSimulations(
    Simulation.RampingInject(rate: 500, interval: TimeSpan.FromSeconds(1),
                            during: TimeSpan.FromSeconds(120))
);

// Enable HTTP metrics
NBomberRunner
    .RegisterScenarios(scenario)
    .WithWorkerPlugins(new HttpMetricsPlugin(new[] { HttpVersion.Version1 }))
    .Run();

Data feeds to vary requests:

// Vary categories in requests
var categories = new[] { "footwear", "climbing", "kayaking" };
var categoryFeed = DataFeed.Circular(categories);

var scenario = Scenario.Create("get_products_by_category", async context =>
{
    var category = categoryFeed.GetNextItem(context.ScenarioInfo);
    
    var request = Http.CreateRequest("GET", 
        $"https://localhost:7075/api/product?category={category}");
    
    return await Http.Send(httpClient, request);
});

Multiple scenarios with JWT:

// Global initialization — get a JWT
private static string _globalJwt = string.Empty;

// Scenario with auth
var productByIdScenario = Scenario.Create("get_product_by_id", async context =>
{
    var productId = productIdsFeed.GetNextItem(context.ScenarioInfo);
    
    var request = Http.CreateRequest("GET", $"https://localhost:7075/api/product/{productId}")
        .WithHeader("Authorization", $"Bearer {_globalJwt}");
    
    return await Http.Send(httpClient, request);
})
.WithInit(async context =>
{
    // Get JWT via client credentials (IdentityModel)
    var tokenClient = new TokenClient(/* ... */);
    var tokenResponse = await tokenClient.RequestClientCredentialsTokenAsync(
        scope: "catalogapi.fullaccess");
    _globalJwt = tokenResponse.AccessToken!;
})
.WithLoadSimulations(
    Simulation.Inject(rate: 500, interval: TimeSpan.FromSeconds(1),
                     during: TimeSpan.FromMinutes(2))
);

// Run both scenarios in parallel
NBomberRunner
    .RegisterScenarios(listScenario, productByIdScenario)
    .WithWorkerPlugins(new HttpMetricsPlugin(new[] { HttpVersion.Version1 }))
    .Run();

6.3 InfluxDB and Grafana

// nbomber-config.json
{
  "InfluxDBSink": {
    "Url": "http://localhost:8086",
    "Database": "nbomber",
    "UserName": "",
    "Password": ""
  }
}
# docker-compose.yml
version: "3.8"
services:
  influxdb:
    image: influxdb:1.8.1
    ports:
      - "8086:8086"
    volumes:
      - influxdb-data:/var/lib/influxdb

  grafana:
    image: grafana/grafana:8.5.2
    ports:
      - "3010:3000"  # Host port 3010 (3000 already used by smtp4dev)
    volumes:
      - grafana-data:/var/lib/grafana

volumes:
  influxdb-data:
  grafana-data:
# Start InfluxDB and Grafana
docker-compose up -d

# Run tests in release mode for realistic metrics
dotnet run -c release

7. Test Execution Strategy

7.1 Test Hierarchy

┌─────────────────────────────────────────────────────────────────┐
│                       TEST PYRAMID                              │
│                                                                 │
│              /▲\                                                │
│             / E2E \         Playwright tests                    │
│            /───────\        (expensive, slow)                   │
│           / Integr.  \      WebApplicationFactory               │
│          /────────────\     (fast, good coverage)               │
│         /   Unit tests \    xUnit + NSubstitute + Bogus         │
│        /────────────────\   (very fast, business logic)         │
└─────────────────────────────────────────────────────────────────┘

7.2 Inner Loop (Developer Local)

1. Unit + integration tests → run often (< 30s)
2. Coverage → target > 80%
3. Testcontainers → realistic DB without manual setup
4. UI tests → for complex pages

7.3 CI/CD Pipeline — Azure DevOps

# pipelines/pull-request.yml
pool:
  vmImage: "ubuntu-latest"  # Supports Docker for Testcontainers

trigger:
  branches:
    include:
      - main

pr:
  branches:
    include:
      - main

steps:
  - task: DotNetCoreCLI@2
    displayName: "Run tests"
    inputs:
      command: test
      arguments: '--settings tests/coverlet.runsettings'

  - task: reportgenerator@5
    displayName: "Generate coverage report"
    inputs:
      reports: "**/coverage.cobertura.xml"
      targetdir: "coverage"
      reporttypes: "HtmlInline_AzurePipelines;Cobertura"

  - task: PublishCodeCoverageResults@1
    displayName: "Publish coverage"
    inputs:
      codeCoverageTool: Cobertura
      summaryFileLocation: "coverage/Cobertura.xml"

7.4 CI/CD Pipeline — GitHub Actions

# .github/workflows/test.yml
name: Tests and Coverage

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest  # Docker supported

    steps:
      - uses: actions/checkout@v3
      
      - name: Setup .NET
        uses: actions/setup-dotnet@v3
        with:
          dotnet-version: 8.0.x
      
      - name: Restore dependencies
        run: dotnet restore
      
      - name: Build
        run: dotnet build --no-restore
      
      - name: Run tests with coverage
        run: dotnet test --settings tests/coverlet.runsettings --no-build
      
      - name: Generate coverage report
        run: |
          dotnet tool install -g dotnet-reportgenerator-globaltool
          reportgenerator -reports:"**/coverage.cobertura.xml" \
            -targetdir:coverage \
            -reporttypes:"HtmlInline;Cobertura;MarkdownSummaryGithub"
      
      - name: Show coverage in summary
        run: cat coverage/SummaryGithub.md >> $GITHUB_STEP_SUMMARY

7.5 Badges (Shields.io)

<!-- In the README -->
[![Build Status](https://dev.azure.com/org/project/_apis/build/status/21)](...)
[![Coverage](https://shields.io/azure-devops/coverage/knowyourtoolset/automation-testing-strategies/21)](...)
[![Tests](https://shields.io/azure-devops/tests/knowyourtoolset/automation-testing-strategies/21)](...)

7.6 E2E Recommendations

- Use ephemeral environments (spin up → test → tear down)
- Predictable data via seed scripts
- DO NOT test directly in production (or with great caution)
- Playwright codegen to generate base code → adjust manually
- Validate that E2E tests cover what's difficult to test in integration:
  → Multi-page navigation
  → Client-side JavaScript (cookies, counters)
  → Real authentication

NuGet Packages Used

<!-- Unit/integration tests -->
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Bogus" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="coverlet.collector" />

<!-- Testcontainers -->
<PackageReference Include="Testcontainers.PostgreSql" />
<PackageReference Include="Testcontainers.MsSql" />
<PackageReference Include="Testcontainers" />

<!-- UI tests -->
<PackageReference Include="AngleSharp" />
<PackageReference Include="WireMock.Net" />

<!-- E2E -->
<PackageReference Include="Microsoft.Playwright.NUnit" />

<!-- Performance -->
<PackageReference Include="NBomber" />
<PackageReference Include="NBomber.Http" />
<PackageReference Include="NBomber.Data" />
<PackageReference Include="NBomber.Sinks.InfluxDB" />

8. Advanced Testing Strategies

graph TD
    E2E["E2E Tests\nPlaywright\n~10% of tests\nSlow, expensive\nTest complete flows"]
    INT["Integration tests\nWebApplicationFactory + Testcontainers\n~30% of tests\nMedium, realistic"]
    UNIT["Unit tests\nxUnit + Moq/NSubstitute\n~60% of tests\nFast, isolated, numerous"]
    PERF["Performance tests\nNBomber\nAd hoc, before release"]

    E2E --> INT --> UNIT
    PERF -.-> E2E

8.2 Test Type Summary Table

TypeFrameworkSpeedIsolationCoverageMockingUsage
UnitxUnit, MSTest⚡ Very fastTotalBusiness logicHighValidation, calculation, rules
IntegrationxUnit + WebApplicationFactory🔄 MediumPartialAPI, middlewareLowEndpoints, auth, DB
TestcontainersxUnit + Testcontainers🐢 SlowRealDB, servicesMinimalSQL queries, migrations
UIAngleSharp + WebApplicationFactory🔄 MediumPartialRazor pagesWireMockHTML content, navigation
E2EPlaywright NUnit🐢 Very slowNoneComplete flowsNoneUser scenarios
PerformanceNBomber⚙️ VariableNoneLoad, stressNoneBenchmarks, scalability

Solution/
├── src/
│   ├── CarvedRock.Api/
│   └── CarvedRock.WebApp/
├── tests/
│   ├── CarvedRock.InnerLoop.Tests/          ← Unit + API integration tests
│   │   ├── Utilities/
│   │   │   ├── HttpClientExtensions.cs
│   │   │   ├── CustomApiFactory.cs
│   │   │   ├── SharedFixture.cs
│   │   │   ├── DataCreator.cs
│   │   │   └── ProductFakers.cs
│   │   ├── Unit/
│   │   │   ├── ProductValidatorTests.cs
│   │   │   └── OrderCalculatorTests.cs
│   │   └── Integration/
│   │       ├── ProductControllerTests.cs
│   │       └── AuthorizationTests.cs
│   └── CarvedRock.InnerLoop.WebApp.Tests/   ← UI tests with AngleSharp + WireMock
│       ├── Utilities/
│       │   ├── CustomWebAppFactory.cs
│       │   └── SharedFixture.cs
│       ├── HomePageTests.cs
│       ├── ProductListingTests.cs
│       ├── CartPageTests.cs
│       └── CheckoutTests.cs
├── e2e/
│   └── CarvedRock.EndToEnd.Tests/           ← Playwright tests
│       ├── Utilities/
│       │   └── BaseTest.cs
│       ├── HomePageTests.cs
│       ├── CartFlowTests.cs
│       └── AdminTests.cs
└── perf/
    └── CarvedRock.Performance/              ← NBomber tests
        ├── GetProductsScenario.cs
        └── Program.cs

10. Unit Tests — In-Depth Examples

10.1 xUnit — Essential Patterns

public class ProductValidatorTests
{
    private readonly Mock<IProductRepository> _repositoryMock;
    private readonly ProductValidator _validator;

    public ProductValidatorTests()
    {
        _repositoryMock = new Mock<IProductRepository>();
        _repositoryMock
            .Setup(r => r.IsNameUniqueAsync(It.IsAny<string>()))
            .ReturnsAsync(true);
        
        _validator = new ProductValidator(_repositoryMock.Object);
    }

    [Fact]
    public async Task Validate_WithValidProduct_ReturnsSuccess()
    {
        // Arrange
        var product = new NewProductModel
        {
            Name = "Trail Boots Pro",
            Description = "High performance trail boots",
            Price = 149.99m,
            Category = "Footwear"
        };

        // Act
        var result = await _validator.ValidateAsync(product);

        // Assert
        result.IsValid.Should().BeTrue();
    }

    [Theory]
    [InlineData("", "Name is required")]
    [InlineData(" ", "Name is required")]
    [InlineData(null, "Name is required")]
    [InlineData("AB", "Name must be at least 3 characters")]
    public async Task Validate_WithInvalidName_ReturnsValidationError(
        string name, string expectedError)
    {
        // Arrange
        var product = new NewProductModel { Name = name, Price = 10.00m };

        // Act
        var result = await _validator.ValidateAsync(product);

        // Assert
        result.IsValid.Should().BeFalse();
        result.Errors.Should().Contain(e => e.ErrorMessage == expectedError);
    }

    [Theory]
    [MemberData(nameof(InvalidPriceData))]
    public async Task Validate_WithInvalidPrice_ReturnsValidationError(
        decimal price, string expectedError)
    {
        var product = new NewProductModel { Name = "Valid Name", Price = price };
        var result = await _validator.ValidateAsync(product);
        result.IsValid.Should().BeFalse();
        result.Errors.Should().Contain(e => e.ErrorMessage == expectedError);
    }

    public static IEnumerable<object[]> InvalidPriceData =>
        new List<object[]>
        {
            new object[] { -1m, "Price must be positive" },
            new object[] { 0m, "Price must be greater than 0" },
            new object[] { 100001m, "Price cannot exceed $100,000" }
        };

    [Fact]
    public async Task Validate_WhenNameAlreadyExists_ReturnsUniqueNameError()
    {
        // Arrange — Override mock to return false (name already exists)
        _repositoryMock
            .Setup(r => r.IsNameUniqueAsync("Existing Product"))
            .ReturnsAsync(false);

        var product = new NewProductModel { Name = "Existing Product", Price = 50m };

        // Act
        var result = await _validator.ValidateAsync(product);

        // Assert
        result.IsValid.Should().BeFalse();
        result.Errors.Should().Contain(e => e.ErrorMessage.Contains("already exists"));
        
        // Verify the repository was called
        _repositoryMock.Verify(r => r.IsNameUniqueAsync("Existing Product"), Times.Once);
    }
}

10.2 Tests with NSubstitute (alternative to Moq)

// NSubstitute — more fluent syntax
public class OrderServiceTests
{
    private readonly IOrderRepository _orderRepo;
    private readonly IEmailService _emailService;
    private readonly OrderService _service;

    public OrderServiceTests()
    {
        // NSubstitute creates substitutes (mocks) automatically
        _orderRepo = Substitute.For<IOrderRepository>();
        _emailService = Substitute.For<IEmailService>();
        _service = new OrderService(_orderRepo, _emailService);
    }

    [Fact]
    public async Task CreateOrder_WithValidCart_SendsConfirmationEmail()
    {
        // Arrange
        var cart = new CartContents { Items = new List<CartItem> { /* ... */ } };
        var customer = new CustomerInfo { Email = "bob@example.com" };
        
        _orderRepo.SaveOrderAsync(Arg.Any<Order>()).Returns(Guid.NewGuid());

        // Act
        await _service.CreateOrderAsync(cart, customer);

        // Assert — Verify that the email was sent
        await _emailService.Received(1)
            .SendOrderConfirmationAsync(
                Arg.Is<string>(email => email == "bob@example.com"),
                Arg.Any<Order>());
    }
    
    [Fact]
    public async Task CreateOrder_WhenDbFails_ThrowsOrderException()
    {
        // Arrange
        _orderRepo.SaveOrderAsync(Arg.Any<Order>())
            .Throws(new DbException("Connection failed"));
        
        // Act & Assert
        await Assert.ThrowsAsync<OrderProcessingException>(
            () => _service.CreateOrderAsync(new CartContents(), new CustomerInfo()));
    }
}

11. Integration Tests — WebApplicationFactory Patterns

11.1 Custom Factory with Service Replacement

// Utility/CustomApiFactory.cs
public class CustomApiFactory : WebApplicationFactory<Program>
{
    public string ConnectionString { get; set; } = string.Empty;

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("InnerLoopTest");
        
        builder.ConfigureServices(services =>
        {
            var descriptor = services.SingleOrDefault(
                d => d.ServiceType == typeof(DbContextOptions<LocalContext>));
            if (descriptor != null)
                services.Remove(descriptor);

            services.AddDbContext<LocalContext>(options =>
            {
                if (!string.IsNullOrEmpty(ConnectionString))
                    options.UseNpgsql(ConnectionString);
                else
                    options.UseSqlite("Data Source=:memory:");
            });

            services.AddTransient<IStartupFilter, AuthTestStartupFilter>();
        });
    }
}

public class AuthTestStartupFilter : IStartupFilter
{
    public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
    {
        return builder =>
        {
            builder.UseMiddleware<TestAuthMiddleware>();
            next(builder);
        };
    }
}

public class TestAuthMiddleware : IMiddleware
{
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        if (context.Request.Headers.TryGetValue("X-Test-Username", out var username) &&
            context.Request.Headers.TryGetValue("X-Test-Role", out var role))
        {
            var claims = new List<Claim>
            {
                new(ClaimTypes.Name, username!),
                new(ClaimTypes.Role, role!),
                new(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString())
            };
            context.User = new ClaimsPrincipal(
                new ClaimsIdentity(claims, "TestScheme"));
        }

        await next(context);
    }
}

11.2 SharedFixture for Shared Test Data

// Utility/SharedFixture.cs
public class SharedFixture : IAsyncLifetime
{
    private PostgreSqlContainer? _postgresContainer;
    public string ConnectionString { get; private set; } = string.Empty;
    public List<Product> OriginalProducts { get; private set; } = new();

    public async Task InitializeAsync()
    {
        _postgresContainer = new PostgreSqlBuilder()
            .WithDatabase("carvedrock_test")
            .WithUsername("testuser")
            .WithPassword("testpassword")
            .Build();
        
        await _postgresContainer.StartAsync();
        ConnectionString = _postgresContainer.GetConnectionString();
        
        var options = new DbContextOptionsBuilder<LocalContext>()
            .UseNpgsql(ConnectionString)
            .Options;
        
        using var context = new LocalContext(options);
        await context.Database.MigrateAsync();
        
        await context.GenerateProductsAsync(50);
        OriginalProducts = await context.Products.AsNoTracking().ToListAsync();
    }

    public async Task DisposeAsync()
    {
        if (_postgresContainer != null)
            await _postgresContainer.DisposeAsync();
    }
}

[CollectionDefinition("InnerLoopTests")]
public class InnerLoopCollection : ICollectionFixture<SharedFixture> { }

[Collection("InnerLoopTests")]
public class ProductControllerTests
{
    private readonly SharedFixture _fixture;
    private readonly CustomApiFactory _factory;
    private readonly HttpClient _client;

    public ProductControllerTests(SharedFixture fixture)
    {
        _fixture = fixture;
        _factory = new CustomApiFactory { ConnectionString = fixture.ConnectionString };
        _client = _factory.CreateClient();
    }

    [Fact]
    public async Task GetProducts_ReturnsAllProducts()
    {
        var products = await _client.GetJsonResultAsync<List<ProductDto>>(
            "/product", HttpStatusCode.OK);

        products.Should().HaveCountGreaterThanOrEqualTo(_fixture.OriginalProducts.Count);
    }
}

12. Playwright Tests — In-Depth Guide

12.1 E2E Test Suite Structure

// e2e/BaseTest.cs
public class BaseTest : PageTest
{
    protected string BaseUrl { get; }
    
    public BaseTest()
    {
        var envUrl = Environment.GetEnvironmentVariable("APP_BASE_URL");
        BaseUrl = !string.IsNullOrEmpty(envUrl) ? envUrl : "https://localhost:7224";
    }
    
    protected async Task LoginAsUserAsync(string username = "alice", string password = "password")
    {
        await Page.GotoAsync($"{BaseUrl}/login");
        await Page.FillAsync("input[name='username']", username);
        await Page.FillAsync("input[name='password']", password);
        await Page.ClickAsync("button[type='submit']");
        await Page.WaitForURLAsync($"{BaseUrl}/");
    }
    
    protected async Task<int> GetCartCountAsync()
    {
        var cartLink = Page.Locator("a:has-text('Cart')");
        var text = await cartLink.TextContentAsync() ?? "0";
        var match = System.Text.RegularExpressions.Regex.Match(text, @"\((\d+)\)");
        return match.Success ? int.Parse(match.Groups[1].Value) : 0;
    }
}

// e2e/CartFlowTests.cs
public class CartFlowTests : BaseTest
{
    [Test]
    public async Task AddToCart_IncreasesCartCount()
    {
        // Arrange
        await Page.GotoAsync($"{BaseUrl}/listing?category=footwear");
        var initialCount = await GetCartCountAsync();
        
        // Act
        await Page.ClickAsync("button:has-text('Add to Cart')");
        await Page.WaitForLoadStateAsync(LoadState.NetworkIdle);
        
        // Assert
        var newCount = await GetCartCountAsync();
        newCount.Should().Be(initialCount + 1);
    }

    [Test]
    public async Task Checkout_CompletesOrderAndSendsEmail()
    {
        // Arrange — login and fill cart
        await LoginAsUserAsync("alice");
        await Page.GotoAsync($"{BaseUrl}/listing?category=kayaks");
        await Page.ClickAsync("button:has-text('Add to Cart')");
        
        // Act — proceed to checkout
        await Page.ClickAsync("a:has-text('Cart')");
        await Page.ClickAsync("button:has-text('Checkout')");
        await Page.ClickAsync("button:has-text('Confirm Order')");
        
        // Assert — confirmation page
        await Expect(Page.Locator("h1")).ToContainTextAsync("Thank You");
    }
}

13. NBomber Performance Tests — Complete Guide

13.1 Progressive Load Scenarios

// perf/Program.cs
var httpClient = new HttpClient { BaseAddress = new Uri("https://localhost:7186") };

var listScenario = Scenario.Create("get_product_list", async context =>
{
    var categories = new[] { "footwear", "kayaks", "climbing" };
    var category = categories[context.InvocationNumber % categories.Length];
    
    var request = Http.CreateRequest("GET", $"/product?category={category}");
    var response = await Http.Send(httpClient, request);
    
    return response.IsSuccessStatusCode
        ? Response.Ok(statusCode: (int)response.StatusCode)
        : Response.Fail(statusCode: (int)response.StatusCode);
})
.WithLoadSimulations(
    // Phase 1: Ramp up — 0 to 100 req/s over 30s
    Simulation.RampingInject(rate: 100, interval: TimeSpan.FromSeconds(1),
        during: TimeSpan.FromSeconds(30)),
    // Phase 2: Steady load — 100 req/s for 60s  
    Simulation.Inject(rate: 100, interval: TimeSpan.FromSeconds(1),
        during: TimeSpan.FromSeconds(60)),
    // Phase 3: Ramp down — 100 to 0 req/s over 20s
    Simulation.RampingInject(rate: 0, interval: TimeSpan.FromSeconds(1),
        during: TimeSpan.FromSeconds(20))
);

// Assert on thresholds
var stats = NBomberRunner.Run(listScenario);
var listStats = stats.ScenarioStats.First(s => s.ScenarioName == "get_product_list");

Assert.IsTrue(listStats.Ok.Latency.Percentile99 < 500, // P99 < 500ms
    $"P99 latency too high: {listStats.Ok.Latency.Percentile99}ms");
Assert.IsTrue(listStats.Ok.RPS > 80, // Throughput > 80 req/s
    $"Throughput too low: {listStats.Ok.RPS} req/s");

14. CI/CD Pipelines — Complete Examples

14.1 GitHub Actions — Complete Pipeline

# .github/workflows/pr.yml
name: Pull Request Tests

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup .NET 8
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'
      
      - name: Restore dependencies
        run: dotnet restore
      
      - name: Build solution
        run: dotnet build --no-restore
      
      - name: Run unit and integration tests
        run: |
          dotnet test tests/CarvedRock.InnerLoop.Tests \
            --no-build \
            --collect:"XPlat Code Coverage" \
            --settings tests/.runsettings \
            --logger "trx;LogFileName=test-results.trx"
      
      - name: Generate coverage report
        run: |
          dotnet tool install -g dotnet-reportgenerator-globaltool
          reportgenerator \
            -reports:"**/coverage.cobertura.xml" \
            -targetdir:"coverage-report" \
            -reporttypes:"Html;MarkdownSummaryGithub;Cobertura"
      
      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          files: "**/coverage.cobertura.xml"
      
      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results
          path: |
            **/test-results.trx
            coverage-report/
      
      - name: Add coverage summary to PR
        if: github.event_name == 'pull_request'
        run: cat coverage-report/SummaryGithub.md >> $GITHUB_STEP_SUMMARY

14.2 Azure DevOps — YAML Pipeline

# pipelines/pull-request.yml
trigger:
  branches:
    include:
      - main

pr:
  branches:
    include:
      - main

pool:
  vmImage: ubuntu-latest

variables:
  buildConfiguration: Release
  testProjects: 'tests/**/*.Tests.csproj'

steps:
  - task: UseDotNet@2
    displayName: 'Setup .NET 8'
    inputs:
      packageType: sdk
      version: 8.0.x

  - task: DotNetCoreCLI@2
    displayName: 'Restore NuGet'
    inputs:
      command: restore

  - task: DotNetCoreCLI@2
    displayName: 'Build solution'
    inputs:
      command: build
      arguments: '--configuration $(buildConfiguration) --no-restore'

  - task: DotNetCoreCLI@2
    displayName: 'Run tests with coverage'
    inputs:
      command: test
      projects: '$(testProjects)'
      arguments: >
        --configuration $(buildConfiguration)
        --no-build
        --collect:"XPlat Code Coverage"
        --settings tests/.runsettings
        --logger "trx"

  - task: reportgenerator@5
    displayName: 'Generate HTML coverage report'
    inputs:
      reports: '**/coverage.cobertura.xml'
      targetdir: '$(Build.ArtifactStagingDirectory)/coverage'
      reporttypes: 'HtmlInline_AzurePipelines;Cobertura'

  - task: PublishTestResults@2
    displayName: 'Publish test results'
    inputs:
      testResultsFormat: VSTest
      testResultsFiles: '**/*.trx'

  - task: PublishCodeCoverageResults@2
    displayName: 'Publish code coverage'
    inputs:
      codeCoverageTool: Cobertura
      summaryFileLocation: '**/coverage.cobertura.xml'
      reportDirectory: '$(Build.ArtifactStagingDirectory)/coverage'

15. Best Practices and Test Anti-Patterns

15.1 The 7 Commandments of Automated Testing

#RuleBest PracticeAnti-pattern
1ReadabilityDescriptive test names: CreateOrder_WhenCartIsEmpty_ThrowsExceptionTest1(), TestMethod_ABC()
2IndependenceEach test can run alone, in any orderTests depending on another test’s state
3DeterminismSame result every executionFlaky tests with mutable shared data
4SpeedUnit tests < 1ms, integration < 1sTests that do unnecessary network I/O
5UniquenessOnly one concept tested per testTest that checks 10 different behaviors
6MaintainabilityAbstractions in helpers, not in testsDuplicated code in every test
7Meaningful coverageTest branches, edge cases, errors100% coverage without testing negative cases

15.2 Common Pitfalls

// ❌ ANTI-PATTERN: Test that tests implementation, not behavior
[Fact]
public async Task Validate_CallsRepositoryOnce()
{
    var product = new NewProductModel { Name = "Test", Price = 10m };
    await _validator.ValidateAsync(product);
    // This test will break if we refactor the internal implementation
    _mock.Verify(r => r.CheckNameAsync(product.Name), Times.Exactly(1));
}

// ✅ GOOD: Test observable behavior (result)
[Fact]
public async Task Validate_WithUniqueName_ReturnsValid()
{
    var product = new NewProductModel { Name = "Unique Name", Price = 10m };
    var result = await _validator.ValidateAsync(product);
    result.IsValid.Should().BeTrue();
}

16. Test Data Generation with Bogus

// Utility/ProductFakers.cs — Centralized and reusable
public static class ProductFakers
{
    public static Faker<NewProductModel> NewProduct => new Faker<NewProductModel>()
        .RuleFor(p => p.Name, f => f.Commerce.ProductName())
        .RuleFor(p => p.Description, f => f.Commerce.ProductDescription())
        .RuleFor(p => p.Price, f => f.Finance.Amount(5, 500))
        .RuleFor(p => p.Category, f => f.PickRandom("footwear", "kayaks", "climbing"))
        .RuleFor(p => p.ImgUrl, f => f.Image.PicsumUrl(200, 200));

    public static Faker<CustomerInfo> Customer => new Faker<CustomerInfo>()
        .RuleFor(c => c.FirstName, f => f.Name.FirstName())
        .RuleFor(c => c.LastName, f => f.Name.LastName())
        .RuleFor(c => c.Email, (f, c) => f.Internet.Email(c.FirstName, c.LastName));

    public static List<NewProductModel> GenerateProducts(int count = 10)
        => NewProduct.Generate(count);
    
    public static NewProductModel WithBlankName()
        => NewProduct.Clone().RuleFor(p => p.Name, "").Generate();
    
    public static NewProductModel WithNegativePrice()
        => NewProduct.Clone().RuleFor(p => p.Price, -1m).Generate();
}

[Fact]
public async Task CreateProduct_WithValidData_Returns201()
{
    var newProduct = ProductFakers.NewProduct.Generate();
    var response = await _client.PostAsJsonAsync("/product", newProduct);
    response.StatusCode.Should().Be(HttpStatusCode.Created);
}

[Theory]
[MemberData(nameof(InvalidProductData))]
public async Task CreateProduct_WithInvalidData_Returns400(NewProductModel product)
{
    var response = await _client.PostAsJsonAsync("/product", product);
    response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}

public static IEnumerable<object[]> InvalidProductData =>
    new List<object[]>
    {
        new object[] { ProductFakers.WithBlankName() },
        new object[] { ProductFakers.WithNegativePrice() }
    };

17. Testing Terminology Glossary

TermDefinition
AAA (Arrange-Act-Assert)Standard test structure: preparation, execution, verification
AngleSharp.NET library for parsing and querying HTML with CSS selectors
BogusLibrary for generating realistic test data for .NET
Code CoveragePercentage of source code executed by tests
CoberturaStandard XML format for code coverage reports
Deterministic testTest that gives the same result on every execution
FactxUnit attribute for a simple test without parameters
FixtureShared test environment between multiple tests in the same class
Flaky testTest that fails intermittently without code changes
IClassFixturexUnit interface for sharing context between class tests
Integration testTest that verifies the interaction between multiple real components
LoadSimulationNBomber — definition of a load profile (inject, ramp, etc.)
MockSimulated object that replaces a dependency in tests
MSTestMicrosoft testing framework included in .NET
NBomber.NET framework for load and performance testing
NSubstituteMocking library for .NET with fluent syntax
NUnitPopular testing framework for .NET
PlaywrightMicrosoft’s multi-browser E2E testing framework
ScenarioNBomber — definition of a load test scenario
SharedFixtureFixture shared between multiple test classes via ICollectionFixture
ShouldlyAssertions library with readable error messages
TestcontainersLibrary for creating dependencies (DB, etc.) in containers for tests
TheoryxUnit attribute for a parameterized test (executed multiple times with different data)
Unit testIsolated test of a single code unit (class/method)
WebApplicationFactoryASP.NET Core class for creating an in-memory test instance of the application
WireMock.NetLibrary for simulating external API responses in tests
xUnitModern unit testing framework for .NET

Additional Resources


Search Terms

asp.net · automation · testing · strategies · core · patterns · architecture · c# · .net · development · test · tests · pipeline · ci/cd · data · integration · testcontainers · unit · xunit · actions · apis · azure · custom · devops

Interested in this course?

Contact us to book it or get a custom training plan for your team.