How To Unit Test Framework Services In Orleans

How To Unit Test Framework Services In Orleans

Testing interactions between components in a distributed system context tends to involve full-scale integration testing rather than fast unit testing. This requires developers to wait for a deployment to execute, rather than having immediate feedback along the test-driven-development cycle. The use of the provider model in Orleans takes much of this pain away, as we can instead mock and fake the base services and providers, while relying on them to do the right thing at run time. We can still have deployment-time integration testing, but with Orleans, it is easy to have immediate testing feedback at development time.

This article describes how we can go about this, and it is based on the unit testing sample I’ve added to the Orleans repository.

TLDR

Overview

Though not obvious at first glance, Orleans allows mocking and faking of all of its own services and interfaces. This enables very high test coverage, which is can be hard to attain when developing a composed system.

Orleans favours testing using two approaches, Isolated Testing and Hosted Testing.

Isolated Testing

These are tests where both dependencies and Orleans services are mocked on a test-by-test basis. This style enables fine-grained isolated unit testing of the code under question. However, like any testing using mocks, it can also lead to more verbose test code due to redundant mocking constructs.

Hosted Testing

These are tests that run on the Orleans Test Cluster, where fake Orleans services are used. This style enables course-grained integration testing of multiple components, using fake data sources for higher coverage. It leads to both shorter and more reliable test code due to better reproduction of live running conditions. However, if using a shared test cluster, the developer must take care to prepare shared fake components and partition data as appropriate to avoid clashes during parallel testing. That said, the developer can spin up multiple test clusters as required and in parallel. The code samples in the appendix show how to wire this up in xUnit.

How It Works

The examples below demonstrate how to test various scenarios that depend on Orleans services. The actual grains under test are the same regardless of approach.

There is no setup required to code isolated unit tests. We call them isolated for a reason. Each one stands on its own.

However we do need some setup for the hosted integration tests - we need to spin up one or more test clusters where our tests can run. We also need to configure this cluster (or clusters!) with some fake services that we can verify afterwards. That said, this is easy to do - you can find that setup in the appendix at the end of this post.

Testing A Basic Grain

Here is a simple grain that allows setting and getting a value.

public class BasicGrain : Grain, IBasicGrain
{
    private int value;

    public Task<int> GetValueAsync() => Task.FromResult(value);

    public Task SetValueAsync(int value)
    {
        this.value = value;
        return Task.CompletedTask;
    }
}
Isolated Test

Testing this grain in isolation is no different from testing any other code.

[Fact]
public async Task Gets_And_Sets_Value()
{
    // create a new grain
    var grain = new BasicGrain();

    // assert the default value is zero
    Assert.Equal(0, await grain.GetValueAsync());

    // set a new value
    await grain.SetValueAsync(123);

    // assert the new value is as set
    Assert.Equal(123, await grain.GetValueAsync());
}

It may feel funny that while we never create grain ourselves in feature code, we’re doing it here. However this is still a valid - and fast - way of unit testing them.

Hosted Test

We can also test this grain on an in-memory test cluster. Again, see the appendix for instructions on how to set this up. This allows us to grab grains from said cluster at test time, in the same way we write feature code.

[Fact]
public async Task Gets_And_Sets_Value()
{
    // get a new basic grain from the cluster
    var grain = fixture.Cluster.GrainFactory.GetGrain<IBasicGrain>(Guid.NewGuid());

    // assert the default value is zero
    Assert.Equal(0, await grain.GetValueAsync());

    // set a new value
    await grain.SetValueAsync(123);

    // assert the new value is as set
    Assert.Equal(123, await grain.GetValueAsync());
}

In the example above, fixture.Cluster references the test cluster, which makes available a GrainFactory for us to use as we would in feature code.

This approach didn’t save any lines of code here (and we’ve had to pay to set this up), but now we’re testing a live grain, living in a live cluster and with the live behaviours we expect to see. We will get our investment money back soon enough.

Testing A Grain That Calls Another Grain

One of the neat things of actor frameworks like Orleans is the ability of actors (or grains in this case) to call each other across the cluster, regardless of where they are. This enables application modelling techniques very hard to do achieve in stateless designs. Here is a sample grain that keeps a running count and publishes that counter to some other grain when requested, wherever it is. And don’t worry if it looks simple - we’ll complicate this soon enough.

public class CallingGrain : Grain, ICallingGrain
{
    private int counter;

    public Task IncrementAsync()
    {
        counter += 1;
        return Task.CompletedTask;
    }

    public Task PublishAsync() => GrainFactory.GetGrain<ISummaryGrain>(Guid.Empty).SetAsync(GrainKey, counter);

    /// <summary>
    /// Opens up the grain factory for mocking.
    /// </summary>
    public virtual new IGrainFactory GrainFactory => base.GrainFactory;

    /// <summary>
    /// Opens up the grain key name for mocking.
    /// </summary>
    public virtual string GrainKey => this.GetPrimaryKeyString();
}
On Dependency Surfacing

Note how we are opening up the GrainFactory and GrainKey properties for mocking. Under normal circumstances, Orleans is the one providing these features, and hence they will not work when we instantiate the grain ourselves. We must therefore open them up for mocking in the isolated test code.

On the other hand, we don’t need this at all for hosted tests! If you’re going with the test cluster, by all means, remove that code. When grains are running in a live cluster, those features work just as you expect them to.

Isolated Test

Testing this grain in isolation now requires a bit more care…

[Fact]
public async Task Publishes_On_Demand()
{
    // mock a summary grain
    var summary = Mock.Of<ISummaryGrain>();

    // mock the grain factory
    var factory = Mock.Of<IGrainFactory>(_ => _.GetGrain<ISummaryGrain>(Guid.Empty, null) == summary);

    // mock the grain type so we can mock the base orleans methods
    var grain = new Mock<CallingGrain>() { CallBase = true };
    grain.Setup(_ => _.GrainFactory).Returns(factory);
    grain.Setup(_ => _.GrainKey).Returns("MyCounter");

    // increment the value in the grain
    await grain.Object.IncrementAsync();

    // publish the value to the summary
    await grain.Object.PublishAsync();

    // assert the summary was called as expected
    Mock.Get(summary).Verify(_ => _.SetAsync("MyCounter", 1));
}

As testing this grain now requires mocking of framework services, we now have to use var grain = new Mock<CallingGrain>() { CallBase = true }; in order to override them. Overriding also adds a few more lines of code. Still, the test remains isolated as we intend it to be.

Hosted Test

The hosted test on the other hand, looks as simple as feature code is with Orleans.

[Fact]
public async Task Publishes_On_Demand()
{
    // we should use a unique grain key as we are using a shared test cluster
    var key = Guid.NewGuid().ToString();

    // get a new grain from the test host
    var grain = fixture.Cluster.GrainFactory.GetGrain<ICallingGrain>(key);

    // increment the value in the grain
    await grain.IncrementAsync();

    // publish the value to the summary
    await grain.PublishAsync();

    // assert the summary was called as expected
    Assert.Equal(1, await fixture.Cluster.GrainFactory.GetGrain<ISummaryGrain>(Guid.Empty).TryGetAsync(key));
}

There are no framework services to fake in the unit test, as the test cluster is providing the correct functionality out-of-the-box, leading to cleaner test code.

Testing A Grain That Performs Work On A Timer

Another common scenario is for grains to perform some work on a timer tick. For the benefit of simplicity, here is a grain that just increments an internal counter on a schedule.

public class TimerGrain : Grain, ITimerGrain
{
    private int value;

    public Task<int> GetValueAsync() => Task.FromResult(value);

    public override Task OnActivateAsync()
    {
        RegisterTimer(_ =>
        {
            ++value;
            return Task.CompletedTask;
        }, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));

        return base.OnActivateAsync();
    }

    /// <summary>
    /// This opens up the timer registration method for mocking.
    /// </summary>
    public virtual new IDisposable RegisterTimer(Func<object, Task> asyncCallback, object state, TimeSpan dueTime, TimeSpan period) =>
        base.RegisterTimer(asyncCallback, state, dueTime, period);
}

Note how we are opening up the RegisterTimer() framework method for mocking, just like we did before with other services. Again, we only need this for isolated tests and not for hosted tests.

Isolated Test

Testing this in isolation requires us to override the timer behaviour. This is because Orleans is no longer there to issue timer ticks in the first place.

[Fact]
public async Task Increments_Value_On_Timer()
{
    // mock the grain to override methods
    var grain = new Mock<TimerGrain>() { CallBase = true };

    // mock the timer registration method and capture the action
    Func<object, Task> action = null;
    object state = null;
    var dueTime = TimeSpan.FromSeconds(1);
    var period = TimeSpan.FromSeconds(1);
    grain.Setup(_ => _.RegisterTimer(It.IsAny<Func<object, Task>>(), It.IsAny<object>(), It.IsAny<TimeSpan>(), It.IsAny<TimeSpan>()))
        .Callback<Func<object, Task>, object, TimeSpan, TimeSpan>((a, b, c, d) => { action = a; state = b; dueTime = c; period = d; })
        .Returns(Mock.Of<IDisposable>());

    // simulate activation
    await grain.Object.OnActivateAsync();

    // assert the timer was registered
    Assert.NotNull(action);
    Assert.Null(state);
    Assert.Equal(TimeSpan.FromSeconds(1), dueTime);
    Assert.Equal(TimeSpan.FromSeconds(1), period);

    // assert the initial value is zero
    Assert.Equal(0, await grain.Object.GetValueAsync());

    // tick the timer
    await action(null);

    // assert the new value is one
    Assert.Equal(1, await grain.Object.GetValueAsync());
}

Here we are overriding the RegisterTimer() framework method in order to tick the timer when we want to, keeping the test very fast. Note as well how we are explicitly calling OnActivateAsync(). We must do this as Orleans is not there to call it for us as needed. This does start leading to more verbose code now, though nothing out of the ordinary for this type of functionality.

Hosted Test

Testing this using the test cluster is far more straightforward. However, this requires some common setup to work, which you can find in the appendix.

[Fact]
public async Task Increments_Value_On_Timer()
{
    // using a random key allows parallel testing on the shared cluster
    var key = Guid.NewGuid();

    // get a new instance of the grain
    var grain = fixture.Cluster.GrainFactory.GetGrain<ITimerGrain>(key);

    // assert the initial state is zero - this will activate the grain and register the timer
    Assert.Equal(0, await grain.GetValueAsync());

    // assert the timer was registered on some silo
    var timer = fixture.GetTimers(grain).SingleOrDefault();

    Assert.NotNull(timer);

    // tick the timer
    await timer.TickAsync();

    // assert the new value is one
    Assert.Equal(1, await grain.GetValueAsync());
}

As compared to the isolated test, this one looks far more readable. Get the grain proxy, call something to activate it, ensure the timer got registered, tick it, and check the results. Nothing to it.

Testing A Grain That Calls Another Grain On A Timer

Didn’t I say we’d start complicating soon enough? Well this is yet another common use of grains, calling some other grain on a schedule in order to do some work, often to propagate some transient state, or to act as a watchdog for some activity.

public class CallingTimerGrain : Grain, ICallingTimerGrain
{
    private int counter;

    /// <summary>
    /// Orleans calls this on grain activation.
    /// For isolated unit tests we must call this to simulate activation.
    /// However, the test host will call this on its own.
    /// </summary>
    public override Task OnActivateAsync()
    {
        // register a timer to call another grain every second
        RegisterTimer(_ => GrainFactory.GetGrain<ISummaryGrain>(Guid.Empty).SetAsync(GrainKey, counter),
            null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));

        return base.OnActivateAsync();
    }

    /// <summary>
    /// This opens up the grain key for mocking.
    /// </summary>
    public virtual string GrainKey => this.GetPrimaryKeyString();

    /// <summary>
    /// This opens up the grain factory property for mocking.
    /// </summary>
    public virtual new IGrainFactory GrainFactory =>
        base.GrainFactory;

    /// <summary>
    /// This opens up the timer registration method for mocking.
    /// </summary>
    public virtual new IDisposable RegisterTimer(Func<object, Task> asyncCallback, object state, TimeSpan dueTime, TimeSpan period) =>
        base.RegisterTimer(asyncCallback, state, dueTime, period);

    /// <summary>
    /// Increments the counter by one.
    /// </summary>
    public Task IncrementAsync()
    {
        counter += 1;
        return Task.CompletedTask;
    }
}

Again, the opening up of the GrainKey, GrainFactory and RegisterTimer services for mocking is only to enable isolated testing.

Isolated Test

Testing this scenario in isolation is now a matter of combining the mocking of the timer service with the mocking of the grain factory.

public class CallingTimerGrainTests
{
    [Fact]
    public async Task Publishes_Counter_To_Summary_On_Timer()
    {
        // mock the summary grain
        var summary = Mock.Of<ISummaryGrain>();

        // mock the grain factory and summary grain
        var factory = Mock.Of<IGrainFactory>(_ => _.GetGrain<ISummaryGrain>(Guid.Empty, null) == summary);

        // mock the grain under test and override affected orleans methods
        var grain = new Mock<CallingTimerGrain>() { CallBase = true };
        grain.Setup(_ => _.GrainFactory).Returns(factory);
        grain.Setup(_ => _.GrainKey).Returns("MyGrainKey");

        Func<object, Task> action = null;
        object state = null;
        var dueTime = TimeSpan.Zero;
        var period = TimeSpan.Zero;
        grain.Setup(_ => _.RegisterTimer(It.IsAny<Func<object, Task>>(), It.IsAny<object>(), It.IsAny<TimeSpan>(), It.IsAny<TimeSpan>()))
            .Callback<Func<object, Task>, object, TimeSpan, TimeSpan>((a, b, c, d) => { action = a; state = b; dueTime = c; period = d; })
            .Returns(Mock.Of<IDisposable>());

        // increment the value while simulating activation
        await grain.Object.OnActivateAsync();
        await grain.Object.IncrementAsync();

        // assert the timer was registered
        Assert.NotNull(action);
        Assert.Null(state);
        Assert.Equal(TimeSpan.FromSeconds(1), dueTime);
        Assert.Equal(TimeSpan.FromSeconds(1), period);

        // tick the timer
        await action(null);

        // assert the summary got the first result
        Mock.Get(factory.GetGrain<ISummaryGrain>(Guid.Empty)).Verify(_ => _.SetAsync("MyGrainKey", 1));

        // increment the value
        await grain.Object.IncrementAsync();

        // tick the timer again
        await action(null);

        // assert the summary got the next result
        Mock.Get(summary).Verify(_ => _.SetAsync("MyGrainKey", 2));
    }
}

Like mocking code always does, though, it does start getting quite verbose.

Hosted Test

Testing this grain on the hosted test cluster, however, still resembles feature code.

[Fact]
public async Task Publishes_Counter_To_Summary_On_Timer()
{
    // using a random key allows parallel testing on the shared cluster
    var key = Guid.NewGuid().ToString();

    // get a new grain instance to test
    var grain = fixture.Cluster.GrainFactory.GetGrain<ICallingTimerGrain>(key);

    // increment the counter - this will also activate the grain and register the timer
    await grain.IncrementAsync();

    // assert the timer was registered on some silo in the cluster
    var timer = fixture.GetTimers(grain).Single();

    // tick the timer once
    await timer.TickAsync();

    // assert the summary grain got the first result
    Assert.Equal(1, await fixture.Cluster.GrainFactory.GetGrain<ISummaryGrain>(Guid.Empty).TryGetAsync(key));

    // increment the counter again
    await grain.IncrementAsync();

    // tick the timer again
    await timer.TickAsync();

    // assert the summary grain got the second result
    Assert.Equal(2, await fixture.Cluster.GrainFactory.GetGrain<ISummaryGrain>(Guid.Empty).TryGetAsync(key));
}

We get the grain proxy, ask to increment the counter, tick the timer, ensure the target grain got the result and then do it again just to make sure. Nice and tidy, we’re starting to get our investment back.

Testing A Grain That Does Work On A Reminder

Reminders are just long-lived timers that survive the grain lifecycle. Testing grains that use reminders is therefore similiar to testing grain that use timers. Here is a sample grain that increments a counter upon a reminder tick.

public class ReminderGrain : Grain, IReminderGrain, IRemindable
{
    private int value;

    public override async Task OnActivateAsync()
    {
        await RegisterOrUpdateReminder(nameof(IncrementAsync), TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
        await base.OnActivateAsync();
    }

    public Task<int> GetValueAsync() => Task.FromResult(value);

    private Task IncrementAsync()
    {
        value += 1;
        return Task.CompletedTask;
    }

    public virtual new Task<IGrainReminder> RegisterOrUpdateReminder(string reminderName, TimeSpan dueTime, TimeSpan period) =>
        base.RegisterOrUpdateReminder(reminderName, dueTime, period);

    public async Task ReceiveReminder(string reminderName, TickStatus status)
    {
        switch (reminderName)
        {
            case nameof(IncrementAsync):
                await IncrementAsync();
                break;

            default:
                await UnregisterReminder(await GetReminder(reminderName));
                break;
        }
    }
}
Isolated Testing

Due to the way grains implement the IRemindable interface, ticking the reminder is as simple as calling the ReceiveReminder() method on its own with a given TickStatus value.

[Fact]
public async Task Increments_Value_On_Reminder()
{
    // mock the grain
    var grain = new Mock<ReminderGrain>() { CallBase = true };
    grain.Setup(_ => _.RegisterOrUpdateReminder("IncrementAsync", TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1)))
        .Returns(Task.FromResult(Mock.Of<IGrainReminder>(_ => _.ReminderName == "IncrementAsync")));

    // simulate activation
    await grain.Object.OnActivateAsync();

    // ensure the reminder was registered
    grain.Verify(_ => _.RegisterOrUpdateReminder("IncrementAsync", TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1)));

    // assert the initial value is zero
    Assert.Equal(0, await grain.Object.GetValueAsync());

    // tick the reminder
    await grain.Object.ReceiveReminder("IncrementAsync", new TickStatus());

    // assert the new value is one
    Assert.Equal(1, await grain.Object.GetValueAsync());
}

Overriding the RegisterOrUpdateReminder() method allows us to Verify() that the grain indeed registered the reminder as we expect.

Hosted Testing

We can also do this using the test cluster, again leading to cleaner code.

[Fact]
public async Task Increments_Value_On_Reminder()
{
    // get a new grain to test
    var grain = fixture.Cluster.GrainFactory.GetGrain<IReminderGrain>(Guid.NewGuid());

    // assert the initial value is zero - this will also activate the grain
    Assert.Equal(0, await grain.GetValueAsync());

    // assert the reminder was registered on one of the fake registries
    var reminder = fixture.GetReminder(grain, "IncrementAsync");
    Assert.NotNull(reminder);

    // tick the reminder
    await grain.AsReference<IRemindable>().ReceiveReminder("IncrementAsync", new TickStatus());

    // assert the new value is one
    Assert.Equal(1, await grain.GetValueAsync());
}

Testing A Grain That Persists State To Storage

As a final example, here is a grain that persists its own state to storage upon request.

public class PersistentGrain : Grain, IPersistentGrain
{
    private readonly IPersistentState<MyState> value;

    public PersistentGrain([PersistentState("State")] IPersistentState<MyState> value)
    {
        this.value = value;
    }

    public Task SetValueAsync(int value)
    {
        this.value.State.Value = value;
        return this.value.WriteStateAsync();
    }

    [ProtoContract]
    public class MyState
    {
        [ProtoMember(1)]
        public int Value { get; set; }
    }
}

The ProtoContract stuff relates to protocol buffers, not a target for testing right now.

Isolated Testing

For testing in isolation, we just mock the IPersistentState<PersistentGrain.MyState> and Verify its use afterwards.

[Fact]
public async Task Saves_State()
{
    // mock a persistent state item
    var state = Mock.Of<IPersistentState<PersistentGrain.MyState>>(_ => _.State == Mock.Of<PersistentGrain.MyState>());

    // create a new grain - we dont mock the grain here because we do not need to override any base methods
    var grain = new PersistentGrain(state);

    // set a new value
    await grain.SetValueAsync(123);

    // assert the state was saved
    Mock.Get(state).Verify(_ => _.WriteStateAsync());
    Assert.Equal(123, state.State.Value);
}
Hosted Testing

For hosting testing, we instead make use of the pre-registered fake storage provider to tell us what the state looks like.

[Fact]
public async Task Saves_State()
{
    // get a brand new grain to test
    var grain = fixture.Cluster.GrainFactory.GetGrain<IPersistentGrain>(Guid.NewGuid());

    // set its value to something we can check
    await grain.SetValueAsync(123);

    // assert that state was saved by one of the silos
    var state = fixture.GetGrainState(typeof(PersistentGrain), "State", grain);
    Assert.NotNull(state);

    // assert that state is of the corect type
    var obj = state.State as PersistentGrain.MyState;
    Assert.NotNull(obj);

    // assert that state has the correct value
    Assert.Equal(123, obj.Value);
}

Appendix: Setting Up Hosted Tests

While use of the test cluster makes for more straightforward testing, it does require some up-front setup. That said, this is a one-time cost. If you’re using xUnit you can probably lift the fixture code from the samples outright and go with that.

Let’s looks at the class we need to add to an xUnit test project

ClusterFixture

The ClusterFixture works as an xUnit managed fixture - a class that xUnit will instantiate and dispose of, while running a given collection of tests.

The fixture below enables spinning up multiple test clusters, each with its own set of silos.

As each cluster can have multiple silos, each with its own set of fake services, the fixture provides some helper methods to locate the right services in the right places during testing.

public class ClusterFixture : IDisposable
{
    /// <summary>
    /// Identifier for this test cluster instance to facilitate parallel testing with multiple clusters that need fake services.
    /// </summary>
    public string TestClusterId { get; } = Guid.NewGuid().ToString();

    /// <summary>
    /// Exposes the shared cluster for unit tests to use.
    /// </summary>
    public TestCluster Cluster { get; }

    /// <summary>
    /// Keeps all the fake grain storage instances in use by different clusters to facilitate parallel unit testing.
    /// </summary>
    public static ConcurrentDictionary<string, ConcurrentBag<FakeGrainStorage>> GrainStorageGroups { get; } = new ConcurrentDictionary<string, ConcurrentBag<FakeGrainStorage>>();

    /// <summary>
    /// Gets the fake grain storage item for the given grain by searching across all silos.
    /// </summary>
    public IGrainState GetGrainState(Type implementationType, string name, IGrain grain)
    {
        return GrainStorageGroups[TestClusterId]
            .SelectMany(_ => _.Storage)
            .Where(_ => _.Key.Item1 == $"{implementationType.FullName}{(name == null ? "" : $",{typeof(PersistentGrain).Namespace}.{name}")}")
            .Where(_ => _.Key.Item2.Equals((GrainReference)grain))
            .Select(_ => _.Value)
            .SingleOrDefault();
    }

    /// <summary>
    /// Keeps all the fake timer registries in use by different clusters to facilitate parallel unit testing.
    /// </summary>
    public static ConcurrentDictionary<string, ConcurrentBag<FakeTimerRegistry>> TimerRegistryGroups { get; } = new ConcurrentDictionary<string, ConcurrentBag<FakeTimerRegistry>>();

    /// <summary>
    /// Gets all the fake timers for the target grain across all silos.
    /// </summary>
    public IEnumerable<FakeTimerEntry> GetTimers(IGrain grain)
    {
        return TimerRegistryGroups[TestClusterId]
            .SelectMany(_ => _.GetAll())
            .Where(_ => _.Grain.GrainReference.Equals((GrainReference)grain));
    }

    /// <summary>
    /// Keeps all the fake reminder registries in use by different clusters to facilitate parallel unit testing.
    /// </summary>
    public static ConcurrentDictionary<string, ConcurrentBag<FakeReminderRegistry>> ReminderRegistryGroups { get; } = new ConcurrentDictionary<string, ConcurrentBag<FakeReminderRegistry>>();

    /// <summary>
    /// Gets the target fake reminder by searching across all silos.
    /// </summary>
    public FakeReminder GetReminder(IGrain grain, string name)
    {
        return ReminderRegistryGroups[TestClusterId]
            .Select(_ => _.GetReminder((GrainReference)grain, name).Result)
            .Where(_ => _ != null)
            .SingleOrDefault();
    }

    public ClusterFixture()
    {
        // prepare to receive the fake services from individual silos
        GrainStorageGroups[TestClusterId] = new ConcurrentBag<FakeGrainStorage>();
        TimerRegistryGroups[TestClusterId] = new ConcurrentBag<FakeTimerRegistry>();
        ReminderRegistryGroups[TestClusterId] = new ConcurrentBag<FakeReminderRegistry>();

        var builder = new TestClusterBuilder();

        // add the cluster id for this instance
        // this allows the silos to safely lookup shared data for this cluster deployment
        // without this we can only share data via static properties and that messes up parallel testing
        builder.ConfigureHostConfiguration(config =>
        {
            config.AddInMemoryCollection(new Dictionary<string, string>()
            {
                { nameof(TestClusterId), TestClusterId }
            });
        });

        // a configurator allows the silos to configure themselves
        // at this time, configurators cannot take injected parameters
        // therefore we must other means of sharing objects as you can see above
        builder.AddSiloBuilderConfigurator<SiloBuilderConfigurator>();

        Cluster = builder.Build();
        Cluster.Deploy();
    }

    private class SiloBuilderConfigurator : ISiloBuilderConfigurator
    {
        public void Configure(ISiloHostBuilder hostBuilder)
        {
            hostBuilder.ConfigureServices(services =>
            {
                // add the fake storage provider as default in a way that lets us extract it afterwards
                services.AddSingleton(_ => new FakeGrainStorage());
                services.AddSingleton<IGrainStorage>(_ => _.GetService<FakeGrainStorage>());

                // add the fake timer registry in a way that lets us extract it afterwards
                services.AddSingleton<FakeTimerRegistry>();
                services.AddSingleton<ITimerRegistry>(_ => _.GetService<FakeTimerRegistry>());

                // add the fake reminder registry in a way that lets us extract it afterwards
                services.AddSingleton<FakeReminderRegistry>();
                services.AddSingleton<IReminderRegistry>(_ => _.GetService<FakeReminderRegistry>());
            });

            hostBuilder.UseServiceProviderFactory(services =>
            {
                var provider = services.BuildServiceProvider();
                var config = provider.GetService<IConfiguration>();

                // grab the cluster id that owns this silo
                var clusterId = config[nameof(TestClusterId)];

                // extract the fake services from the silo so unit tests can access them
                GrainStorageGroups[clusterId].Add(provider.GetService<FakeGrainStorage>());
                TimerRegistryGroups[clusterId].Add(provider.GetService<FakeTimerRegistry>());
                ReminderRegistryGroups[clusterId].Add(provider.GetService<FakeReminderRegistry>());

                return provider;
            });
        }
    }

    public void Dispose() => Cluster.StopAllSilos();
}

To be able to tell xUnit where and when to use this fixture, we need to create at least one CollectionDefinition for it. This is an xUnit specific pattern.

[CollectionDefinition(nameof(ClusterCollection))]
public class ClusterCollection : ICollectionFixture<ClusterFixture>
{
}

Nothing stops us from creating more collection definitions, each with its own name, instead of the default in the example. This is what allows us to spin up multiple clusters at the same time.

This xUnit collection allows us to inject a cluster fixture instance into specifc xUnit unit tests.

[Collection(nameof(ClusterCollection))]
public class BasicGrainTests
{
    private readonly ClusterFixture fixture;

    public BasicGrainTests(ClusterFixture fixture)
    {
        this.fixture = fixture;
    }

    /* ... */
}

And voila - shared test cluster on-demand.

Fakes

The example test cluster fixture makes generous use of fake services to promote easier tests. Below are some example, and nothing stops us from adding more as we need to.

The FakeGrainStorage holds grain state in memory, in the same spirit of the built-in memory storage provider, but one that we can search across all silos for easy state verification.

public class FakeGrainStorage : IGrainStorage
{
    public ConcurrentDictionary<Tuple<string, GrainReference>, IGrainState> Storage { get; } = new ConcurrentDictionary<Tuple<string, GrainReference>, IGrainState>();

    public Task ClearStateAsync(string grainType, GrainReference grainReference, IGrainState grainState)
    {
        Storage.TryRemove(Tuple.Create(grainType, grainReference), out _);
        return Task.CompletedTask;
    }

    public Task ReadStateAsync(string grainType, GrainReference grainReference, IGrainState grainState)
    {
        Storage.TryGetValue(Tuple.Create(grainType, grainReference), out grainState);
        return Task.CompletedTask;
    }

    public Task WriteStateAsync(string grainType, GrainReference grainReference, IGrainState grainState)
    {
        Storage[Tuple.Create(grainType, grainReference)] = grainState;
        return Task.CompletedTask;
    }
}

The FakeReminder is used by the FakeReminderRegistry to hold reminder information.

public class FakeReminder : IGrainReminder
{
    public FakeReminder(string reminderName, TimeSpan dueTime, TimeSpan period)
    {
        ReminderName = reminderName;
        DueTime = dueTime;
        Period = period;
    }

    public string ReminderName { get; }
    public TimeSpan DueTime { get; }
    public TimeSpan Period { get; }
}

The FakeReminderRegistry overrides the built-in reminder services, storing reminder entries in-memory and in searchable form.

public class FakeReminderRegistry : GrainServiceClient<IReminderService>, IReminderRegistry
{
    private readonly ConcurrentDictionary<GrainReference, ConcurrentDictionary<string, FakeReminder>> reminders =
        new ConcurrentDictionary<GrainReference, ConcurrentDictionary<string, FakeReminder>>();

    public FakeReminderRegistry(IServiceProvider provider) : base(provider)
    {
    }

    private ConcurrentDictionary<string, FakeReminder> GetRemindersFor(GrainReference reference) =>
        reminders.GetOrAdd(reference, _ => new ConcurrentDictionary<string, FakeReminder>());

    #region Fake Service Calls

    public Task<IGrainReminder> GetReminder(string reminderName)
    {
        GetRemindersFor(CallingGrainReference).TryGetValue(reminderName, out var reminder);
        return Task.FromResult((IGrainReminder)reminder);
    }

    public Task<List<IGrainReminder>> GetReminders() =>
        Task.FromResult(GetRemindersFor(CallingGrainReference).Values.Cast<IGrainReminder>().ToList());

    public Task<IGrainReminder> RegisterOrUpdateReminder(string reminderName, TimeSpan dueTime, TimeSpan period)
    {
        var reminder = new FakeReminder(reminderName, dueTime, period);
        GetRemindersFor(CallingGrainReference)[reminderName] = reminder;
        return Task.FromResult((IGrainReminder)reminder);
    }

    public Task UnregisterReminder(IGrainReminder reminder)
    {
        GetRemindersFor(CallingGrainReference).TryRemove(reminder.ReminderName, out _);
        return Task.CompletedTask;
    }

    #endregion Unvalidated Service Calls

    #region Test Helpers

    public Task<FakeReminder> GetReminder(GrainReference grainRef, string reminderName)
    {
        GetRemindersFor(grainRef).TryGetValue(reminderName, out var reminder);
        return Task.FromResult(reminder);
    }

    #endregion Test Helpers
}

The FakeTimerEntry is used by the FakeTimerRegistry to hold timer information in a searchable form.

/// <summary>
/// Implements a fake timer entry to facilitate unit testing.
/// </summary>
public class FakeTimerEntry : IDisposable
{
    private readonly TaskScheduler scheduler;
    private readonly FakeTimerRegistry owner;
    public Grain Grain { get; }
    public Func<object, Task> AsyncCallback { get; }
    public object State { get; }
    public TimeSpan DueTime { get; }
    public TimeSpan DuePeriod { get; }

    public FakeTimerEntry(FakeTimerRegistry owner, TaskScheduler scheduler, Grain grain, Func<object, Task> asyncCallback, object state, TimeSpan dueTime, TimeSpan period)
    {
        this.scheduler = scheduler;
        this.owner = owner;

        Grain = grain;
        AsyncCallback = asyncCallback;
        State = state;
        DueTime = dueTime;
        DuePeriod = period;
    }

    /// <summary>
    /// Ticks the timer action within the activation context.
    /// </summary>
    public async Task TickAsync() => await await Task.Factory.StartNew(AsyncCallback, State, default, TaskCreationOptions.None, scheduler);

    public void Dispose()
    {
        try
        {
            owner.Remove(this);
        }
        catch (Exception)
        {
            // noop
        }
    }
}

The FakeTimerRegistry overrides the built-in timer registry and enables easier searching for timers from test code.

/// <summary>
/// Implements a fake timer registry to facilitate unit tests using the test cluster.
/// </summary>
public class FakeTimerRegistry : ITimerRegistry
{
    /// <summary>
    /// We dont have a ConcurrentHashSet yet so this does the job.
    /// </summary>
    private readonly ConcurrentDictionary<FakeTimerEntry, FakeTimerEntry> timers = new ConcurrentDictionary<FakeTimerEntry, FakeTimerEntry>();

    /// <summary>
    /// Registers a new fake timer entry and returns it.
    /// Note how we are capturing the activation task scheduler to ensure we can tick the fake timers within the activation context.
    /// </summary>
    public IDisposable RegisterTimer(Grain grain, Func<object, Task> asyncCallback, object state, TimeSpan dueTime, TimeSpan period)
    {
        var timer = new FakeTimerEntry(this, TaskScheduler.Current, grain, asyncCallback, state, dueTime, period);
        timers[timer] = timer;
        return timer;
    }

    /// <summary>
    /// Returns all fake timer entries.
    /// </summary>
    public IEnumerable<FakeTimerEntry> GetAll() => timers.Keys.ToList();

    /// <summary>
    /// Removes a timer.
    /// </summary>
    public void Remove(FakeTimerEntry entry) => timers.TryRemove(entry, out _);
}

Final Notes

To see everything running, go check out the Orleans Unit Testing Sample.

Any questions, let me know in the comments, or just pop over at the Orleans gitter channel.

Jorge Candeias's Picture

About Jorge Candeias

Jorge helps organizations succeed by building high-performing solutions on the Microsoft tech stack.

London, United Kingdom https://jorgecandeias.github.io