In this article, we will learn how to integrate BenchmarkDotNet with Unit Tests and use some advanced APIs of the BenchmarkDotNet library when doing that.

In the .NET ecosystem, BenchmarkDotNet is the go-to library for conducting performance benchmarks. Typically we encounter this library being used in a console application to run these benchmarks. However, the process is manual, requiring developers to trigger the application and examine the results on the console.

Automating these benchmark tests and validating the resulting values would be incredibly helpful by incorporating them into unit tests. By doing so, we can execute these unit tests as part of our CI/CD pipeline, enabling us to receive continuous feedback on our code performance after each build.

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!
To download the source code for this article, you can visit our GitHub repository.

Let’s dive in.

Benchmark Test Cases

We will keep the benchmark tests code simple, showcasing the various string concatenation techniques in C#. This is the summary of the test results executed on our machine (keep in mind that benchmark results may differ across various machines): 

|               Method |       Mean |     Error |    StdDev |     Median | Rank |   Gen0 | Allocated |
|--------------------- |-----------:|----------:|----------:|-----------:|-----:|-------:|----------:|
|         StringConcat |  0.0000 ns | 0.0000 ns | 0.0000 ns |  0.0000 ns |    1 |      - |         - |
|  StringInterpolation |  0.0091 ns | 0.0133 ns | 0.0124 ns |  0.0005 ns |    1 |      - |         - |
| StringConcatWithJoin | 28.0945 ns | 0.4573 ns | 0.4054 ns | 27.9739 ns |    2 | 0.0095 |      80 B |
|         StringFormat | 66.2081 ns | 0.7046 ns | 0.6591 ns | 66.1982 ns |    3 | 0.0048 |      40 B |

We will be focussing only on the StringInterpolation test case and assert its values. As the execution of benchmark test cases can take considerable time, we want to run the performance benchmark only once. To achieve this, we will utilize the xUnit TestFixture class.

Let’s look at the code:

  
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Order;
using BenchmarkDotNet.Reports;
using BenchmarkDotNet.Running;
using BenchmarkDotNetWithUnitTests;

namespace Tests;

public class BenchmarkFixture
{
    public Summary BenchmarkSummary { get; }
    public BenchmarkFixture()
    {
        var config = new ManualConfig
        {
            SummaryStyle = SummaryStyle.Default.WithMaxParameterColumnWidth(100),
            Orderer = new DefaultOrderer(SummaryOrderPolicy.FastestToSlowest),
            Options = ConfigOptions.Default
        };

        BenchmarkSummary = BenchmarkRunner.Run(config);
    }
}

In the constructor, we run the string concat benchmarks with the appropriate configuration, exposing the results as the BenchmarkSummary property of type Summary. We then initialize this property in the class constructor.

So now the text fixture class is done, let’s use it in our test class:

public class StringConcatBenchmarkLiveTest : IClassFixture
{
    private readonly ImmutableArray<BenchmarkReport> _benchmarkReports;
    private readonly BenchmarkReport _stringInterpolationReport;

    public StringConcatBenchmarkLiveTest(BenchmarkFixture benchmarkFixture)
    {
        _benchmarkReports = benchmarkFixture.BenchmarkSummary.Reports;
        _stringInterpolationReport = _benchmarkReports.First(x =>
            x.BenchmarkCase.Descriptor.DisplayInfo == "StringConcatBenchmarks.StringInterpolation");
    }
}

We hold the reference to the benchmark’s calculated reports and assign it to an immutable collection field _benchmarkReports. Additionally, we fetch the report related to StringInterpolation and hold a reference on the _stringInterpolationReport field. Both these fields are used on test assertions.

Integrate BenchmarkDotNet With Unit Tests By Asserting the Count

Now that the test setup is complete, it’s time to execute the tests. Firstly, let’s go over the basics. We need to validate the number of test cases that are being executed.

Let’s understand with the code snippet:

[Fact]
public void WhenBenchmarkTestsAreRun_ThenFourCasesMustBeExecuted()
{
    var benchmarkCases = _benchmarkReports.Length;
    
    Assert.Equal(4, benchmarkCases);
}

We create a benchmarkCases variable and check if its value, which is the number of benchmark reports in the array, equals 4 using xUnit’s Assert.Equal(). If not, then the test fails.

Asserting the Speed

After verifying the count, we can proceed to test the execution speed of the StringInterpolation benchmark case:

[Fact]
public void WhenStringInterpolationCaseIsExecuted_ThenItShouldNotTakeMoreThanFifteenNanoSecs()
{
    var stats = _stringInterpolationReport.ResultStatistics;
    
    Assert.True(stats is { Mean: < 15 }, $"Mean was {stats.Mean}");
}

We need to obtain the statistical results of the benchmark case, and for that, we extract the ResultStatistics value from the _stringInterpolationReport object. We then verify the mean value of the result statistics (stats.Mean) using the Assert.True() method to ensure it is below 15 nanoseconds. If the condition is unmet, the test fails and displays a message containing the actual mean value.

Asserting the Memory Allocation

Let’s move on to some advanced stuff now by checking the memory allocation of the StringInterpolation code is within a particular threshold value:

[Fact]
public void WhenStringInterpolationCaseIsExecuted_ThenItShouldNotConsumeMemoryMoreThanMaxAllocation()
{
    const int maxAllocation = 1342178216;
    var memoryStats = _stringInterpolationReport.GcStats;
    var stringInterpolationCase = _stringInterpolationReport.BenchmarkCase;
    var allocation = memoryStats.GetBytesAllocatedPerOperation(stringInterpolationCase);
    var totalAllocatedBytes = memoryStats.GetTotalAllocatedBytes(true);

    Assert.True(allocation <= maxAllocation, $"Allocation was {allocation}");
    Assert.True(totalAllocatedBytes <= maxAllocation,
        $"TotalAllocatedBytes was {totalAllocatedBytes}");
}

We begin by defining a constant variable maxAllocation that represents the maximum allowed allocation. We then retrieve the garbage collection statistics (GcStats) of the benchmark case from the _stringInterpolationReport object. Then we also retrieve the benchmark case itself using the same object to obtain the stringInterpolationCase. After this, we calculate the allocation in bytes per operation by passing stringInterpolationCase it as a parameter to GetBytesAllocatedPerOperation() on the memoryStats object.

We validate two conditions using the Assert.True() method for validating memory allocated per operation and total memory allocated for all operations:

  • We check if the allocation per operation (allocation) is less than or equal to the maxAllocation. If the condition is false, the test fails with a failure message, including the actual allocation.
  • We also check if the total allocated bytes (memoryStats.GetTotalAllocatedBytes(true)) is less than or equal to the maxAllocation. If the condition is false, the test fails with a failure message that includes the total allocated bytes.

Asserting the Gen1 and Gen2 Allocation

We’ll now look closer at memory allocations and examine the Gen0 and Gen1 allocations for the StringInterpolation benchmark case:

[Fact]
public void WhenStringInterpolationCaseIsExecuted_ThenZeroAllocationInGen1AndGen2()
{
    var memoryStats = _stringInterpolationReport.GcStats;
    
    Assert.True(memoryStats.Gen1Collections == 0, $"Gen1Collections was {memoryStats.Gen1Collections}");
    Assert.True(memoryStats.Gen2Collections == 0, $"Gen2Collections was {memoryStats.Gen2Collections}");
}

We retrieve the garbage collection statistics (GcStats) of the benchmark case from the _stringInterpolationReport object. We then use the Assert.True() method is used to perform two assertions.

First, we check if the number of Gen1 garbage collections (memoryStats.Gen1Collections) is equal to zero. If the condition is false, the test fails with a failure message, including the number of Gen1 collections.

Then, we check if the number of Gen2 garbage collections (memoryStats.Gen2Collections) is equal to zero. If the condition is false, the test fails with a failure message, including the actual number of Gen2 collections.

Conclusion

It’s not enough to measure performance once and be done with it. In this article, we discussed how to integrate BenchmarkDotNet with unit tests and emphasize the significance of that integration. This approach provides ongoing feedback on our code and boosts our confidence when making significant changes.

We also explore utilizing advanced APIs to measure crucial factors of the benchmark, like speed and memory allocations. Automation is critical, and we covered how to achieve it.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!