blog post image
Andrew Lock avatar

Andrew Lock

~4 min read

Creating a custom xUnit theory test DataAttribute to load data from JSON files

In my last post, I described the various ways to pass data to an xUnit [Theory] test. These are:

  • [InlineData] - Pass the data for the theory test method parameters as arguments to the attribute
  • [ClassData] - Create a custom class that implements IEnumerable<object[]>, and use this to return the test data
  • [MemberData] - Create a static property or method that returns an IEnumerable<object[]> and use it to supply the test data.

All of these attributes derive from the base DataAttribute class that's part of the xUnit SDK namespace: XUnit.Sdk.

In this post I'll show how you can create your own implementation of DataAttribute. This allows you to load data from any source you choose. As an example I'll show how you can load data from a JSON file.

The DataAttribute base class

The base DataAttribute class is very simple. It's an abstract class that derives from Attribute, with a single method to implement, GetData(), which returns the test data:

public abstract class DataAttribute : Attribute
{
    public virtual string Skip { get; set; }

    public abstract IEnumerable<object[]> GetData(MethodInfo testMethod);
}

The GetData() method returns an IEnumerable<object[]>, which should be familiar if you read my last post, or you've used either [ClassData] or [MemberData]. Each object[] item that's part of the IEnumerable<> contains the parameters for a single run of a [Theory] test.

Using the custom JsonFileDataAttribute

To implement a custom method, you just need to derive from this class, and implement GetData. You can then use your new attribute to pass data to a theory test method. In this post we'll create an attribute that loads data from a JSON file, called, JsonFileDataAttribute. We can add this to a theory test, and it will use all the data in the JSON file as data for test runs:

[Theory]
[JsonFileData("all_data.json")]
public void CanAddAll(int value1, int value2, int expected)
{
    var calculator = new Calculator();

    var result = calculator.Add(value1, value2);

    Assert.Equal(expected, result);
}

With this usage, the entire file provides the data for the [Theory] test. Alternatively, you can specify a property value in addition to the file name. This lets you have a single JSON file containing data for multiple theory tests, e.g.

[Theory]
[JsonFileData("data.json", "AddData")]
public void CanAdd(int value1, int value2, int expected)
{
    var calculator = new Calculator();

    var result = calculator.Add(value1, value2);

    Assert.Equal(expected, result);
}

[Theory]
[JsonFileData("data.json", "SubtractData")]
public void CanSubtract(int value1, int value2, int expected)
{
    var calculator = new Calculator();

    var result = calculator.Subtract(value1, value2);

    Assert.Equal(expected, result);
}

That's how we'll use the attribute, Now we'll look at how to create it.

Creating the custom JsonFileDataAttribute

The implementation of JsonFileDataAttribute uses the provided file path to load a JSON file. It then deserialises the file into an IEnumerable<object[]>, optionally selecting a sub-property first. I've not tried to optimise this at all at, so it just loads the whole file into memory and then deserialises it. You could do a lot more in that respect if performance is an issue, but it does the job.

public class JsonFileDataAttribute : DataAttribute
{
    private readonly string _filePath;
    private readonly string _propertyName;

    /// <summary>
    /// Load data from a JSON file as the data source for a theory
    /// </summary>
    /// <param name="filePath">The absolute or relative path to the JSON file to load</param>
    public JsonFileDataAttribute(string filePath)
        : this(filePath, null) { }

    /// <summary>
    /// Load data from a JSON file as the data source for a theory
    /// </summary>
    /// <param name="filePath">The absolute or relative path to the JSON file to load</param>
    /// <param name="propertyName">The name of the property on the JSON file that contains the data for the test</param>
    public JsonFileDataAttribute(string filePath, string propertyName)
    {
        _filePath = filePath;
        _propertyName = propertyName;
    }

    /// <inheritDoc />
    public override IEnumerable<object[]> GetData(MethodInfo testMethod)
    {
        if (testMethod == null) { throw new ArgumentNullException(nameof(testMethod)); }

        // Get the absolute path to the JSON file
        var path = Path.IsPathRooted(_filePath)
            ? _filePath
            : Path.GetRelativePath(Directory.GetCurrentDirectory(), _filePath);

        if (!File.Exists(path))
        {
            throw new ArgumentException($"Could not find file at path: {path}");
        }

        // Load the file
        var fileData = File.ReadAllText(_filePath);

        if (string.IsNullOrEmpty(_propertyName))
        {
            //whole file is the data
            return JsonConvert.DeserializeObject<List<object[]>>(fileData);
        }

        // Only use the specified property as the data
        var allData = JObject.Parse(fileData);
        var data = allData[_propertyName];
        return data.ToObject<List<object[]>>();
    }
}

The JsonFileDataAttribute supports relative or absolute file paths, just remember that the the file path will be relative to the folder in which your tests execute.

You may have also noticed that the GetData() method is supplied a MethodInfo parameter. If you wanted, you could update the JsonFileDataAttribute to automatically use the theory test method name as the JSON sub-property, but I'll leave that as an exercise!

Loading data from JSON files

Just to complete the picture, the following solution contains two JSON files, data.json and all_data.json, which provide the data for the tests shown earlier in this post.

Solution including JSON files

You have to make sure the files are copied to the test output, which you can do from the properties dialog as shown above, or by setting the CopyToOutputDirectory attribute in your .csproj directly:

<ItemGroup>
  <None Update="all_data.json" CopyToOutputDirectory="PreserveNewest" />
  <None Update="data.json" CopyToOutputDirectory="PreserveNewest" />
  <None Update="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

The data.json file contains two properties, for two different theory tests. Notice that each property is an array of arrays, so that we can deserialize it into an IEnumerable<object[]>.

{
  "AddData": [
    [ 1, 2, 3 ],
    [ -4, -6, -10 ],
    [ -2, 2, 0 ]
  ],
  "SubtractData": [
    [ 1, 2, -1 ],
    [ -4, -6, 2 ],
    [ 2, 2, 0 ]
  ]
}

The all_data.json file on the other hand, consists of a single array of arrays, as we're using the whole file as the source for the theory test.

[
  [ 1, 2, 3 ],
  [ -4, -6, -10 ],
  [ -2, 2, 0 ]
]

With everything in place, we can run all the theory tests, using the data from the files:

Theory tests

Summary

xUnit contains the concept of parameterised tests, so you can write tests using a range of data. Out of the box, you can use [InlineData], [ClassData], and [MemberData] classes to pass data to such a theory test. All of these attributes derive from DataAttribute, which you can also derive from to create your own custom data source.

In this post, I showed how you could create a custom DataAttribute called JsonFileDataAttribute to load data from a JSON file. This is a basic implementation, but you could easily extend it to meet your own needs. You can find the code for this and the last post on GitHub.

Andrew Lock | .Net Escapades
Want an email when
there's new posts?