Chapter 19: Testing and Tooling
Testing guards the edges of your code and keeps regressions away. Tooling sharpens your workflow so errors surface early and fixes arrive quickly. .NET ships with a rich test runner, analyzers, packaging tools, and cross-platform CI options that fit comfortably into any project. This chapter introduces essential practices for writing and maintaining reliable software.
Unit testing fundamentals
Unit tests check small pieces of logic in isolation. The standard way to organise tests in .NET is to create a separate test project using a framework such as xUnit, NUnit, or MSTest. Each framework uses attributes to mark test methods and offers assertions to verify outcomes. Keep tests focused on one behaviour at a time to make failures clear and actionable.
A simple test with xUnit
The following example tests a small calculator helper. The runner discovers test methods marked with [Fact] and reports results.
using Xunit;
public static class MathEx
{
public static int Square(int x) => x * x;
}
public class MathExTests
{
[Fact]
public void Square_of_value_is_correct()
{
var result = MathEx.Square(4);
Assert.Equal(16, result);
}
}
Asserting exceptions and edge cases
Tests should include inputs that explore edge behaviour. Assert on exceptions when invalid inputs should fail, and check results at boundary conditions.
[Fact]
public void Square_negative_value()
{
var result = MathEx.Square(-3);
Assert.Equal(9, result);
}
[Fact]
public void ArgumentNull_throws()
{
Assert.Throws<ArgumentNullException>(() => SomeApi.DoWork(null!));
}
Mocking and fakes
When logic depends on external services or slow operations, replace collaborators with fakes or mocks. A fake implements the same interface but behaves predictably for tests. A mock is usually created by a library such as Moq or NSubstitute and allows verification of interactions.
Using interfaces for testable design
Inject dependencies through interfaces so you can substitute them. This pattern keeps logic loosely coupled and easy to test.
public interface IClock
{
DateTime UtcNow { get; }
}
public sealed class SystemClock : IClock
{
public DateTime UtcNow => DateTime.UtcNow;
}
Mocking interactions with Moq
This example uses Moq to simulate a collaborator. The test verifies both behaviour and method calls.
using Moq;
using Xunit;
public class Greeter
{
private readonly IClock _clock;
public Greeter(IClock clock) { _clock = clock; }
public string Greet() => _clock.UtcNow.Hour < 12 ? "Morning" : "Evening";
}
public class GreeterTests
{
[Fact]
public void Greets_based_on_clock_time()
{
var mock = new Mock<IClock>();
mock.Setup(c => c.UtcNow).Returns(new DateTime(2025, 1, 1, 9, 0, 0));
var g = new Greeter(mock.Object);
Assert.Equal("Morning", g.Greet());
mock.Verify(c => c.UtcNow, Times.Once);
}
}
Data-driven tests
Data-driven tests run the same logic against multiple inputs. Frameworks offer attributes that feed parameter sets into a single test method. This keeps duplicate tests small and tidy.
Theory tests with xUnit
Use [Theory] along with [InlineData] for simple sets. Larger sets can come from custom providers.
public class ParserTests
{
[Theory]
[InlineData("42", 42)]
[InlineData("7", 7)]
[InlineData("0", 0)]
public void Parses_integers_correctly(string input, int expected)
{
var result = int.Parse(input);
Assert.Equal(expected, result);
}
}
External data sources
For extensive test cases you can load data from files or generate sequences using MemberData or ClassData. This supports broad coverage without cluttering individual methods.
public class RangeData : IEnumerable<object[]>
{
public IEnumerator<object[]> GetEnumerator()
{
for (int i = -5; i <= 5; i++)
yield return new object[] { i, i * i };
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
public class RangeTests
{
[Theory]
[ClassData(typeof(RangeData))]
public void Squares_values(int input, int expected)
{
Assert.Equal(expected, input * input);
}
}
Analyzers and code fixes
Code analyzers inspect your source at compile time and highlight issues or improvement opportunities. Roslyn-based analyzers run inside the compiler pipeline and offer diagnostics and automatic fixes. Many .NET SDKs include built-in analyzers for style, performance, and safety.
Using built-in analyzers
Enable recommended rules by adding a configuration file named .editorconfig to your project and selecting rule sets. You can set severity levels such as suggestion, warning, or error.
[*.cs]
dotnet_analyzer_diagnostic.severity = warning
dotnet_style_qualification_for_field = true:suggestion
dotnet_code_quality_unused_parameters = true:warning
Custom analyzers and fixes
You can write your own analyzer that flags patterns in code. Pair it with a code fix provider that rewrites syntax automatically. This is useful when you enforce project-wide conventions or detect subtle mistakes early.
// Analyzer skeleton … full implementation would inspect syntax nodes
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class NoMagicNumbersAnalyzer : DiagnosticAnalyzer
{
public override void Initialize(AnalysisContext context)
{
context.EnableConcurrentExecution();
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.NumericLiteralExpression);
}
private static void Analyze(SyntaxNodeAnalysisContext context)
{
// Check literal; report diagnostic when needed …
}
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray<DiagnosticDescriptor>.Empty;
}
Packaging with NuGet
NuGet packages share code across projects or with other developers. You can pack a library into a .nupkg file and publish it to nuget.org or a private feed. Your project file controls metadata, assembly inclusion, and dependency versions. Semantic versioning helps consumers understand compatibility.
Packing a project
The SDK can generate packages directly. Include metadata such as PackageId, Version, Authors, and Description.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageId>MyLib</PackageId>
<Version>1.0.0</Version>
<Authors>Robin</Authors>
<Description>Utility helpers for …</Description>
</PropertyGroup>
</Project>
Publishing and dependency management
Use dotnet nuget push to publish packages. When consuming packages specify floating versions carefully; stable ranges offer predictability, while wildcards allow faster updates. Private feeds can live inside Azure DevOps, GitHub Packages, or file shares.
Continuous integration basics
Continuous integration keeps builds, tests, and analysis running automatically. A CI pipeline compiles your code, runs tests on each commit, checks analyzers, and can publish artifacts. Hosted runners such as GitHub Actions and Azure Pipelines run on Windows, Linux, and macOS.
Simple GitHub Actions workflow
The example workflow restores packages, builds the project, and runs tests across platforms. It keeps the build green and alerts you when new changes break behaviour.
name: build-and-test
on: [push, pull_request]
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- run: dotnet restore
- run: dotnet build --configuration Release --no-restore
- run: dotnet test --configuration Release --no-build
Caching and artifacts
You can cache NuGet packages and store artifacts from each run. This speeds up CI and keeps evidence of test results. Keep caches focused on stable assets so they remain effective and do not accumulate stale content.