What is Modulos.Testing ?

Modulos.Testing is a simple library built to improve integration and unit testing. It also brings a bunch of battled proven patterns for better efficiency during testing process.

Prerequisites

In this documentation and included samples, xUnit framework is in use. Basic knowledge on this area is required.

Test environemnt

Below diagram shows simplified schema of how a test environment works.

+------------------------------------+
| TestEnvironment                    |
|                                    |
|  +---------------------------+     |
|  |                           |     |    +-----------------+
|  |  Setup test environment   |     |    |                 |
|  |  using Blocks (pipeline)  |     |    |  Create test    |
|  |                           |     +--->+   (ITest)       |
|  +------------+--------------+     |    |                 |
|               |                    |    +--------+--------+
|   +-----------+---------------+    |             |
|   |                           |    |             |
|   | Build a test environment  |    |             v
|   | (blocks are executed)     |    |    +--------+----------------+
|   |                           |    |    | Execute functionalities |
|   +---------------------------+    |    | to test.                |
|                                    |    | (resolve it from test)  |
+------------------------------------+    +---------+---------------+
                                                    |
                                                    |
                                            +-------+-----------+
                                            |   Assert results  |
                                            +-------------------+

  • Test environment is a class used to create tests.
  • Test environment is built with blocks.
  • Blocks are executed in a pipeline, one after another.
  • Lifetime of the test environment may be:
    • test method
    • single test class
    • multiple test classes
  • Created test are child-scoped instance of the ITest interface

Test environment is represented by ITestEnvironment interfce.

/// <summary>
/// Lifetime of this object should be managed by test framework like a xUnit, NUnit.
/// </summary>
public interface ITestEnvironment : IAsyncDisposable
{
    Task<Test> CreateTest(Action<TestOptions> optionsModifier = null);
    Task<ITestEnvironment> Build(params object[] data);
    void SetServiceProvider(IServiceProvider provider);
    IServiceProvider GetServiceProvider();

    // methods to manipulate blocks (Add, Remove, Update, ect.)
    (...)

Dependency injection

Using DI container is part of Modulos.Testing proposal pattern.

The below diagram shows how DI container is set and consumed by the test environment.

+------------------------+       +-----------+
|                        +------>+ Building  |
|  Test environment      |       | process   |
|  +-----------------+   |       +----+------+
|  |                 |   |            |
|  | Dependency      |   |            v
|  | Injection       |   |    +-------+-----------+
|  | Container       +<-------+SetServiceProvider |
|  +-------+---------+   |    +-------------------+
|          |             |
+------------------------+
           |
     +-----V-------+
     |CreateTest() |
     |             |
     +------+------+
            |
            v
  +---------+-----------+
  |  ITest is child     |
  |  scope of test      |
  |  environment        |
  |  container          |
  +---------------------+
  • use SetServiceProvider from block to setup DI container for test environment
  • if few blocks call SetServiceProvider then the last one win
  • test environment uses dependency injection to control lifecycle
  • each of the created test is child scope of test environment container
  • after SetServiceProvider is called, any further block can resolve dependencies from the DI container
  • even if setting up DI container is not required - for most of the cases is strongly recommended

Defining and building test environment

Test environment is built with blocks.

await using var env = new TestEnvironment();
env.Add<InitializeIoc>(); // block
env.Add<DtopAndCreateDb>(); // block
await env.Build();

Blocks

Think about blocks as workers, created and executed to prepare and clean up tests environment.

  • Block is represented by IBlock interfce.
    public interface IBlock
    {
        // executes during building 
        Task<BlockExecutionResult> Execute(ITestEnvironment testEnv);

        // executes during disposal of test environment 
        Task ExecuteAtTheEnd(ITestEnvironment testEnv);
    }
  • Blocks are defined by developers, for example, the mentioned InitializeIoc is not available from a Modulos.Testing package. Anyway, feel free to analyze and copy implementation from available examples.
  • Blocks can be add or manipulate using ITestEnvironment instance.

Setup block

Block may be updated during the configuration process.

// during add 
env.Add<SampleBlock>(block =>
{
    block.SampleBlockProperty  = "ulalla";
});

// during update 
and.Update<SammpleBlock>(block =>
{
    block.SampleBlockProperty  = "ulalla";
});

Creating test

The prepared and built test environment can create tests. It's done by calling CreateTest method.

(...)
await using ITest test = await env.CreateTest();

Created test is represented by ITest interface.

public interface ITest : IServiceScope, IServiceProvider, IAsyncDisposable
{
    (...)
}

The simplest explanation of ITest is to say that:

  • it's an execution scope for the test (controls lifecycle)
  • because it's child scope of test environment DI container, it provides registered data
await using var test = await env.CreateTest()
{
    // obtain registered dependencies 
    var functionality = test.Resolve<SomeDependency>();
}
// test is disposed, scope is disposed
// (brackets are no required)

Wrappers

Tests can be wrapped by ITestWrapper.

public interface ITestWrapper
{
    Task Begin();
    Task Finish();
}

Begin method is called just after the test is created, Finish during the disposal process. Test wrappers can consume registered dependencies from the same scope as a test (ctor injection).

Defining wrappers

  • It's possible to configure wrapper directly from the test environment. In this situation, each of the created tests will be wrapped.
public class CustomEnvironment : DefaultEnvironment
{
    public CustomEnvironment()
    {
        (...)
        // each test will be wrapped with EmptyTestWrapper
        Wrap<EmptyTestWrapper>();
    }
}
  • During test creation using CreateTest method. In this situation, only a particular invocation will be wrapped.
await using var test = await testEnvironment
    .CreateTest(options =>
    {
        options.Wrap<TestWrapper>();
    });

Study case - simplest

Let's write some integration test.

[Fact]
public async Task GetUserById_test()
{
    // Arrange
    await using var env = new TestEnvironment();
    env.Add<InitializeIoc>(block =>
    {
        block.RegisterServices = collection =>
        {
            collection.AddSingleton<IUserRepository, DbUserRepository>();
            collection.AddTransient<IGetUserById, GetUserById>();
        };
    });
    await env.Build();
    await using var test = await env.CreateTest();
    var functionality = test.Resolve<IGetUserById>();

    // Act
    var result = functionality.Execute(0);

    // Assert
    result.Should().NotBeNull();
    result.Name.Should().Be("Tom");
}

public class GetUserById : IGetUserById
{
    public GetUserById(IUserRepository userStorage)
    (...)
}

And now unit test with mocked repository. It's done by replacing one line.

this

collection.AddSingleton<IUserRepository, DbUserRepository>();

into this

collection.AddSingleton<IUserRepository, MockedUserRepository>();

You can create integration and unit tests with the same pattern, it only dependes of registration (real object or mocked). Maybe it does not look like a great piece of art, but in fact, it is a really nice feature. It's possible to create a shared codebase for unit and integration tests.

Controlling environment lifetime (xUnit)

In the currently presented examples test environments were created for each of the tests, This approach is generally acceptable, but for various scenarios non needed. For example, if you prepared an environment for some integration test, there is a lot of chance it's fittable to more than one test. In fact, it's not a bad idea to create one environment shared between dozens or even hundreds of tests.

Let's define some custom environments and use xUnit to consume and controll its lifetime.

Define environment

public class CustomEnvironment: TestEnvironment, 
    IAsyncLifetime  // xUnit
{
    public CustomEnvironment()
    {
        Add<InitializeIoc>(block =>
        {
            block.AddTransient<IGetUserById, GetUserById>();
            block.AddTransient<IUserRepository, MockedUserRepository>();
        });
    }

    async Task IAsyncLifetime.InitializeAsync() // xUnit (IAsyncLifetime)
    {
        await Build();
    }

    async Task IAsyncLifetime.DisposeAsync() // xUnit (IAsyncLifetime)
    {
        await DisposeAsync();
    }
}

Consume it, in this example one environment for a class

public class TestClass : IClassFixture<CustomEnvironment> // xUnit 
{
    private readonly CustomEnvironment env;

    public TestClass(CustomEnvironment env)
    {
        this.env = env;
    }

    [Fact]
    public async Task GetUserById_test()
    {
        // Arrange
        await using var test = await env.CreateTest();
        var functionality = test.Resolve<IGetUserById>();

        // Act
        var result = functionality.Execute(0);

        // Assert
        result.Should().NotBeNull();
        result.Name.Should().Be("Tom");
    }

You can either use the same environment in many classes using xUnit collection functionality.

[CollectionDefinition(nameof(CustomEnvironment))]
public class CustomEnvironmentCollection:ICollectionFixture<CustomEnvironment>
{
}

The same environment in many classes.

[Collection(nameof(CustomEnvironment))]
public class TestClass : IClassFixture<CustomEnvironment>
{
    public TestClass(CustomEnvironment env)
    {
        this.env = env;
    }
(...)

[Collection(nameof(CustomEnvironment))]
public class TestClass2 : IClassFixture<CustomEnvironment>
{
    public TestClass2(CustomEnvironment env)
    {
        this.env = env;
    }
(...)

This approach may result in significant performance improvements.

Environment inheritance

Inherit from test environments is part of Modulos.Testing pattern. This approach is an essence of reconfigurable and reusable test environments. It was the purpose to write this library.

Let's see how it works.

1.Definitions of common blocks, let's say it's from nuget package.

public sealed class InitializeIoc : IBlock, IServiceCollection
{
    private readonly IServiceCollection internalCollection;

    public InitializeIoc()
    {
        internalCollection = new ServiceCollection();
    }

    Task<BlockExecutionResult> IBlock.Execute(ITestEnvironment testEnv)
    {
        var collection = new ServiceCollection();

        foreach (var serviceDescriptor in internalCollection)
        {
            collection.Add(serviceDescriptor);
        }

        var provider = collection.BuildServiceProvider();

        testEnv.SetServiceProvider(provider);

        return Task.FromResult(BlockExecutionResult.EmptyContinue);
    }

    Task IBlock.ExecuteAtTheEnd(ITestEnvironment testEnv)
    {
        ((IServiceCollection)this).Clear();
        return Task.CompletedTask;
    }

    (...) // 'delegate' implementation of IServiceCollection (internalCollection)
}

public sealed class CreateDatabase : IBlock
{
    public bool DropDbAtTheEnd { get; set; } = true;
    public bool RecreateDbAtStart { get; set; } = true;
    (...)
} 

public sealed class Cleanup : IBlock
{
    (...)
} 

2. Default environment from nuget package.

public class DefaultEnvironment : TestEnvironment, IAsyncLifetime
{
    public DefaultEnvironment()
    {
        Add<InitializeIoc>();
        Add<CreateDatabase>();
        Add<Cleanup>();
    }

    async Task IAsyncLifetime.InitializeAsync()
    {
        await Build();
    }

    async Task IAsyncLifetime.DisposeAsync()
    {
        await DisposeAsync();
    }
}

3. At last inheritance with updates.

public class MyCustomEnvironment : DefaultEnvironment
{
    public MyCustomEnvironment()
    {
        Update<InitializeIoc>(block =>
        {
            // register additional services for MyCustomEnvironment
            block.AddTransient<IUserRepository, InMemoryUserRepository>();
        });

        // change behavior of CreateDatabase block
        Update<CreateDatabase>(block =>
        {
            block.DropDbAtTheEnd = false;
        });
    }
}

Modulos.Testing Pattern (MTP)

  • Prepare common blocks and environments to share them between projects, teams, solutions.
  • It's a good idea for the test environment to inherit from the existing one
  • Use mocks as dependency injection registrations

Give a chance for this pattern and soon it's going to be one of your favorite.

Examples

Examples are available directly in 'Modulos.Testing` project github.