Oskar Dudycz

Pragmatycznie o programowaniu

How to set up a test matrix in XUnit?

2021-02-21 oskar dudyczTests

cover

Each country has the go-to place for hiding from daily struggles. In Poland, we have Bieszczady. It’s a mountain range that’s also the wildest part of our country. You will find there: solitude, forests, wolves, bobcats and all that wild nature stuff. Beautiful place with a mysterious vibe similar to Twin Peaks. We have a running joke in Poland, “Fuck that! Let’s go breed sheep in Bieszczady!”. That was precisely the feeling I had while adding System.Text.Json support to Marten.

If you’re following me on Twitter, then you probably saw a few of my rants about that. I’ll spare you my frustration and summarise: it was an unpleasant surprise. STJ is not covering many basic scenarios, and its design decisions are at least debatable (e.g. the need to use attributes everywhere).

Still, we decided to support System.Text.Json, as:

  • the limited feature-set may not hit users that need only simple POCO,
  • it may give performance improvement,
  • Newtonsoft Json.NET seems not to be supported enough and might get obsolete soon.

Adding a new serialiser to the library like Marten is a challenge. Marten, thanks to Postgres JSON capabilities uses it as a document database and an event store. JSON serialisation and translation is a centrepiece of our actions.

We have a big set of unit and integration tests (a few thousand) to ensure that all edge cases are covered. We are taking the free CI/CD engines spinning to their limits.

  • GitHub Actions - to run against .NET Core 3.1,
  • AzureDevops - for .NET 5.

Both environments are running build matrix tests for Postgres versions ranging from 9.6 to 12. That’s a hellova test runs and permutations.

As I mentioned, JSON serialisation and mapping (e.g., constructing SQL queries from LINQ) are critical in Marten. I could take a similar approach as I did in adding NodaTime support - dedicated tests. However, I wanted to make sure that we can precisely know what’s working and what’s not. That’s why I decided to add matrix tests to run for both serialisers.

That appeared harder than I thought. Both AzureDevops and GitHub Actions have decent support for the build matrix. However, this approach is against the XUnit conventions. It’s recommended to have explicit test data. It is a reasonable approach, but as I described, sometimes you have bigger needs. Tuning a framework to your needs is always hard. It took me a lot of googling, and a few tries to achieve that. That’s why I decided to share the solution with you.

In the samples below, I’ll be using, for clarity, sample project: https://github.com/oskardudycz/XUnit.MatrixTests/. I extracted part responsible for setting up the matrix tests with XUnit.

If you prefer, you can check the full Marten’s Pull Request: https://github.com/JasperFx/marten/pull/1685.

The first case to solve was passing information about the selected serialiser type for the test suite run. XUnit doesn’t support any test settings file. That’s why I followed KISS and used a good old environment variable. Based on it, I’m setting the default serialiser. To do that, I created two classes:

Factory to be able to select default serialiser:

public enum SerializerType
{
    NewtonsoftJsonNet = 1,
    SystemTextJson = 2
}

public static class SerializerFactory
{
    public static SerializerType DefaultSerializerType { get; set; } = SerializerType.NewtonsoftJsonNet;

    public static ISerializer New(SerializerType? serializerType = null)
    {
        serializerType ??= DefaultSerializerType;

        return serializerType switch
        {
            SerializerType.NewtonsoftJsonNet => new NewtonsoftSerializer(),
            SerializerType.SystemTextJson => new SystemTextJsonSerializer(),
            _ => throw new ArgumentOutOfRangeException(nameof(serializerType), serializerType, null)
        };
    }
}

Test setting class that, based on the environment variable, gets the serialiser type.

public static class TestsSettings
{
    private static SerializerType? serializerType;

    public static SerializerType SerializerType
    {
        get
        {
            if (serializerType.HasValue) 
                return serializerType.Value;
            
            var defaultSerializerEnv = Environment.GetEnvironmentVariable("DEFAULT_SERIALIZER");

            serializerType = Enum.TryParse(defaultSerializerEnv, out SerializerType parsedSerializerType)
                ? parsedSerializerType
                : SerializerType.NewtonsoftJsonNet;
            
            return serializerType.Value;
        }
    }
}

Having those classes, the only issue left was initialising the test settings automatically before the XUnit test run. It’s possible by creating your own XunitTestFramework.

using Weasel.Serialization;
using Xunit;
using Xunit.Abstractions;
using Xunit.Sdk;

[assembly: TestFramework("XUnit.MatrixTests.TestSetup", "XUnit.MatrixTests")]

namespace XUnit.MatrixTests
{
    public class TestSetup : XunitTestFramework
    {
        public TestSetup(IMessageSink messageSink)
            :base(messageSink)
        {
            SerializerFactory.DefaultSerializerType = TestsSettings.SerializerType;
        }

        public new void Dispose()
        {
            // Place tear down code here
            base.Dispose();
        }
    }
}

You can do a lot of customisation there, but for our case, it was enough to connect the dots and set the default serialiser type. Besides deriving from XunitTestFramework it’s also needed to put the assembly attribute. Beware - you need to use the magical strings with the name of your tests project assembly and defined test framework name with a namespace. The wrong copy&paste can hurt a lot!

That’s all to have the XUnit build matrix!

Well, almost. I knew already that some of the test scenarios wouldn’t work. System.Text.Json is not working correctly with the anonymous and dynamic types, classes hierarchies etc. I wanted to have an option to mark some tests as only supported for Newtonsoft. I used the pattern that we already had for skipping features unsupported in the older Postgres versions (e.g. full-text search below version 10). I implemented the custom XUnit Fact for that with information about the selected serialiser.

[AttributeUsage(AttributeTargets.Method)]
[XunitTestCaseDiscoverer("XUnit.MatrixTests.Extras.SerializerTargetedFactDiscoverer", "XUnit.MatrixTests")]
public sealed class SerializerTypeTargetedFact: FactAttribute
{
    public SerializerType RunFor { get; set; }
}

Besides that, I needed to implement a custom fact discoverer to skip the test if the serialiser used in the test run is different than selected in SerializerTypeTargetedFact.

public sealed class SerializerTargetedFactDiscoverer: FactDiscoverer
{
    private readonly SerializerType serializerType;

    public SerializerTargetedFactDiscoverer(IMessageSink diagnosticMessageSink): base(diagnosticMessageSink)
    {
        serializerType = TestsSettings.SerializerType;
    }

    public override IEnumerable<IXunitTestCase> Discover(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod,
        IAttributeInfo factAttribute)
    {
        var runForSerializer = factAttribute.GetNamedArgument<SerializerType?>(nameof(SerializerTypeTargetedFact.RunFor));
        
        if (runForSerializer != null && runForSerializer != serializerType)
        {
            yield return new TestCaseSkippedDueToSerializerSupport($"Test skipped as it cannot be run for {serializerType} ", DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), discoveryOptions.MethodDisplayOptionsOrDefault(), testMethod);
            yield break;
        }
        
        yield return CreateTestCase(discoveryOptions, testMethod, factAttribute);
    }

    internal sealed class TestCaseSkippedDueToSerializerSupport: XunitTestCase
    {
        [Obsolete("Called by the de-serializer", true)]
        public TestCaseSkippedDueToSerializerSupport()
        {
        }

        public TestCaseSkippedDueToSerializerSupport(string skipReason, IMessageSink diagnosticMessageSink, TestMethodDisplay defaultMethodDisplay, TestMethodDisplayOptions defaultMethodDisplayOptions, ITestMethod testMethod, object[] testMethodArguments = null) : base(diagnosticMessageSink, defaultMethodDisplay, defaultMethodDisplayOptions, testMethod, testMethodArguments)
        {
            SkipReason = skipReason;
        }
    }
}

Then I could use it as:

[SerializerTypeTargetedFact(RunFor = SerializerType.NewtonsoftJsonNet)]
 public void TestWithCustomFact()
 {
     var session = new DocumentSession();

     var doc = session.Load<dynamic>("1");
     
     Assert.Equal("1", (string)doc.Id);
     Assert.Equal("test", (string)doc.Name);
 }

You can implement the custom XUnit Theory. But, I’ll let you check it directly on GitHub: https://github.com/oskardudycz/XUnit.MatrixTests/blob/main/XUnit.MatrixTests/Extras/SerializerTypeTargetedTheory.cs.

As the final touch for this blog post, I’ll show you how to configure such a test matrix in GitHub Actions:

name: ASP.NET Core CI

on: [push]

jobs:
    build:
      runs-on: ubuntu-latest
      strategy:
        matrix:
          serializer: [Newtonsoft, SystemTextJson]

      steps:
        - name: Check Out Repo
          uses: actions/checkout@v1

        - name: Setup .NET Core
          uses: actions/setup-dotnet@v1
          with:
            dotnet-version: '5.0.100'

        - name: Build with dotnet
          run: dotnet build --configuration Release 
          
        - name: Test with dotnet
          run: dotnet test --configuration Release 
          env:
            DEFAULT_SERIALIZER: ${{ matrix.serializer }}

Yes, it’s so simple. You need to define the set of matrix variables (serializer: [Newtonsoft, SystemTextJson]). And pass them to the build step environment variable.

Magic! Smoke and Mirrors!

Breeding sheep in Bieszczady is a decent idea. But programming still can be fun. I hope that this post will help you and save you some of the head-banging-on-desk experience.

Cheers!

Oskar

👋 If you found this article helpful and want to get notification about the next one, subscribe to Architecture Weekly.

✉️ Join over 11500 subscribers, get the best resources to boost your skills, and stay updated with Software Architecture trends!

Loading...
Event-Driven by Oskar Dudycz

cover

Through my window, I see the result of good plans but poor execution. Opposite my flat, there is a partially completed construction place. Buildings were supposed to be eye-catching Mediterranean style apartments. Delivery date? Two years ago. Actual? More and more unknown.

Some time ago, I heard that using Event Sourcing makes creating Event-Driven Architecture easier. The arguments were correct, that if we’re already publishing events to trigger business workflows, then at some point, we may want to also store events to not lose information. Agreed. However, I also heard that keeping the state as events will simplify things. We’ll have a source of truth with a record of the system behaviour. This will allow, e.g. to confront the results of the operations with the recorded state. I’d agree with that, with one distinction. It’s easier as long as you already know Event Sourcing.

Many people in the DDD community claim that the essential is to properly break down the system into autonomous parts called bounded contexts. Once we have it, the rest is secondary and will sort itself out. For sure.

Many seasoned programmers speak similarly about new technologies. They claim that they can translate past experience into new technologies. That’s true that by analogy, they can catch the big picture quicker. But isn’t it a bold assumption to say that Win.Forms specialist will learn Angular quickly?

The end result may differ a lot from the initial ideas. I saw the plan of those buildings next to me. Now I can see the effects of the execution. Or actually, the lack.

I believe that we should carefully acknowledge not only the point of view of our authorities but also their seating point. If we want to find out how to form a wall, do we ask an architect or a foreman? An architect may know the theory, but the practice is what we’re looking for. On the other hand, if you want to know where to put the wall, you prefer the architect to do measurements. At least if you don’t want to have the roof falling to your head.

After I had torn a ligament in my knee, I went to two qualified orthopedists. One said I should have surgery and do a reconstruction. The second stated that there is no need for that; rehabilitation should be enough. Guess which one had a specialization in surgery and which in rehabilitation?

People usually give us advice from the point where they’re currently standing. They are entitled to a biased view. An architect who rarely does programming will tend to downplay the value of implementation and tactical patterns. Midlevel developers will focus on technicalities instead of the global system impact. The team manager or consultant will emphasize the importance of soft skills (or esoteric techniques known only to them).

The truth is that we need all of them. The excellent plan will fall on the bad execution. The best execution for the wrong case will be just a waste of time. We should carefully evaluate the advice considering what we need and what an expert can give us.

Therefore, when we’re reading an article, watching a talk, let’s also pay attention to the place where the person is standing. The perspective from there may be much different from where we are right now. That can be good, as it may push us in the right direction. But it may also be misleading, as we accidentally take biases of this person without understanding the tradeoffs. Personally, I prefer to follow not only people from pedestal but also those that are closer to my position. A bit further in the journey, but not too far. That helps me to calibrate my view as those people are more relative to my daily struggles.

Polish historical leader Józef Piłsudzki reportedly used to say: “Right is like an ass, everyone has its own”.

Cheers!

Oskar