.NET
unit testing
multiple frameworks
software development
implementation differences

How to properly unit test a .NET project with multiple target frameworks, given implementation differences among targets?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Multi-targeted .NET libraries are tricky to test because the public API may stay stable while implementation details legitimately differ per target framework. A good test strategy verifies common behavior once, then adds targeted tests only where framework-specific differences are real and intentional. The goal is not to duplicate every test for every target without thought, but to structure the test project so differences are explicit and maintainable.

Start From A Multi-Targeted Test Project

The simplest baseline is to multi-target the test project too, so the same tests run against each framework target.

xml
1<Project Sdk="Microsoft.NET.Sdk">
2  <PropertyGroup>
3    <TargetFrameworks>net48;net8.0</TargetFrameworks>
4    <IsPackable>false</IsPackable>
5  </PropertyGroup>
6
7  <ItemGroup>
8    <PackageReference Include="xunit" Version="2.7.0" />
9    <PackageReference Include="xunit.runner.visualstudio" Version="2.5.8" />
10    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
11  </ItemGroup>
12
13  <ItemGroup>
14    <ProjectReference Include="..\\MyLibrary\\MyLibrary.csproj" />
15  </ItemGroup>
16</Project>

This ensures each target compiles and executes against its own implementation path.

Separate Shared Contract Tests From Framework-Specific Tests

Most tests should assert behavior that must stay identical across all targets. Keep those in a normal shared test class.

csharp
1using Xunit;
2
3public class SlugTests
4{
5    [Theory]
6    [InlineData("Hello World", "hello-world")]
7    [InlineData("One  Two", "one-two")]
8    public void CreateSlug_Normalizes_Text(string input, string expected)
9    {
10        Assert.Equal(expected, Slug.Create(input));
11    }
12}

Then isolate target-specific expectations only where behavior actually differs.

Use Conditional Compilation Sparingly

If implementation differences are intentional, you can branch tests with target symbols.

csharp
1using Xunit;
2
3public class TimeFormattingTests
4{
5    [Fact]
6    public void FormatUtcTimestamp_Matches_Target_Behavior()
7    {
8        var value = Formatter.FormatUtcTimestamp();
9
10#if NET48
11        Assert.EndsWith("Z", value);
12#else
13        Assert.Contains("UTC", value);
14#endif
15    }
16}

This works, but it can become messy if overused. Prefer separate test files or helpers when differences grow beyond a few lines.

A Cleaner Pattern: Abstract Test Base Plus Target Helpers

For more complex differences, put shared assertions in a base class and provide target-specific expectations through helper properties or methods.

csharp
1using Xunit;
2
3public abstract class ParserTestsBase
4{
5    protected abstract string ExpectedEmptyValue { get; }
6
7    [Fact]
8    public void ParseEmpty_Returns_TargetExpectedValue()
9    {
10        Assert.Equal(ExpectedEmptyValue, Parser.Parse(string.Empty));
11    }
12}
13
14#if NET48
15public class ParserTests : ParserTestsBase
16{
17    protected override string ExpectedEmptyValue => "legacy";
18}
19#else
20public class ParserTests : ParserTestsBase
21{
22    protected override string ExpectedEmptyValue => "modern";
23}
24#endif

This keeps most logic shared and makes the difference visible in one place.

Run CI Per Target, Not Just Local Defaults

A multi-targeted test project is only useful if CI actually runs every target. Make the pipeline fail when one target passes and another breaks.

Use commands like:

bash
dotnet test MyLibrary.Tests/MyLibrary.Tests.csproj -f net48
dotnet test MyLibrary.Tests/MyLibrary.Tests.csproj -f net8.0

Even if dotnet test runs all targets by default in your environment, explicit per-target jobs make failures easier to diagnose.

Test Behavior, Not Framework Internals

A common mistake is asserting specific internal implementation details per target instead of the public contract. If the library exposes the same method signature and intended behavior, tests should focus there first.

Only add framework-specific assertions when the difference is unavoidable, documented, and user-visible.

Common Pitfalls

  • Copying the same test file into separate target-specific projects and creating drift.
  • Using #if everywhere until tests become harder to read than production code.
  • Testing internal mechanics instead of stable public behavior.
  • Running CI on only one framework and assuming the others are safe.
  • Leaving framework-specific differences undocumented, so failures look random.

Summary

  • Multi-target the test project so each framework runs real tests against its own implementation.
  • Keep most tests shared and behavior-focused.
  • Isolate intentional target differences in small, explicit places.
  • Use conditional compilation carefully and only when needed.
  • Make CI execute and report each target separately.

Course illustration
Course illustration

All Rights Reserved.