Ease.NET 1.0.1

There is a newer version of this package available.
See the version list below for details.
dotnet add package Ease.NET --version 1.0.1                
NuGet\Install-Package Ease.NET -Version 1.0.1                
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Ease.NET" Version="1.0.1" />                
For projects that support PackageReference, copy this XML node into the project file to reference the package.
paket add Ease.NET --version 1.0.1                
#r "nuget: Ease.NET, 1.0.1"                
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
// Install Ease.NET as a Cake Addin
#addin nuget:?package=Ease.NET&version=1.0.1

// Install Ease.NET as a Cake Tool
#tool nuget:?package=Ease.NET&version=1.0.1                

Ease.NET

banner

Test builders done with ease and done right

Ease is a simple Framework for creating dynamic and fluent builders in .NET, biased towards the use in tests. Intentionally does not come with batteries, for example random data is not part of Ease but it is trivial to add such functionality.

alternate text is missing from this package README image

NuGet version (Ease.NET)

Convince me!

Imagine creating tests objects like this:

const string teamName = "awesome";
var team = A.Team.WithName(teamName)
    .WithUsers(A.User.ThatIsValid());
const string teamName = "awesome";
var team = A.Team.With(x => x.Name, teamName)
   .WithMany(x => x.Users, A.User.ThatIsValid());
var team = A.Team.ThatIsValid()
    .IgnoreProperty(x =>x.Description);

While working with DTOs, models, entities, etc, particularly those that are used throughout your domain and boundaries, you will find that they are required in a multitude of tests. A natural approach is to call the constructor of each when required and hydrate them with the required setup. While this is straightforward there are a couple of challenges. To illustrate this, lets us introduce a simple domain model showing the relationship between a Team and Users

internal record User
{
    public User(string fullName, string email, DateTimeOffset joinedAt)
    {
        FullName = fullName;
        Email = email;
        JoinedAt = joinedAt;
    }

    public string FullName { get; set; }
    public string Email { get; set; }
    public DateTimeOffset JoinedAt { get; set; }
}

internal record Team
{
    public string Name { get; set; }
    public string Description { get; set; }
    public DateTimeOffset CreatedAt { get; set; }
    public IEnumerable<User> Users { get; set; }
}

Now, what could go wrong with the simple approach of calling the constructor or object initializers directly in tests? Well, the following:

  • As your solution expands and the domain entities are used in hundreds of tests and you may update the domain by adding/removing properties that affect the ctor. In this case, you need to update a multitude of tests manually.
  • Over time you may change the meaning of things in the domain and how they are set up. To give a naive example, imagine changing the JoinedAt type from DateTime to DateTimeOffset. Any tests that were created already will also need to be updated with this change. This breaks a lot of principles, ideally, the state and validity of an object should be self-contained, and if the outside has to understand the inner workings to attain a given state that is a fail, and yes even for tests.
  • In many circumstances, you may simply want a domain entity for the test without a need for it to be in a specific state, but rather just to be valid. In this circumstance, you will likely find the same code copy-pasted all over the code base creating yet another maintenance nuisance.

So, how do I solve this?

Let's start with another very tempting pattern that I have seen frequently.

internal class MediocreUserBuilder
{
    internal static User CreateUser(
        string fullName = default,
        string email= default,
        DateTimeOffset joinedAt = default
    ) =>
        new User(fullName, email, joinedAt);
}

This does not solve all the problems or at least does so by introducing new ones. As our domain evolves then the method params here become chaotic, notice the optional parameters that are in place to cater to creating the objects without providing everything, this does not scale well at all as more properties are introduced. Things get even more chaotic when there are nested objects and these builder methods also cater to that by maybe falling into the temptation of accepting the raw parameter values.

The biggest problems with this pattern are that it does not communicate intention nor does it evolve well with domain changes. Even with such an abstraction, a lot is still left to the setup phase of the tests, which is not great as this in many test cases would be an auxiliary concern and not the focus of the test, at least something that should not be a distraction with the test. As you can have multiple scenarios also how does this work with this pattern? One way is to add more parameters to control this. Or maybe to create multiple of these methods for each scenario 🤦‍♂️. With this pattern, I often find this code will still be copy pasted to add flexibility, so naturally, I am not promoting this one.

Finally, the tests that use this pattern or other such alternatives all tend to be very long and messy to read. By looking at the code at a glance it is not possible to understand what the arrange stage is doing, worse still it makes it hard to distinguish clearly the arrange from the act.

Get to it already, show me the way!

Let's get straight to it and look at a different pattern in code.

Starting with the one I most recommend, dynamic builders

internal class TeamBuilder : Builder<Team>
{
    private readonly Faker _faker = new();

    public override TeamBuilder ThatIsValid()
    {
        With(x => x.Name, _faker.Company.CompanyName());
        With(x => x.Description, _faker.Company.CatchPhrase());
        With(x => x.CreatedAt, DateTimeOffset.Now);

        // notice ability to chain builders
        HavingMany(x => x.Users, A.User.ThatIsValid(), A.User.ThatIsValid(), A.User.ThatIsValid());

        return this;
    }
}

// create a team without caring about the details, it should just be valid
var team = A.Team.ThatIsValid();

// create a team without caring about the details, it should just be valid, but with the exception of some property/properties
var team = A.Team.ThatIsValid()
            .IgnoreProperty(x => x.Description);
            
// take control of the values
const string teamName = "awesome";
var team = A.Team.With(x => x.Name, teamName)
    .WithMany(x => x.Users, A.User.ThatIsValid());

Delegate control to the builder, by having specific builder methods. Not my favorite, but possible especially if you want to massage data as part of the builder or create abstractions for scenarios like say a team that is suspended. While a team that is suspended may mean an interaction of many properties, from usage it would simply be A.Team.ThatIsSuspended().

internal class TeamBuilder : Builder<Team>
{
    private readonly Faker _faker = new();

    public TeamBuilder WithName(string name)
    {
        With(x => x.Name, name);

        return this;
    }

    public TeamBuilder WithUsers(params UserBuilder[] users)
    {
        // can also leverage ability to chain builders
        WithMany(x => x.Users, users);

        return this;
    }

    public override TeamBuilder ThatIsValid()
    {
        With(x => x.Name, _faker.Company.CompanyName());
        With(x => x.Description, _faker.Company.CatchPhrase());
        With(x => x.CreatedAt, DateTimeOffset.Now);

        HavingMany(x => x.Users, A.User.ThatIsValid(), A.User.ThatIsValid(), A.User.ThatIsValid());

        return this;
    }
}

const string teamName = "awesome";
var team = A.Team.WithName(teamName)
    .WithUsers(A.User.ThatIsValid());

Ok so why is this better? I'm glad you asked!

  • Communication of intent. The methods we see here are very clear on what they are building and while we kept this simple with our examples, this scales very well. This makes it easy to create objects in tests, especially in scenarios when the object is not primary to the test but still required for the test setup.
  • Fluent. Who does not love fluent code, this one makes this further easy to use and very natural to read. If there is anything you should strive for is easily readable tests. Recall that when we ditched explicit documentation in code, we made an oath to write self-documenting code, one of which is through tests, so they better be easy to read and understand.
  • Less code. So if the auxiliary act of creating objects for our tests is not key to the tests why should that mess make the test hard to read? I would rather see A.Team.ThatIsSuspended() than see all the code that entails this.
  • Ease of refactoring. This approach isolates the actual creation of something to one place and one place only much like a factory. So now as your domain evolves and you change the meaning of things, ctors change, etc among many changes, as far as your tests are concerned this change only needs to be done in one place.

All things considered, we can certainly say that this is both simple and powerful. Creating the builders is easy and the pattern fosters clean coding patterns. Using the builders is also very intuitive and the fluent pattern further makes this a pleasure to use. Ease!

If you were paying close attention you would have noticed the readability added by the use of the A or Some to give results like A.User and Some.Team that conform to natural language. While optional this can be the cherry on the top to make the calls natural to read. You can achieve this as follows

internal static class A
{
    public static UserBuilder User => new();
    public static TeamBuilder Team => new();
}

Notice the use of => and not =. This is intentional and care must be taken to make sure you always do it this way. You want to ensure that each call to this property makes a new builder. This isolation for tests is essential, especially considering that the builders have a state.

This pattern is very simple. However given that 1. this involves 'only tests' and 2. the problem does not seem that complex or pressing, this tends to be highly neglected. The consequences are however dire and do not discriminate because these are only tests. The amount of time lost to go around the challenges of not handling this problem properly can be great.

Setup

.NET CLI

dotnet add package Ease.NET

Package Reference

<PackageReference Include="Ease.NET" Version="1.0.0" />

Paket CLI

paket add Ease.NET

You can also choose to install this as a dependency from NuGet.

The code behind all this is less than 100 lines of code, so you could also choose to copy this into your code bases and maintain that over time, because this is focused on simplicity, it is very unlikely new features will be introduced to this repository.

DO use test builders for repeatable data initialization. Key candidates are things EF entities, domain entities, models, etc.

DO consider adding generic test constructs such as builders that can and should be reused in multiple test projects to some common test project to avoid duplication.

DO consider the A or Some static container pattern as shown in the examples above to make it easy to work with pre-initialized builders.

internal static class A
{
    public static UserBuilder User => new();
}

DO favor the dynamic test builder pattern for simple scenarios and only create custom methods in your builders for the more complex scenarios to avoid boilerplate code.

var team = A.Team.With(x => x.Name, teamName)
    .WithMany(x => x.Users, A.User.ThatIsValid());

DO as a requirement, override the CreateInstance() method when the object being created does not have a parameterless ctor of when properties do not match what was set, for example for objects that simulate property bags.

protected override User CreateInstance()
    => new User(Get(x => x.FullName), Get(x => x.Email), Get(x => x.JoinedAt));

🛑 DO NOT solve the test builder problem with some mediocre factory method. This is the main takeaway from this project.

Product Compatible and additional computed target framework versions.
.NET net7.0 is compatible.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net7.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last updated
1.0.6 3,622 12/9/2022
1.0.5 315 12/9/2022
1.0.4 316 11/25/2022
1.0.3 337 11/23/2022
1.0.2 341 11/23/2022
1.0.1 354 11/22/2022
1.0.0 348 11/22/2022

v 1.0.0 release 🚀