Xunit and exceptions with async Task

Recently, I wrote Xunit tests for business object that requires to catch exceptions generated by wrong property values in synchronous and asynchronous calls. This post includes several examples and full code is accessible on GitHub Blog repository. Here I will use approach described in Richard Banks’ post Stop Using Assert.Throws in Your BDD Unit Tests that gives me right direction.

Let’s describe objects that will be used for demonstration. The Data class describes the simple object with one property that throws an exception on negative values:

public class Data
{
  private readonly object _lockObject = new object();

  private int _state;

  public int State
  {
    get { return _state; }
    set
    {
      lock (_lockObject)
      {
        if (value < 0)
          throw new ArgumentOutOfRangeException(
            nameof(State),
            "State should be positive");
        _state = value;
        // some inner changes
      }
    }
  }
}

Let’s write simple test that assigns positive values and doesn’t throw an exception:

[Theory]
[InlineData(0)]
[InlineData(1)]
public void Data_ShouldAccept_NonNegativeValue(int state)
{
  Data data = null;
  var exception = Record.Exception(() =>
  {
    data = new Data();
    data.State = state;
  });

  data.Should().NotBeNull();
  exception.Should().BeNull();
}

All tests are executed successfully, and exception is not thrown.

Now let’s consider the test that assigns negative state and throws an exception:

[Theory]
[InlineData(-1)]
public void Data_ShouldThrow_ExceptionOnNegativeValueAndReturnNullObject(int state)
{
  Data data = null;
  Action task = () =>
    {
      data = new Data
      {
        State = state
      };
    };

  var exception = Record.Exception(task);
  data.Should().BeNull();
  exception.Should().NotBeNull();
  exception.Message.Should().Be(ExceptionMessage);
}

As Data class is designed to be thread-safe, so we need tests that accesses Data.State asynchronously. Note that used method Record.ExceptionAsync returns value of type Task and marked as can be null. That is why returned result is checked against null value and then we check for the inner exception:

[Fact]
public void Data_ShouldNotThrow_ExceptionOnNonNegativeValueInAsync()
{
  var data = new Data();
  var task = Task.Run(() =>
        {
          for (var pos = 5; pos >= 0; pos--)
          {
            data.State = pos;
          }
        });

  var taskException = Record.ExceptionAsync(async () => await task);
  data.Should().NotBeNull();
  data.State.Should().Be(0);
  taskException.Should().NotBeNull();
  taskException.Result.Should().BeNull();
}

Further, the next test correctly catches the generated exception:

[Fact]
public void Data_ShouldThrow_ExceptionOnNegativeValueInAsync()
{
  var data = new Data();
  var task = Task.Run(() =>
        {
          for (var pos = 1; pos >= -2; pos--)
          {
            data.State = pos;
          }
        });

  var exception = Record.ExceptionAsync(async () => await task);
  
  data.Should().NotBeNull();
  data.State.Should().Be(0);
  exception.Should().NotBeNull();
  exception.Result.Should().NotBeNull();
  exception.Result.Message.Should().Be(ExceptionMessage);
}

And the similar test could be written with two asynchronous tasks:

[Fact]
public void Data_ShouldThrow_ExceptionOnNegativeStateInTwoAsyncTasks()
{
  var data = new Data();
  var tasks = new Task[]
    {
      Task.Run(() =>
        {
          for (var pos = 0; pos < 10; pos++)
          {
            data.State += 1;
          }
        }), 
      Task.Run(() =>
        {
          for (var pos = 0; pos < 20; pos++)
          {
            data.State -= 1;
          }
        }), 
    };

  var exception = Record.ExceptionAsync(async () =>
        await Task.WhenAll(tasks));
  data.Should().NotBeNull();
  exception.Should().NotBeNull();
  exception.Result.Should().NotBeNull();
  exception.Result.Message.Should().Be(ExceptionMessage);
}

1. All used IP-addresses, names of servers, workstations, domains, are fictional and are used exclusively as a demonstration only.
2. Information is provided «AS IS».

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.