crefft Source 2026.10.4

C# Style Guide

crefft > Languages > C#

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.

Extends Google C# Style Guide and Microsoft C# Coding Conventions.

Quick Reference

TaskToolCommand
LintRoslynator1via NuGet
Formatdotnet format2dotnet format
Type checkbuilt-indotnet build
SemanticSonarAnalyzer3via NuGet
Dead codebuilt-inIDE0051, IDE0052
Coveragecoverlet4dotnet test --collect:"XPlat Code Coverage"
Complexity-via Roslynator1
FuzzSharpFuzz5sharpfuzz
Test perfdotnet testdotnet test -- RunConfiguration.MaxCpuCount=0

C# 14 Features

Projects targeting .NET 10 SHOULD adopt C# 14 features where they improve clarity and reduce boilerplate.

Extension Members

C# 14 introduces extension blocks that allow defining properties, static members, and operators on existing types:

Do:

using System;
using System.Collections.Generic;
using System.Linq;

// Extension blocks live in a top-level, non-generic static class
public static class EnumerableExtensions
{
    // The block declares its own type parameters and constraints
    extension<T>(IEnumerable<T> source) where T : struct
    {
        public bool IsEmpty => !source.Any();
        public int SafeCount => source?.Count() ?? 0;

        public static IEnumerable<T> Empty => Enumerable.Empty<T>();
    }
}

// Usage
public static class Report
{
    public static void Print()
    {
        var numbers = new[] { 1, 2, 3 };
        if (!numbers.IsEmpty)
        {
            Console.WriteLine($"Count: {numbers.SafeCount}");
        }
    }
}

Don’t:

// Don't use extension blocks for single simple methods
// - use traditional extension methods instead
public static class StringExtensions
{
    extension(string s)
    {
        public bool IsNullOrEmpty => string.IsNullOrEmpty(s);
    }
}

// Better as traditional extension method
public static class StringHelpers
{
    public static bool IsNullOrEmpty(this string s) => string.IsNullOrEmpty(s);
}

Why a containing static class and an explicit type parameter

An extension block MUST be declared inside a top-level, non-generic static class, and MUST declare its own type parameters. A bare extension(...) block is a compile error: the compiler reports CS9283 outside such a class, and CS0080 plus CS0246 when the block uses T without declaring extension<T>. The receiver parameter does not introduce type parameters.

Field Keyword

The field keyword provides direct access to auto-property backing fields. The backing field has the property’s own type, so field ??= ... compiles only when that type is nullable, and a field-backed non-nullable property MUST be required or carry an initializer or the compiler reports CS9264.

Do:

using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;

public class User : INotifyPropertyChanged
{
    // Validate on set while keeping auto-property syntax
    public required string Email
    {
        get;
        set => field = value?.ToLowerInvariant()
            ?? throw new ArgumentNullException(nameof(value));
    }

    // Notify on change
    public required string Name
    {
        get;
        set
        {
            if (field != value)
            {
                field = value;
                OnPropertyChanged();
            }
        }
    }

    // DateTime has no null state, so seed the backing field from an
    // initializer; `field ??= DateTime.UtcNow` does not compile
    public DateTime CreatedAt { get; init; } = DateTime.UtcNow;

    // Lazy initialization needs a nullable property so `??=` has a null to test
    public string? Slug
    {
        get => field ??= Name.ToLowerInvariant().Replace(' ', '-');
    }

    public event PropertyChangedEventHandler? PropertyChanged;

    private void OnPropertyChanged([CallerMemberName] string? name = null) =>
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

Don’t:

using System;

// Don't trade a mutable property for an immutable one without saying so:
// the null check below runs on every assignment, at run time
public class MutableOrder
{
    public required string Id
    {
        get;
        set => field = value ?? throw new ArgumentNullException(nameof(value));
    }
}

// Better when the value must not change after construction: the compiler
// enforces it at every call site instead
public class ImmutableOrder
{
    public required string Id { get; init; }
}

// A member already named `field` is shadowed inside an accessor (CS9258):
// write `this.field` or `@field` to reach it
public class Legacy
{
    private readonly string field = "backing";

    public string Value => this.field;
}

Why required rather than an unchecked setter

required string Id { get; init; } and a mutable property with a null-rejecting setter are not interchangeable. The first is checked by the compiler at every object initializer and cannot change afterwards; the second throws at run time and stays writable. Projects MUST choose init only where the value is genuinely set once.

Projects that already declare a member named field MUST migrate it or qualify every accessor reference. In language version 14.0 the compiler reports CS9258 and binds field to a synthesized backing field, so this.field or @field is required to reach the existing member.

Null-Conditional Assignment

The ?.= operator assigns only if the left side is non-null:

Do:

public class OrderProcessor
{
    public void UpdateOrder(Order? order, string status)
    {
        // Only assigns if order is not null
        order?.Status = status;
        order?.UpdatedAt = DateTime.UtcNow;

        // Equivalent to:
        // if (order is not null) { order.Status = status; }
    }

    public void ConfigureOptions(Options? options)
    {
        options?.Timeout = TimeSpan.FromSeconds(30);
        options?.RetryCount = 3;
    }
}

Partial Constructors and Events

Partial classes can now split constructor and event definitions:

Do:

// Generated code (e.g., source generator)
public partial class ViewModel
{
    public partial event PropertyChangedEventHandler? PropertyChanged;

    public partial ViewModel(ILogger logger);
}

// Hand-written implementation
public partial class ViewModel
{
    private readonly ILogger _logger;

    public partial ViewModel(ILogger logger)
    {
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    public partial event PropertyChangedEventHandler? PropertyChanged
    {
        add => _propertyChanged += value;
        remove => _propertyChanged -= value;
    }
    private PropertyChangedEventHandler? _propertyChanged;
}

nameof with Unbound Generics

nameof now works with unbound generic types:

Do:

// Get type name without specifying type argument
public void LogGenericUsage<T>()
{
    _logger.LogDebug("Using {TypeName}", nameof(List<>));      // "List"
    _logger.LogDebug("Using {TypeName}", nameof(Dictionary<,>)); // "Dictionary"
}

// Useful for attributes and reflection
[TypeConverter(typeof(GenericConverter<>))]
public class MyType { }

Simple Lambda Parameters with Modifiers

Lambda parameters can carry modifiers without explicit types, provided the target delegate declares the same modifiers. A params parameter is the exception: it MUST be explicitly typed.

Do:

using System;
using System.Linq;

public delegate int RefComparer(ref int x, ref int y);

public delegate int SpanLength(scoped ReadOnlySpan<char> text);

public delegate int Aggregator(params int[] values);

public static class LambdaSamples
{
    public static void Run()
    {
        // ref, out and in modifiers need a delegate that declares them
        RefComparer compare = (ref x, ref y) => x.CompareTo(y);
        int left = 3, right = 7;
        Console.WriteLine(compare(ref left, ref right));

        // Span<T>.Sort takes Comparison<T>, whose parameters are by value
        Span<int> numbers = stackalloc int[] { 3, 1, 2 };
        numbers.Sort((x, y) => x.CompareTo(y));

        // scoped modifier
        SpanLength trimmedLength = (scoped text) => text.Trim().Length;
        Console.WriteLine(trimmedLength("  hello  "));

        // params modifier: the parameter type must be written out
        Aggregator sum = (params int[] values) => values.Sum();
        Console.WriteLine(sum(1, 2, 3));
    }
}

Why the delegate signature decides the modifiers

A lambda parameter modifier is checked against the target delegate. Span<T>.Sort takes Comparison<T>, which passes both operands by value, so (ref x, ref y) => ... is rejected with CS1677. An implicitly typed params lambda parameter is rejected outright with CS9272.

Linting: Roslynator + SonarAnalyzer

C# projects MUST use Roslynator1 and SonarAnalyzer3 for static analysis.

Why Roslynator + SonarAnalyzer

  • Roslynator1: Provides 500+ analyzers and refactorings built on Roslyn, covering code quality, performance, and style issues
  • SonarAnalyzer3: Adds security vulnerability detection and code smell identification that complement Roslynator’s coverage
  • Both integrate seamlessly with the .NET build process and IDEs
  • Native NuGet packages require no separate tooling or installation

Installation

You MUST add to your project or Directory.Build.props:

<ItemGroup>
  <PackageReference Include="Roslynator.Analyzers" Version="5.0.0">
    <PrivateAssets>all</PrivateAssets>
    <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
  </PackageReference>
  <PackageReference Include="SonarAnalyzer.CSharp" Version="10.33.0.1635">
    <PrivateAssets>all</PrivateAssets>
    <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
  </PackageReference>
</ItemGroup>

Both versions MUST be written exactly as published. SonarAnalyzer.CSharp uses four-part versions, so Version="10.33.0" resolves to the nearest higher build with NU1603, which TreatWarningsAsErrors turns into a fatal restore error.

Migrating to Roslynator 5.0.0

Roslynator 5.0.0 (released 2026-08-21) is a breaking release. Projects upgrading from 4.x MUST:

  • Rename the legacy .editorconfig keys roslynator.max_line_length and roslynator.prefix_field_identifier_with_underscore to roslynator_max_line_length and roslynator_prefix_field_identifier_with_underscore, and replace roslynator_suppress_unity_script_methods with roslynator_unity_code_analysis.enabled
  • Replace the analyzers removed in 5.0.0 with their successors, for example RCS0014 with RCS0061, RCS1035 with RCS1260, and RCS1237 with RCS1254
  • Reference Microsoft.CodeAnalysis.CSharp.Workspaces explicitly in test projects that used the Roslynator testing packages, which now floor their Roslyn dependency at 3.8.0 instead of forcing 4.14.0
  • Install the Roslynator 2026 extension for Visual Studio 2026, or stay on the last 4.x VSIX; the 5.0.0 extensions no longer bundle analyzers

Projects MUST baseline the diagnostics both upgrades introduce before turning warnings into errors, otherwise the first build after the upgrade fails on pre-existing code.

Solution-Wide (Directory.Build.props)

Projects SHOULD configure analyzers solution-wide using Directory.Build.props:

<Project>
  <PropertyGroup>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
    <EnableNETAnalyzers>true</EnableNETAnalyzers>
    <AnalysisLevel>latest-all</AnalysisLevel>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Roslynator.Analyzers" Version="5.0.0">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
    </PackageReference>
    <PackageReference Include="SonarAnalyzer.CSharp" Version="10.33.0.1635">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
    </PackageReference>
  </ItemGroup>
</Project>

Formatting: dotnet format

C# projects MUST use dotnet format2 for code formatting.

Why dotnet format

  • Built into the .NET SDK (no separate installation required)
  • Reads rules from .editorconfig for consistent configuration
  • Supports both formatting and style rule enforcement
  • Fast and reliable with official Microsoft support
  • Integrates seamlessly with CI/CD pipelines

dotnet format2 reads rules from .editorconfig.

# Format entire solution
dotnet format

# Check only (for CI)
dotnet format --verify-no-changes

# Format specific project
dotnet format ./src/MyProject/MyProject.csproj

EditorConfig (.editorconfig)

root = true

[*.cs]
# Indentation
indent_style = space
indent_size = 4

# New lines
end_of_line = lf
insert_final_newline = true

# Naming
dotnet_naming_rule.private_fields_should_be_camel_case.severity = warning
dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields
dotnet_naming_rule.private_fields_should_be_camel_case.style = camel_case_underscore

dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private

dotnet_naming_style.camel_case_underscore.required_prefix = _
dotnet_naming_style.camel_case_underscore.capitalization = camel_case

# Code style
csharp_style_var_for_built_in_types = false:warning
csharp_style_var_when_type_is_apparent = true:warning
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
csharp_style_namespace_declarations = file_scoped:warning

# Analyzer severity
dotnet_diagnostic.CA1062.severity = warning
dotnet_diagnostic.CA2007.severity = warning

Code Coverage: coverlet

C# projects MUST use coverlet4 for code coverage measurement.

Why coverlet

  • Cross-platform coverage tool built for .NET Core and .NET 5+
  • Integrates natively with dotnet test command
  • Supports multiple output formats (Cobertura, OpenCover, lcov)
  • Works with all major CI systems and coverage reporting tools
  • Built-in support for excluding generated code and test assemblies
# Run tests with coverage into a known directory
dotnet test --settings coverlet.runsettings \
  --collect:"XPlat Code Coverage" \
  --results-directory ./TestResults

# With threshold
dotnet test /p:CollectCoverage=true /p:Threshold=80

# Generate report from the per-run GUID directories VSTest creates
dotnet new tool-manifest
dotnet tool install dotnet-reportgenerator-globaltool --version 5.5.11
dotnet tool run reportgenerator \
  -reports:"TestResults/**/coverage.cobertura.xml" \
  -targetdir:coverage

ReportGenerator6 is used to generate human-readable coverage reports.

Why the glob and the local tool

The VSTest collector writes coverage.cobertura.xml into a freshly named GUID directory under TestResults, never at the repository root, so -reports:coverage.cobertura.xml fails with “No report files specified”. A recursive glob matches whichever directory the run produced. Pinning ReportGenerator in a tool manifest MUST be preferred over dotnet tool install -g, which gives CI an unpinned floating version.

Configuration

The settings below belong in coverlet.runsettings. The XML declaration MUST be the first node in the file, so the filename cannot be written as a leading comment; dotnet test otherwise rejects the file with “Unexpected XML declaration”.

<?xml version="1.0" encoding="utf-8" ?>
<RunSettings>
  <DataCollectionRunSettings>
    <DataCollectors>
      <DataCollector friendlyName="XPlat Code Coverage">
        <Configuration>
          <Format>cobertura</Format>
          <Exclude>[*]*.Migrations.*</Exclude>
          <ExcludeByAttribute>GeneratedCodeAttribute</ExcludeByAttribute>
        </Configuration>
      </DataCollector>
    </DataCollectors>
  </DataCollectionRunSettings>
</RunSettings>

Fuzzing: SharpFuzz

# Install
dotnet tool install --global SharpFuzz.CommandLine

# Instrument assembly
sharpfuzz path/to/MyLibrary.dll

The fuzzing harness below uses SharpFuzz5:

using SharpFuzz;

public class Program
{
    public static void Main(string[] args)
    {
        Fuzzer.Run(stream =>
        {
            try
            {
                MyParser.Parse(stream);
            }
            catch (FormatException) { }
        });
    }
}

Test Performance

# Parallel execution (0 = use all cores)
dotnet test -- RunConfiguration.MaxCpuCount=0

# Parallel per assembly
dotnet test -- RunConfiguration.DisableParallelization=false

# Blame mode for hanging tests
dotnet test --blame-hang-timeout 60s

xUnit Parallelization

// Assembly level
[assembly: CollectionBehavior(DisableTestParallelization = false)]

// Or per collection
[CollectionDefinition("Sequential", DisableParallelization = true)]
public class SequentialCollection { }

xUnit7 is the recommended testing framework for .NET projects.

CI Pipeline

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
        with:
          dotnet-version: '10.0.x'

      - run: dotnet restore
      - run: dotnet build --no-restore /p:TreatWarningsAsErrors=true
      - run: dotnet format --verify-no-changes

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
        with:
          dotnet-version: '10.0.x'

      - run: dotnet test --collect:"XPlat Code Coverage"
      - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0

Dependencies & Package Management

C# projects MUST use NuGet8 for package management and SHOULD enable lock files in CI environments.

# Add package
dotnet add package Newtonsoft.Json

# Enable lock file
dotnet restore --use-lock-file

# Update and lock
dotnet restore --force-evaluate

packages.lock.json

Projects SHOULD enable lock files and MUST use locked mode in CI:

<!-- MyProject.csproj -->
<PropertyGroup>
  <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
  <RestoreLockedMode Condition="'$(CI)' == 'true'">true</RestoreLockedMode>
</PropertyGroup>

Version Constraints

<!-- exact -->
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<!-- range -->
<PackageReference Include="Serilog" Version="[3.0.0,4.0.0)" />
<!-- floating -->
<PackageReference Include="Polly" Version="8.*" />

Vulnerability Scanning

Projects MUST check for vulnerable packages regularly:

# Check for vulnerable packages
dotnet list package --vulnerable

# Include transitive
dotnet list package --vulnerable --include-transitive

Dependabot Config

Projects SHOULD configure Dependabot9 for automated dependency updates:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "nuget"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 5

E2E & Acceptance Testing

Web applications SHOULD use Playwright10 for end-to-end testing. BDD projects MAY use SpecFlow11.

Playwright for .NET

Install the runner-specific integration package rather than the bare library, so the base class supplies Page and Expect, and install the browsers before the first run:

dotnet add package Microsoft.Playwright.Xunit.v3 --version 1.62.0
dotnet build
pwsh bin/Debug/net10.0/playwright.ps1 install

Tests then derive from PageTest, which opens a page per test and disposes it. The example uses Playwright for .NET10:

using Microsoft.Playwright;
using Microsoft.Playwright.Xunit.v3;
using Xunit;

public class LoginTests : PageTest
{
    [Fact]
    public async Task UserCanLogin()
    {
        await Page.GotoAsync("https://example.com/login");
        await Page.FillAsync("#username", "user@test.com");
        await Page.FillAsync("#password", "password");
        await Page.ClickAsync("button[type=submit]");

        await Expect(Page).ToHaveURLAsync("https://example.com/dashboard");
    }
}

Why the integration package

IPlaywright does not implement IAsyncDisposable, so await using var playwright = await Playwright.CreateAsync() fails with CS8417, and Expect is a member of the runner base class rather than a free function, so calling it without that base class fails with CS0103. Projects using xUnit MUST reference Microsoft.Playwright.Xunit.v3 for xUnit v3 or Microsoft.Playwright.Xunit for xUnit v2; NUnit attributes such as [Test] MUST NOT be mixed into an xUnit suite.

SpecFlow for BDD

# Features/Login.feature
Feature: User Login
  Scenario: Successful login
    Given I am on the login page
    When I enter valid credentials
    Then I should see the dashboard
[Binding]
public class LoginSteps(WebApplicationFactory<Program> factory)
{
    private HttpClient _client = factory.CreateClient();
    private HttpResponseMessage _response;

    [Given(@"I am on the login page")]
    public void GivenIAmOnLoginPage() { }

    [When(@"I enter valid credentials")]
    public async Task WhenIEnterValidCredentials()
    {
        _response = await _client.PostAsJsonAsync("/api/login",
            new { Username = "user", Password = "pass" });
    }

    [Then(@"I should see the dashboard")]
    public void ThenIShouldSeeDashboard()
    {
        _response.StatusCode.Should().Be(HttpStatusCode.OK);
    }
}

WebApplicationFactory

ASP.NET Core applications SHOULD use WebApplicationFactory12 for integration testing:

public class ApiTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task GetUsers_ReturnsOk()
    {
        var client = factory.CreateClient();
        var response = await client.GetAsync("/api/users");

        response.StatusCode.Should().Be(HttpStatusCode.OK);
        var users = await response.Content.ReadFromJsonAsync<List<User>>();
        users.Should().NotBeEmpty();
    }
}

This example uses FluentAssertions13 for readable assertions.

Thread Safety Testing

Concurrent code MUST include thread safety tests:

[Fact]
public async Task ConcurrentAccess_IsSafe()
{
    var counter = new ThreadSafeCounter();
    var tasks = Enumerable.Range(0, 1000)
        .Select(_ => Task.Run(() => counter.Increment()));

    await Task.WhenAll(tasks);

    counter.Value.Should().Be(1000);
}

// Implementation
public class ThreadSafeCounter
{
    private int _value;

    public void Increment() => Interlocked.Increment(ref _value);
    public int Value => Interlocked.CompareExchange(ref _value, 0, 0);
}

Lock Testing

[Fact]
public async Task Lock_PreventsConcurrentModification()
{
    var resource = new LockedResource();
    var results = new ConcurrentBag<int>();

    var tasks = Enumerable.Range(0, 100)
        .Select(i => Task.Run(() => results.Add(resource.IncrementAndGet())));

    await Task.WhenAll(tasks);

    results.Should().OnlyHaveUniqueItems();
    results.Should().HaveCount(100);
}

Thread-Safe Collections

[Fact]
public void ConcurrentDictionary_HandlesConcurrentWrites()
{
    var dict = new ConcurrentDictionary<int, string>();

    Parallel.For(0, 1000, i =>
    {
        dict.TryAdd(i, $"Value{i}");
    });

    dict.Count.Should().Be(1000);
}

Idempotence Testing

Operations that can be called multiple times SHOULD be tested for idempotence:

[Fact]
public async Task CreateUser_IsIdempotent()
{
    var request = new CreateUserRequest { Email = "test@example.com" };

    var result1 = await _service.CreateUserAsync(request);
    var result2 = await _service.CreateUserAsync(request);

    result1.Id.Should().Be(result2.Id);
    var users = await _repository.GetByEmailAsync(request.Email);
    users.Should().ContainSingle();
}

Polly Retry Testing

The retry test below builds its policy with Polly14:

[Fact]
public async Task RetryPolicy_HandlesTransientFailures()
{
    var attempts = 0;
    var policy = Policy
        .Handle<HttpRequestException>()
        .WaitAndRetryAsync(3, _ => TimeSpan.FromMilliseconds(100));

    await policy.ExecuteAsync(async () =>
    {
        attempts++;
        if (attempts < 3) throw new HttpRequestException();
        return await Task.FromResult("success");
    });

    attempts.Should().Be(3);
}

Reliability & Resilience Testing

Applications with external dependencies SHOULD implement and test resilience patterns using Polly14.

Polly Resilience Patterns

[Fact]
public async Task CircuitBreaker_OpensAfterFailures()
{
    var breaker = Policy
        .Handle<Exception>()
        .CircuitBreakerAsync(2, TimeSpan.FromSeconds(30));

    await breaker.Invoking(p => p.ExecuteAsync(() => throw new Exception()))
        .Should().ThrowAsync<Exception>();
    await breaker.Invoking(p => p.ExecuteAsync(() => throw new Exception()))
        .Should().ThrowAsync<Exception>();

    await breaker.Invoking(p => p.ExecuteAsync(() => Task.CompletedTask))
        .Should().ThrowAsync<BrokenCircuitException>();
}

Chaos Engineering

Polly 8.7.0 ships chaos strategies in the box, so Simmy15 as a separate package is no longer required. Injection MUST be made deterministic in tests by setting InjectionRate to 1.0 rather than a probability:

using System.Diagnostics;
using Polly;
using Polly.Simmy;
using Polly.Simmy.Latency;

public class ChaosTests
{
    [Fact]
    public async Task Service_HandlesLatency()
    {
        var pipeline = new ResiliencePipelineBuilder()
            .AddChaosLatency(new ChaosLatencyStrategyOptions
            {
                InjectionRate = 1.0,
                Latency = TimeSpan.FromMilliseconds(500)
            })
            .Build();

        var stopwatch = Stopwatch.StartNew();
        await pipeline.ExecuteAsync(
            async ct => await _service.GetDataAsync(ct),
            CancellationToken.None);
        stopwatch.Stop();

        stopwatch.Elapsed.Should()
            .BeGreaterThanOrEqualTo(TimeSpan.FromMilliseconds(500));
        stopwatch.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(5));
    }
}

Why not MonkeyPolicy

Polly.Contrib.Simmy stopped at 0.3.0 and its MonkeyPolicy has no two-argument InjectLatencyAsync(injectionRate, latency) overload, so the legacy call fails with CS1501. A probabilistic injectionRate: 0.5 also makes the assertion pass roughly half the time for the wrong reason.

Timeout Testing

The executed delegate MUST accept and forward the cancellation token Polly supplies, otherwise an optimistic timeout has nothing to cancel:

[Fact]
public async Task Operation_RespectsTimeout()
{
    var timeout = Policy.TimeoutAsync(TimeSpan.FromMilliseconds(100));

    await timeout.Invoking(p => p.ExecuteAsync(
            async ct => await Task.Delay(TimeSpan.FromSeconds(1), ct),
            CancellationToken.None))
        .Should().ThrowAsync<TimeoutRejectedException>();
}

Why the token must be forwarded

Policy.TimeoutAsync(TimeSpan) uses the optimistic strategy, which cancels the token it passes to the delegate and reports TimeoutRejectedException only if the delegate observes it. A delegate that calls Task.Delay(1000) without a token runs to completion, the policy throws nothing, and the test above passes only because the assertion is never reached.

Compatibility Testing

Libraries SHOULD test against multiple .NET versions using multi-targeting.

Multi-Targeting

<!-- MyLibrary.csproj -->
<PropertyGroup>
  <TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
</PropertyGroup>

Why no conditional System.Text.Json reference

System.Text.Json ships in the shared framework for every net8.0 and later target, so referencing the package explicitly is redundant: NuGet reports NU1510 and asks for the reference to be removed. Pinning it to 8.0.0 also drags in two high-severity denial-of-service advisories, GHSA-hh2w-p6rv-4g7w and GHSA-8g4q-xg66-9fp4, which restore reports as NU1903 and TreatWarningsAsErrors turns into build failures. A target that genuinely needs the package, such as netstandard2.0, MUST pin a serviced version — 8.0.6 is the last 8.x, and 10.0.11 is the current stable release — and CI MUST fail on NU1903.

CI Matrix

Each matrix leg MUST check out the repository and build a single target framework, and the job MUST install every runtime the matrix exercises alongside an SDK new enough to evaluate net10.0:

jobs:
  test:
    strategy:
      matrix:
        framework: ['net8.0', 'net9.0', 'net10.0']
        os: [ubuntu-latest, windows-latest, macos-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
        with:
          dotnet-version: |
            8.0.x
            9.0.x
            10.0.x
      - run: dotnet test --framework ${{ matrix.framework }}

Why not a matrix of SDK versions

Building net10.0 with the .NET 8 or 9 SDK fails with NETSDK1045, so a matrix over dotnet-version either fails or silently falls back to a preinstalled newer SDK and tests nothing it claims to. Matrixing over the target framework instead keeps one current SDK and asserts that each target framework really builds and runs.

Internationalization Testing

Applications with international users MUST test localization and UTF-8 handling. Resource lookup reads CultureInfo.CurrentUICulture, not CultureInfo.CurrentCulture, so a localization test MUST set and restore both, and MUST run without parallelism because culture is ambient state:

[Collection("Sequential")]
public class GreetingTests
{
    [Theory]
    [InlineData("en-US", "Hello")]
    [InlineData("es-ES", "Hola")]
    [InlineData("ja-JP", "こんにちは")]
    public void Greeting_LocalizesToCulture(string culture, string expected)
    {
        var originalCulture = CultureInfo.CurrentCulture;
        var originalUICulture = CultureInfo.CurrentUICulture;
        try
        {
            var target = new CultureInfo(culture);
            CultureInfo.CurrentUICulture = target;  // resource lookup
            CultureInfo.CurrentCulture = target;    // formatting and parsing

            var greeting = _localizer["Greeting"];

            greeting.Value.Should().Be(expected);
        }
        finally
        {
            CultureInfo.CurrentCulture = originalCulture;
            CultureInfo.CurrentUICulture = originalUICulture;
        }
    }
}

Why both cultures

IStringLocalizer resolves satellite assemblies through CultureInfo.CurrentUICulture. Setting only CurrentCulture leaves the lookup on the neutral resources, so a correctly localized application still returns the fallback string and the test fails. CurrentCulture is still needed when the assertion covers number, date, or currency formatting. Compare LocalizedString.Value rather than the LocalizedString itself, which does not compare equal to a string.

UTF-8 Handling

[Fact]
public void Parser_HandlesUtf8()
{
    var input = "Hello 世界 🌍";
    var bytes = Encoding.UTF8.GetBytes(input);

    var result = Parser.Parse(bytes);

    result.Should().Be(input);
}

Resource Files

[Fact]
public void ResourceManager_LoadsCorrectCulture()
{
    var rm = new ResourceManager("MyApp.Resources", Assembly.GetExecutingAssembly());

    var english = rm.GetString("Welcome", new CultureInfo("en"));
    var french = rm.GetString("Welcome", new CultureInfo("fr"));

    english.Should().Be("Welcome");
    french.Should().Be("Bienvenue");
}

Data Integrity Testing

Applications using databases MUST test migrations and transactions.

EF Core Migration Testing

Entity Framework Core16 migrations should be tested:

[Fact]
public async Task Migrations_ApplyCleanly()
{
    await using var context = CreateContext();
    await context.Database.MigrateAsync();

    var pendingMigrations = await context.Database.GetPendingMigrationsAsync();
    pendingMigrations.Should().BeEmpty();
}

[Fact]
public async Task Migration_PreservesExistingData()
{
    await using var context = CreateContext();
    context.Users.Add(new User { Name = "Test" });
    await context.SaveChangesAsync();

    await context.Database.MigrateAsync();

    var user = await context.Users.FirstAsync();
    user.Name.Should().Be("Test");
}

Transaction Testing

[Fact]
public async Task Transaction_RollsBackOnError()
{
    await using var context = CreateContext();
    await using var transaction = await context.Database.BeginTransactionAsync();

    try
    {
        context.Users.Add(new User { Name = "Test" });
        await context.SaveChangesAsync();
        throw new Exception("Simulated error");
    }
    catch
    {
        await transaction.RollbackAsync();
    }

    context.Users.Should().BeEmpty();
}

A/B Testing & Feature Flags

Applications implementing feature flags SHOULD use Microsoft.FeatureManagement17.

Microsoft.FeatureManagement

Register the filters with Microsoft.FeatureManagement17:

// Configuration
services.AddFeatureManagement()
    .AddFeatureFilter<PercentageFilter>()
    .AddFeatureFilter<TimeWindowFilter>();
{
  "FeatureManagement": {
    "NewCheckout": {
      "EnabledFor": [
        {
          "Name": "Percentage",
          "Parameters": { "Value": 50 }
        }
      ]
    }
  }
}

Testing Feature Filters

Tests MUST pin the filter inputs so the outcome is decided, not sampled:

[Fact]
public async Task FeatureFilter_AtZeroPercent_IsDisabled()
{
    var featureManager = CreatePercentageFeatureManager(0);

    var enabled = await featureManager.IsEnabledAsync("NewCheckout");

    enabled.Should().BeFalse();
}

[Fact]
public async Task FeatureFilter_AtHundredPercent_IsEnabled()
{
    var featureManager = CreatePercentageFeatureManager(100);

    var enabled = await featureManager.IsEnabledAsync("NewCheckout");

    enabled.Should().BeTrue();
}

private static IFeatureManager CreatePercentageFeatureManager(int percentage)
{
    var config = new ConfigurationBuilder()
        .AddInMemoryCollection(new Dictionary<string, string?>
        {
            ["FeatureManagement:NewCheckout:EnabledFor:0:Name"] = "Percentage",
            ["FeatureManagement:NewCheckout:EnabledFor:0:Parameters:Value"] =
                percentage.ToString(CultureInfo.InvariantCulture)
        })
        .Build();

    var services = new ServiceCollection();
    services.AddSingleton<IConfiguration>(config);
    services.AddFeatureManagement().AddFeatureFilter<PercentageFilter>();

    return services.BuildServiceProvider().GetRequiredService<IFeatureManager>();
}

[Fact]
public async Task Feature_IsDisabledByDefault()
{
    var featureManager = CreateFeatureManager(new Dictionary<string, bool>());

    var enabled = await featureManager.IsEnabledAsync("UnknownFeature");

    enabled.Should().BeFalse();
}

Why the bounds rather than the midpoint

A 50% filter is non-deterministic, so no single assertion can describe it. BooleanAssertions has no BeOneOf member in FluentAssertions13 8.10.0 either, so enabled.Should().BeOneOf(true, false) fails to compile with CS1061 — and would assert nothing if it did. The 0% and 100% cases pin both outcomes and still exercise the real PercentageFilter.

References

See Also

Footnotes

  1. Roslynator - A collection of 500+ analyzers, refactorings, and fixes for C# ↩ ↩2 ↩3 ↩4

  2. dotnet format - Code formatter for .NET ↩ ↩2 ↩3

  3. SonarAnalyzer.CSharp - Static code analyzer for C# code quality and security ↩ ↩2 ↩3

  4. coverlet - Cross platform code coverage framework for .NET ↩ ↩2

  5. SharpFuzz - AFL-based fuzz testing for .NET ↩ ↩2

  6. ReportGenerator - Converts coverage reports into human readable formats ↩

  7. xUnit.net - Free, open source, community-focused unit testing tool for .NET ↩

  8. NuGet - Package manager for .NET ↩

  9. Dependabot - Automated dependency updates for GitHub repositories ↩

  10. Playwright for .NET - Cross-browser end-to-end testing library ↩ ↩2

  11. SpecFlow - BDD framework for .NET ↩

  12. WebApplicationFactory - ASP.NET Core integration testing ↩

  13. FluentAssertions - Fluent API for asserting the results of unit tests ↩ ↩2

  14. Polly - .NET resilience and transient-fault-handling library ↩ ↩2

  15. Simmy - Chaos engineering library for .NET ↩

  16. Entity Framework Core - Modern object-database mapper for .NET ↩

  17. Microsoft.FeatureManagement - Feature flag library for .NET ↩ ↩2