Demo application: CarvedRock Fitness (API + Razor Pages UI + EF Core + OpenID Connect) ASP.NET Core 8, Visual Studio 2022
Table of Contents
- Overview and Demo Application
- Unit Tests and Integration Tests
- Test Data Management and Testcontainers
- UI Tests and External APIs
- End-to-End Tests with Playwright
- Performance Testing with NBomber
- 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 -->
[](...)
[](...)
[](...)
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
8.1 Recommended Test Pyramid
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
| Type | Framework | Speed | Isolation | Coverage | Mocking | Usage |
|---|---|---|---|---|---|---|
| Unit | xUnit, MSTest | ⚡ Very fast | Total | Business logic | High | Validation, calculation, rules |
| Integration | xUnit + WebApplicationFactory | 🔄 Medium | Partial | API, middleware | Low | Endpoints, auth, DB |
| Testcontainers | xUnit + Testcontainers | 🐢 Slow | Real | DB, services | Minimal | SQL queries, migrations |
| UI | AngleSharp + WebApplicationFactory | 🔄 Medium | Partial | Razor pages | WireMock | HTML content, navigation |
| E2E | Playwright NUnit | 🐢 Very slow | None | Complete flows | None | User scenarios |
| Performance | NBomber | ⚙️ Variable | None | Load, stress | None | Benchmarks, scalability |
9. Recommended Test Project Architecture
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
| # | Rule | Best Practice | Anti-pattern |
|---|---|---|---|
| 1 | Readability | Descriptive test names: CreateOrder_WhenCartIsEmpty_ThrowsException | Test1(), TestMethod_ABC() |
| 2 | Independence | Each test can run alone, in any order | Tests depending on another test’s state |
| 3 | Determinism | Same result every execution | Flaky tests with mutable shared data |
| 4 | Speed | Unit tests < 1ms, integration < 1s | Tests that do unnecessary network I/O |
| 5 | Uniqueness | Only one concept tested per test | Test that checks 10 different behaviors |
| 6 | Maintainability | Abstractions in helpers, not in tests | Duplicated code in every test |
| 7 | Meaningful coverage | Test branches, edge cases, errors | 100% 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
| Term | Definition |
|---|---|
| AAA (Arrange-Act-Assert) | Standard test structure: preparation, execution, verification |
| AngleSharp | .NET library for parsing and querying HTML with CSS selectors |
| Bogus | Library for generating realistic test data for .NET |
| Code Coverage | Percentage of source code executed by tests |
| Cobertura | Standard XML format for code coverage reports |
| Deterministic test | Test that gives the same result on every execution |
| Fact | xUnit attribute for a simple test without parameters |
| Fixture | Shared test environment between multiple tests in the same class |
| Flaky test | Test that fails intermittently without code changes |
| IClassFixture | xUnit interface for sharing context between class tests |
| Integration test | Test that verifies the interaction between multiple real components |
| LoadSimulation | NBomber — definition of a load profile (inject, ramp, etc.) |
| Mock | Simulated object that replaces a dependency in tests |
| MSTest | Microsoft testing framework included in .NET |
| NBomber | .NET framework for load and performance testing |
| NSubstitute | Mocking library for .NET with fluent syntax |
| NUnit | Popular testing framework for .NET |
| Playwright | Microsoft’s multi-browser E2E testing framework |
| Scenario | NBomber — definition of a load test scenario |
| SharedFixture | Fixture shared between multiple test classes via ICollectionFixture |
| Shouldly | Assertions library with readable error messages |
| Testcontainers | Library for creating dependencies (DB, etc.) in containers for tests |
| Theory | xUnit attribute for a parameterized test (executed multiple times with different data) |
| Unit test | Isolated test of a single code unit (class/method) |
| WebApplicationFactory | ASP.NET Core class for creating an in-memory test instance of the application |
| WireMock.Net | Library for simulating external API responses in tests |
| xUnit | Modern unit testing framework for .NET |
Additional Resources
- xUnit Documentation
- Testcontainers for .NET
- Playwright for .NET
- NBomber
- WireMock.Net
- Bogus — data generator
- Code coverage with Coverlet
- ReportGenerator
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