Practical complex data for unit testing

Unit testing is a long-established and essential part of the software development process particularly when using .NET.  The basic premise, if you are not aware, is to test the public methods of classes so that they work as expected. 

Unit tests follow the 3As: Arrange, Act and Assert. In other words, set up the conditions for the test, run the code and finally, check that it worked as expected. If it did, great the test passed!  If not, then there’s some work to do.

This article takes a look at the first step, arranging the test.  Although there are a few things to do during this stage, right now we are only concerned with creating the data required to run the test.

Later, we are going to look at how to create complex test data that can be easily understood by using the Builder pattern.

Why is it important how test data is created?

Well, the test data can be confusing to look at and to understand.  It becomes time-consuming to follow and to update. This is especially the case where the test data has relationships to be maintained. 

If the test data is not clear it is easy to make a mistake and introduce bugs into test code.

Test data which was written a long time ago becomes hard to recall.  As with everything it is fresh in the mind when it is written, but three or six months later, it becomes difficult to recall the intent of the data.

It is easy and very tempting at times to break the DRY (Don't Repeat Yourself) principle and just repeat test data.  As new tests are set up, test data that is not well understood is typically copied and tweaked to suit the purpose.  This just increases the technical debt within the test data making the whole project harder to maintain.

Arranging test data

Most, if not all code depends on parameters or data to run.  So when it comes to unit testing and asserting that the code works some amount of data is usually required to run the test.  This can range from a simple value-type parameter to a complex object model, or even a larger set of related data. 

There are different ways of creating unit test data and we’ll take a look at some of them briefly.

Test data can be created in a unit test directly, passed into a test in line, loaded from files, or created for use in an in-memory database. I consider in-memory database fine for unit testing.  A separate database hosted outside the test execution process would be considered integration testing.

Adding test data with xUnit Unit Test Framework

To manage and run my unit tests I use xUnit. It is a popular, free, open source and community-focused unit testing framework. xUnit provides a few useful ways to manage our test data.

Theory Unit Test

Theory unit tests allow a single test to act on sets of parameterised data .  Data is passed using the InlineData data attribute for as many different cases as needed.  This keeps the test code DRY and removes the need to repeat the test logic for different data values.

In cases where test data is complex, xUnit also supports ClassData and MemberData data attribute for loading test data in from a method or from another class.  xUnit is extensible and custom data attributes can be written, for example, to load test data from files or from a database.

Fixtures

The final xUnit feature to look at is the Fixture class. Fixtures allow for sharing setup and clean up code for all tests in a class or even in multiple test classes.  This is a great place to create complex test data and the perfect place for creating an in-memory database for the tests that require one.

For our development we often use SQL Server accessed via Entity Framework and to test we use SQLite in-memory database to simulate the database layer. We use this approach to initialise an in-memory database with the data we need which provides the context for a series of tests logically grouped in a class.

AutoFixture

One neat library for creating and generating test data is AutoFixture.  The creators describe it as follows:

AutoFixture is an open source library for .NET designed to minimize the 'Arrange' phase of your unit tests in order to maximize maintainability. Its primary goal is to allow developers to focus on what is being tested rather than how to setup the test scenario, by making it easier to create object graphs containing test data.

We don’t use AutoFixture for our test data creation in this example, but it is worth mentioning as it can speed up the process of writing tests.

How the Builder Pattern can help create complex data

As touched upon before, there are issues that emerge when test data gets complex.  Thankfully there is an approach which we can use to help us and has been around for a long time.

The builder pattern is a well-understood design pattern in software development.  It allows the construction of complex objects step by step and can be used to create different representations of complex data.

This is great for our needs as we can use this approach to create our test data.  The builder pattern enables us to create our test data with the intent clear. It does mean creating a concrete implementation of the pattern using knowledge of the domain. 

However, I think this approach can be worthwhile as it gives us the following benefits:

  • Single place to instantiate objects
  • It leads to preventing the repetition of data
  • Provides a way of controlling each step of the construction process. This leads to the code providing a clear intent of the data.
  • The concrete implementation can be developed using domain-specific language. This also makes the intent of the data clearer. Name the builder methods as you wish to provide the most information to the developer/tester.
  • As the construction of the object is encapsulated in code, it can be designed so that foreign keys are handled in code which reduces complexity.
  • Also, it means collections are created when required. Collections can be navigated by accessing the last item by convention.

Builder Pattern In Action

For this project I used .NET Core 5.0, EFCore and SQLite.  Also included is xUnit for unit testing, AgileMapper for mapping and Fluent Assertions for the assertions.

Our demo project is a nascent itinerary planner and is available on GitHub. It has a model based around tourists who have excursions containing visits to places (e.g. Paris) and visits to points of interests (e.g. Eiffel Tower).

The only bit of business logic is to swap the order of places to visit in an excursion.  This logic is the code under test, has a dependency on a database and is encapsulated in a TouristService.

So now for the data.  As mentioned before, we will simulate a database using an in-memory SQLite database as this will work nicely with our service under test.  This will be added using an xUnit fixture class so that all tests in the class can share the same data.  Finally, we have our custom implementation of the builder pattern used to create the test data.

What does the test builder look like?

The ITourist interface contains methods to create our Tourist. You can see that the builder enables the complex object model to be built step by step using method names that have domain relevance. Finally the BuildTourist method will return our object.

Now for a sample of the implementation. 

Initially, we create our top-level object, a Tourist which is kept in a private field.

Next, we add an Excursion to our tourist.

All of the other steps are similar to the above to build up the object.

Finally, the BuildTourist method is implemented which returns our Tourist.

The test project includes an example of loading the data using the Builder pattern.  The order and indentation of items is important for readability, but not essential.

For contrast, it also includes an example of loading the same test data by direct object creation.

Comparing these two different implementations it is easy to see that using a Builder, the test data is more readable and easier to maintain.

Now on to the unit tests.  The TestsWithFixtureAndBuilder class uses the Fixture where our test data is created.  The test class contains a single test to check that the SwapPlaceVisits method works as expected.

Remember to check out the entire code example in GitHub.

Further ideas for the Test Data Builder

As this is custom code, there is a huge amount of flexibility available and you can enhance the basic test data builder to:

  • Include validation rules. This can be useful for ensuring the business logic constraints are correct before running a test and ensuring your test data is correctly formed.
  • Set the primary keys automatically. Depending on how the tests are set up in your specific case, there could be the chance to set the primary keys in the builder.
  • Create model with default values and update specific values only. This can be done using lambda functions.
  • Add plain English descriptions to the builder and override the ToString() method. Textual information can be added inline.   This could also be outputted during unit test execution to help debug problematic tests.
  • Add factory methods to generate common test data.

Conclusion

As we’ve seen, there are many ways of creating test data. However, when the data starts to become complex it can become difficult to understand and maintain and lead to complications with our unit tests.  The builder pattern has been around for a long time and it has proven to be useful when creating test data to give context and make the test data easy to understand and maintain.

Hopefully this article and code example will provide some useful pointers to building your test data.  Beyond this specific code example, the take home points are to find the approach which works best for you and the rest of your development and testing team.  So when creating test data, aim for the following:

  • The test data is easy to understand.
  • The relationship of the data is easy to understand.
  • The data is easy to change or add.
  • The approach ensures developers stick to the DRY principle.

 

Innovensa provide custom software development in the UK using .NET and C# and xUnit.  We are a passionate and experienced software development team with experts ready to help you.

Get in touch about your next .NET development project for a no obligation chat.

 

Jonathan Dodd, Co-Founder of Innovensa

Jonathan Dodd

Jonathan Dodd is a co-founder of Innovensa Ltd.

Back to top