TDD + async: Introducing MemoryChannel

Spread the love

Can you use test-driven development and unit testing with asynchronous code? Yes, with a few caveats. Above all, unit tests should be deterministic and fast. This means you should stick to single-threaded workflows as much as possible and never sleep.

To demonstrate these principles along with typical .NET 4.5 asynchronous patterns, I came up with a useful and complete example — MemoryChannel. MemoryChannel is a simple one-way communication channel which allows a sender to pass raw byte buffers to an asynchronous receiver. With some appropriate adapters, it could replace a named pipe or TCP socket (thus helping to eliminating external dependencies, for example, in a set of unit tests).

Before writing the code, I sketched a crude FSM diagram on a piece of paper. Here is a much more refined version of that diagram (courtesy of PowerPoint, my diagramming tool of choice):

MemoryChannel-fsm

There are only four states (no data, data available, receive pending, and disposed) and three operations (send, receive, dispose). The only real complications here are the conditions relating to the sent and received data sizes. For example, if there is some data in the channel and a receiver requests more data than is available, the request should still complete but notify the receiver that only S bytes were transferred. However, if less data is requested (R), the excess (S - R) should remain available for later receive attempts.

In true TDD style, I started with the minimum possible interface and decided to focus on the receive side of the state machine.

public class MemoryChannel
{
    public MemoryChannel()
    {
    }

    public Task ReceiveAsync(byte[] buffer)
    {
        throw new NotImplementedException();
    }

    public void Send(byte[] buffer)
    {
        throw new NotImplementedException();
    }
}

Here is the first unit test, covering just the highlighted part of the state machine:
MemoryChannel-fsm-recv

public void Pending_receive_completes_after_send_with_same_data_size()
{
    MemoryChannel channel = new MemoryChannel();

    byte[] receiveBuffer = new byte[3];
    Task<int> receiveTask = channel.ReceiveAsync(receiveBuffer);

    Assert.False(receiveTask.IsCompleted);
    Assert.False(receiveTask.IsFaulted);

    byte[] sendBuffer = new byte[] { 1, 2, 3 };
    channel.Send(sendBuffer);

    Assert.Equal(TaskStatus.RanToCompletion, receiveTask.Status);
    Assert.Equal(3, receiveTask.Result);
    Assert.Equal(new byte[] { 1, 2, 3 }, receiveBuffer);
}

To fulfill the conditions of this test, we need to return a Task<int> from ReceiveAsync which has not yet completed and only does so when a matching Send call is made. Additionally, to satisfy the unit testing principles above we need to do this without external threads. How can we do this?

Enter TaskCompletionSource — one of the most useful library classes in the async .NET toolbox. Essentially, TCS lets us hand out a Task representing a custom asynchronous operation while giving us the ability to explicitly control the lifetime of this operation.

To make the test above pass, we can use TCS to represent the pending receive operation like so, initializing it on start of receive and completing it (via SetResult) when Send is invoked:

public class MemoryChannel
{
    private TaskCompletionSource<int> pendingReceive;
    private byte[] pendingReceiveBuffer;

    public MemoryChannel()
    { 
    }

    public Task<int> ReceiveAsync(byte[] buffer)
    {
        this.pendingReceive = new TaskCompletionSource<int>();
        this.pendingReceiveBuffer = buffer;
        return this.pendingReceive.Task;
    }
 
    public void Send(byte[] buffer)
    {
        buffer.CopyTo(this.pendingReceiveBuffer, 0);
        this.pendingReceive.SetResult(buffer.Length);
    }
}

Note that SetResult will transition the task to a completed state as well run any synchronous continuations. This can be undesirable in some situations which I will get into in a later post.

You can track the evolution of MemoryChannel into its full implementation in my CommSample project. The commit history shows the changes following every new unit test or refactoring.

9 thoughts on “TDD + async: Introducing MemoryChannel

  1. ranyao

    Is the minimum possible interface designed so that the ReceiveAsync task will completed only after data being send to the channel? Why not design a SendAsync task completed only after the data being read from a full channel?

    1. Brian Rogers Post author

      Yes, receive will complete only when data is available (or when the channel is disposed). The class as designed does not put a limit on the size of the buffer. If that feature were added then I would either make the send operation async as you suggest or throw a “buffer full” exception.

  2. ranyao

    Also, another question related to TDD. I’m a bit unclear how to choose test cases. There could be arbitrary number of test cases. Here for example, simply considering two APIs from a class: send and receive. However, combined with state and order, it could be:
    1. send x, receive y (x y)
    2. receive x, send y
    3. receive x, receive y, send z
    4. receive x, send y, send z (which does not seem not included in the unit test here but hit a bug in my implementation)

    Is there any guideline to balance the overwhelming test cases and effectiveness of TDD? Thanks!

    1. ranyao

      I mean considering two and three APIs of a class here along with the possible state transition introduced by the input parameter, could TDD give a false sense of confidence because only a small fraction of test cases are covered compared with all possibilities?

      1. Brian Rogers Post author

        If you’re doing TDD to the fullest, each unit of behavior will have one and only one specification (test). This is how you choose test cases. In the real world, this is hard to achieve because humans are not robots, and due to the effects of things like “emergent behavior” — subtle new (and perhaps buggy) behaviors arising from changes/updates to existing ones. That being said, TDD is a good defense against this growing too far out of control.

        TDD is typically pitched by practitioners as a design activity; its emphasis on small testable units will naturally promote simpler and clearer designs. Due to this, you can often prevent larger issues from happening because of the (good) constraints TDD places on you. For example, you are unlikely to litter your codebase with a lot of external file system calls if you insist on having fast “context-free” code that doesn’t require a bunch of expensive setup. This means that you will think harder about these dependencies and how they should be represented in your system, perhaps coming up with a clear, focused domain-specific abstraction. All of this leads to fewer issues at the core and typically fewer integration problems as well given the relatively few ways such small components can interact.

        The only false sense of confidence is one borne out of unreasonable expectations. You should not expect TDD to be the sole testing activity of a real-world project. You need some level of system testing, performance/scalability testing, etc. Small portions of this can be done at the unit level. But typically you need to wire up the real thing at least once in a while to help prove your assumptions about how things in your system “should” work — and then maybe update your unit tests after your assumptions are challenged and changed.

  3. Pingback: Async holes: ZipArchive – WriteAsync .NET

  4. Pingback: Async recipes: after first (matching) task, cancel rest – WriteAsync .NET

Leave a Reply to Brian Rogers Cancel reply

Your email address will not be published. Required fields are marked *