Write your first concurrency unit test with Coyote

Modern software systems are inherently concurrent in nature as they perform many different activities at the same time, across different threads, processes and machines. Concurrency is notoriously hard to test, and concurrent bugs can be hard to reproduce and understand. Coyote is a very effective tool in taming this complexity. By giving you the ability to easily test for concurrency bugs, Coyote helps you build more reliable applications and services.

In this tutorial, you will write a simple AccountManager class to create, get and delete account records in a backend NoSQL database. We’ll design our class to be used in a concurrent setting, where methods in multiple instances of the class can be called concurrently, either within the same process or across processes and machines. This latter condition means that using locks will not help you in writing correct concurrent code.

What you will need

To run the code in this tutorial, you will need to:

Watch this tutorial

Optionally, you can watch this tutorial on YouTube:

image

Walkthrough

Without further ado, let’s look at the signature of the AccountManager class:

public class AccountManager
{
  private IDbCollection AccountCollection;

  // Returns true if the account is created, else false.
  public async Task<bool> CreateAccount(string accountName, string accountPayload) { ... }

  // Returns the accountPayload if the account is found, else null.
  public async Task<string> GetAccount(string accountName) { ... }

  // Returns true if the account is deleted, else false.
  public async Task<bool> DeleteAccount(string accountName) { ... }
}

Here are the methods available in the IDbCollection interface:

public interface IDbCollection
{
  Task<bool> CreateRow(string key, string value);

  Task<bool> DoesRowExist(string key);

  Task<string> GetRow(string key);

  Task<bool> DeleteRow(string key);
}

The CreateRow method creates the row with the given key, unless it already exists in which case it returns the RowAlreadyExistsException exception. The DoesRowExist method returns true if the row exists, otherwise it returns false. The GetRow method returns the content of the given key and throws RowNotFoundException exception if it doesn’t exist. Finally, the DeleteRow method deletes the row if it exists and throws RowNotFoundException exception if it doesn’t exist.

Before reading on, please open your editor and attempt to write an implementation of the AccountManager class. You might write something like this:

public class AccountManager
{
  private readonly IDbCollection AccountCollection;

  public AccountManager(IDbCollection dbCollection)
  {
    this.AccountCollection = dbCollection;
  }

  // Returns true if the account is created, else false.
  public async Task<bool> CreateAccount(string accountName, string accountPayload)
  {
    if (await this.AccountCollection.DoesRowExist(accountName))
    {
      return false;
    }

    return await this.AccountCollection.CreateRow(accountName, accountPayload);
  }

  // Returns the accountPayload if the account is found, else null.
  public async Task<string> GetAccount(string accountName)
  {
    if (!await this.AccountCollection.DoesRowExist(accountName))
    {
      return null;
    }

    return await this.AccountCollection.GetRow(accountName);
  }

  // Returns true if the account is deleted, else false.
  public async Task<bool> DeleteAccount(string accountName)
  {
    if (!await this.AccountCollection.DoesRowExist(accountName))
    {
      return false;
    }

    return await this.AccountCollection.DeleteRow(accountName);
  }
}

Does the above implementation look reasonable to you? Can you find any bugs? And how can you convince yourself of the absence of any bugs in the above program?

Let’s write a unit test to test the AccountManager code. In production, IDbCollection is implemented using a distributed NoSQL database. To keep things simple during testing, you can just replace it with a mock. The following code shows such a mock implementation:

public class InMemoryDbCollection : IDbCollection
{
  private readonly ConcurrentDictionary<string, string> Collection;

  public InMemoryDbCollection()
  {
    this.Collection = new ConcurrentDictionary<string, string>();
  }

  public Task<bool> CreateRow(string key, string value)
  {
    return Task.Run(() =>
    {
      bool success = this.Collection.TryAdd(key, value);
      if (!success)
      {
        throw new RowAlreadyExistsException();
      }

      return true;
    });
  }

  public Task<bool> DoesRowExist(string key)
  {
    return Task.Run(() =>
    {
      return this.Collection.ContainsKey(key);
    });
  }

  public Task<string> GetRow(string key)
  {
    return Task.Run(() =>
    {
      bool success = this.Collection.TryGetValue(key, out string value);
      if (!success)
      {
        throw new RowNotFoundException();
      }
      return value;
    });
  }

  public Task<bool> DeleteRow(string key)
  {
    return Task.Run(() =>
    {
      bool success = this.Collection.TryRemove(key, out string _);
      if (!success)
      {
        throw new RowNotFoundException();
      }

      return true;
    });
  }
}

The InMemoryDbCollection mock is very simple, it just maintains an in-memory ConcurrentDictionary to store the keys and values. Each method of the mock runs a new concurrent task (via Task.Run) to make the call execute asynchronously, modeling async I/O in a real database call. You can read later this follow-up tutorial to delve into mock design for concurrency unit testing.

Now that you have written this mock, you can write a simple test:

[Test]
public static async Task TestAccountCreation()
{
  // Initialize the mock in-memory DB and account manager.
  var dbCollection = new InMemoryDbCollection();
  var accountManager = new AccountManager(dbCollection);

  // Create some dummy data.
  string accountName = "MyAccount";
  string accountPayload = "...";

  // Create the account, it should complete successfully and return true.
  var result = await accountManager.CreateAccount(accountName, accountPayload);
  Assert.True(result);

  // Create the same account again. The method should return false this time.
  result = await accountManager.CreateAccount(accountName, accountPayload);
  Assert.False(result);
}

The above unit test clearly tests that the same account cannot be created twice. Try run it (check below for instructions on how to build and run this tutorial from our samples repository) and you will see that it always passes. But is the behavior still true if two requests happen concurrently? How can you test this?

What happens if you spawn two tasks that create the same account concurrently? What if you assert that only one creation succeeds, while the other always fails? That should work because the InMemoryDbCollection uses a ConcurrentDictionary right?

[Test]
public static async Task TestConcurrentAccountCreation()
{
  // Initialize the mock in-memory DB and account manager.
  var dbCollection = new InMemoryDbCollection();
  var accountManager = new AccountManager(dbCollection);

  // Create some dummy data.
  string accountName = "MyAccount";
  string accountPayload = "...";

  // Call CreateAccount twice without awaiting, which makes both methods run
  // asynchronously with each other.
  var task1 = accountManager.CreateAccount(accountName, accountPayload);
  var task2 = accountManager.CreateAccount(accountName, accountPayload);

  // Then wait both requests to complete.
  await Task.WhenAll(task1, task2);

  // Finally, assert that only one of the two requests succeeded and the other
  // failed. Note that we do not know which one of the two succeeded as the
  // requests ran concurrently (this is why we use an exclusive OR).
  Assert.True(task1.Result ^ task2.Result);
}

Try run this concurrent test. The assertion will most likely fail. The reason it is not a guaranteed failure is that there are some task interleavings where it passes, and others where it fails with the following exception:

RowAlreadyExistsException: Exception of type 'RowAlreadyExistsException' was thrown.
...

Let’s dig into why the concurrent test failed.

The test started two asynchronous CreateAccount calls, the first one checked whether the account existed through the DoesRowExist method which returned false. Due to the underlying concurrency, control passed to the second task which made a similar call to DoesRowExist which also returned false. Both tasks then resumed believing that the account does not exist and tried to add the account. One of them succeeded while the other threw an exception, indicating a bug in your AccountManager implementation.

So writing out this test was useful and easily exposed this race condition. But why don’t we write such tests a lot more often? The reason is they are often flaky and find bugs through sheer luck instead of a systematic exploration of the possible interleavings. The above test hits the bug fairly frequently due to the way .NET task scheduling works (on a reasonably fast machine with light CPU load).

Let’s tweak the test very slightly by adding a delay of a millisecond between the two CreateAccount calls:

var task1 = accountManager.CreateAccount(accountName, accountPayload);
await Task.Delay(1); // Artificial delay.
var task2 = accountManager.CreateAccount(accountName, accountPayload);

If you run this test, chances are it will fail very rarely. If you run this test in a loop invoking it a hundred times it probably won’t fail once.

The race condition is still there but our concurrency unit test suddenly became ineffective at catching it. This explains why developers don’t write such tests as they are very sensitive to timing issues. Instead, developers often write stress tests, where the system is bombarded with thousands of concurrent requests in the hopes that some rare interleaving would expose these kind of nondeterministic bugs (known as Heizenbugs) before the code is deployed in production. But stress testing can be complex to setup and it doesn’t always find the most tricky bugs. Even if it does find a bug, it usually produces such long traces (or logs) that understanding the bug and fixing it becomes a very time consuming and frustrating task.

Flakey tests is clearly not a satisfactory situation. What we need is a tool which can systematically explore the various task interleavings in test mode as opposed to leaving that to luck (i.e. the operating system scheduler). Coyote gives you exactly this.

To use Coyote on your task-based program is very easy in most cases. All you need to do is to invoke the coyote tool to rewrite your assembly (for testing only) so that Coyote can inject logic that allows it to take control of the schedule of C# tasks. Then, you can invoke the coyote test tool which systematically explores task interleavings to uncover bug. What is even better is that if a bug is uncovered, Coyote allows you to deterministically reproduce it every single time.

Now run your test under the control of Coyote. First use Coyote to rewrite the assembly:

coyote rewrite .\AccountManager.dll
. Rewriting AccountManager.dll
... Rewriting the 'AccountManager.dll' assembly
... Writing the modified 'AccountManager.dll' assembly to AccountManager.dll
. Done rewriting in 0.6425808 sec

Note: if your project contains multiple assemblies (which is usually the normal), then you need to rewrite all of them. This can be easily done by passing a JSON configuration file to coyote rewrite as discussed here.

Awesome, now lets try use Coyote on the above concurrent test:

coyote test .\AccountManager.dll -m TestConcurrentAccountCreation -i 100

Note: for this to work the unit test method needs to use the [Microsoft.Coyote.SystematicTesting.Test] custom attribute to declare the test method.

The above command tells Coyote to execute the test method TestConcurrentAccountCreation for 100 iterations. Each iteration will try explore different interleavings to try unearth the bug. You can read more about other Coyote tool options here.

Indeed after 20 iterations and 0.15 seconds Coyote finds a bug:

. Testing .\AccountManager.dll
... Method TestConcurrentAccountCreation
... Started the testing task scheduler (process:17368).
... Created '1' testing task (process:17368).
... Task 0 is using 'random' strategy (seed:1046544966).
..... Iteration #1
..... Iteration #2
..... Iteration #3
..... Iteration #4
..... Iteration #5
..... Iteration #6
..... Iteration #7
..... Iteration #8
..... Iteration #9
..... Iteration #10
..... Iteration #20
... Task 0 found a bug.
... Emitting task 0 traces:
..... Writing AccountManager.dll\CoyoteOutput\AccountManager_0_0.txt
..... Writing AccountManager.dll\CoyoteOutput\AccountManager_0_0.trace
... Elapsed 0.0743756 sec.
... Testing statistics:
..... Found 1 bug.
... Exploration statistics:
..... Explored 26 schedules: 26 fair and 0 unfair.
..... Found 3.85% buggy schedules.
..... Number of scheduling decisions in fair terminating schedules: 17 (min), 23 (avg), 31 (max).
... Elapsed 0.1574494 sec.

Cool, the flakey test is no longer flakey! Coyote can also help you reproduce and debug it. You can simply run coyote replay giving the .trace file that Coyote outputs upon finding a bug:

coyote replay .\AccountManager.dll AccountManager_0_0.trace
    -m TestConcurrentAccountCreation
. Reproducing trace in .\Output\AccountManager.dll\CoyoteOutput\AccountManager_0_1.trace
... Task 0 is using 'replay' strategy.
... Reproduced 1 bug (use --break to attach the debugger).
... Elapsed 0.0671654 sec.

Nice, the bug was reproduced. You can use the --break option to attach the VS debugger and happily debug the deterministic trace to figure out what is causing the bug and take as long as you want, stepping through the code in the debug, and that will not change any timing conditions, the same bug will still happen. You can repeat this as many times as you want!

In this tutorial, you saw that you were able to use Coyote to reliably reproduce the race condition in AccountManager. You did this with a tiny test (just two CreateAccount calls racing with each other), as opposed to overloading the system with thousands of concurrent tasks through stress testing.

This of course was a simple example, but it’s easy to imagine how Coyote can find many non-trivial concurrency bugs in a much more complex codebase. Such bugs have very low probability of being caught during test time if you don’t use a tool like Coyote. In the absence of such tools, these bugs can go undetected and occur sporadically in production, making them difficult to diagnose and debug. No more late nights debugging a live site!

In the next tutorial, you will write a few more concurrency unit tests for the AccountManager to increase our familiarity with Coyote.

Get the sample source code

To get the complete source code for the AccountManager tutorial, clone the Coyote git repo. Note that the repo also contains the code from the next tutorial which builds upon this AccountManager sample.

You can build the sample by following the instructions here.

You can now run the tests (without Coyote) like this:

cd .\Samples\bin\net8.0
.\AccountManager.exe

This version has some command line arguments to make it easy select which test to run:

Usage: AccountManager [option]
Options:
  -s    Run sequential test without Coyote
  -c    Run concurrent test without Coyote

To rewrite and test the sample with Coyote you can use the following commands (as discussed above):

coyote rewrite .\AccountManager.dll
coyote test .\AccountManager.dll -m TestConcurrentAccountCreation -i 100

If you find a bug you can replay with the following command:

coyote replay .\AccountManager.dll AccountManager_0_0.trace
    -m TestConcurrentAccountCreation

Enjoy!