Last time, we looked at a decorator class that adds retry functionality (More DI: Adding Retry with the Decorator Pattern). This time, we'll write some unit tests to make sure the class behaves as expected.
There are a few quirks when we're testing asynchronous methods.This article is one of a series about dependency injection. The articles can be found here: More DI and the code is available on GitHub: https://github.com/jeremybytes/di-decorators.
The Unit Under Test
We're testing the retry functionality in our decorator. This is in the "PersonReader.Decorators" project, RetryReader.cs file, specifically the "GetPeople" method.
This method should try to call a method (up to) 3 times. If the call is successful, it returns the results. If the call throws an exception, then it waits 3 seconds and tries again. For details on this method, see the previous article.
This class wraps another data reader (such as a ServiceReader) that provides the actual data. In our tests, we will create a fake object to handle this functionality.
For testing, we want to test 3 scenarios. These are the same 3 scenarios that we checked by manually running the application in the previous article. (1) Success on the first try; (2) Failure on the first try with success on a subsequent try; (3) Failure after exhausting the retry attempts.
Let's head over to the tests.
Unit Testing the Retry Reader
We already have a test project set up: PeopleReader.Decorators.Tests. The completed tests are in the RetryReaderTests.cs file. Let's walk through creating the first test.
The first test will ensure that the Retry Reader returns data when there are no problems with the wrapped data reader -- the "happy path". Here's a start to that test:
First, about the name: I use a 3-part naming system. The first part is the unit under test ("GetPeople"). The second part is the state that we're testing (that the wrapped reader is "NotBroken"). The third part is the expected outcome (that the method "ReturnsPeople" successfully).
We start by creating an instance of the RetryReader class that we're testing. The constructor needs a parameter which is an IPersonReader (the "real" data reader that we're wrapping). Since we don't want to use a real data reader, we'll create a fake one.
A Fake Reader
The fake reader will need to implement the "IPersonReader" interface (from the "Common" project, IPersonReader.cs file).
The interface has 2 methods that both return Task, so we're dealing with asynchronous methods here.
For the fake reader, we want to be able to control whether it returns successfully or throws an exception. So we'll create a class that has a "brokenCount" value. This value determines how many times the method should fail before it succeeds.
Here's the code for the BrokenReader (in the "PersonReader.Decorator.Tests" project, BrokenReader.cs file):
The first thing to note about this class is that it implements the "IPersonReader" interface, so it has the 2 members "GetPeople" and "GetPerson".
At the top of the class, we have some fields to help us. The "brokenCount" holds the number of times the class should fail before it returns successfully. If the "brokenCount" is 0, then all calls will be successful. If the "brokenCount" is 1, then the first call will fail, but the second call will succeed. This value is set by a constructor parameter.
The "retryCount" is used internally to keep track of how many calls have been made so far.
The "testPeople" field is our test data that is returned when the call is successful.
The "GetPeople" method returns a Task<IEnumerable<Person>>. Before we look at the final code, let's walk through a few things we might try.
First, we might try to write the method as if we didn't have to worry about Task:
The "if" block checks to see if we've hit the threshold. If the retry count is less than the broken count, the we want the call to fail by throwing an exception. The retry count is also incremented so the value will be different with the next call.
For the "return" of this method, we try to return "testPeople". But this fails because "testPeople" is a List<Person>. List<Person> implements IEnumerable<Person>, so those types are compatible, but we really need a Task<IEnumerable<Person>>.
There are a couple of approaches. One option is to wrap the "testPeople" in a Task:
This option works, but I'm not a big fan of the syntax. It's a bit difficult to read, particularly for people who aren't well-versed in using Task directly.
The next option feels like a bit of a cheat, but I like it quite a bit better:
In this code, we "await Task.Delay(1)". This will pause operation for 1 millisecond. But the more important thing is that when we "await" something in a method, the whole method becomes asynchronous (we also need to mark the method with the "async" modifier).
When we changed the interface to async methods (More DI: Async Interfaces), we saw that when we "await" something in a method, the return value is automatically wrapped in a Task for us.
In this method, that means we can "return testPeople", and it will automatically be wrapped in a Task.
Using "await Task.Delay(1)" is not something I would use in production code -- it is an artificial delay after all. But for unit tests, I'm willing to take the slight performance hit so that the tests are more easily approachable.
As Graham King points out in the comments, another option is to use Task.FromResult() to create a completed task with the desired result. This is a good option (that I completely forgot about), so let's take a look.
Here's the "GetPeople" method using Task.FromResult:
The idea is that "Task.FromResult" will create a completed Task that has "testPeople" as the result. Unfortunately, the compiler is not happy here. The error tells us that we have a type mismatch between the expected result (IEnumerable<Person>) and the actual result (List<Person>, which is the "testPeople" type). Even though List<T> implements IEnumerable<T> and "testPeople" is an IEnumerable<Person>, Task needs us to be more specific.
To fix the code, we can add a generic type to the "FromResult" call:
Or we can add "AsEnumerable" to the "testPeople" parameter:
But probably the best option is to change the type of the "testPeople" field from "List<Person>" to "IEnumerable<Person>":
Then we don't have to worry about the type coercion.
This feels a lot better, and this is what you'll see in the final code for this class (BrokenReader.cs file). Thanks, Graham!
Using the Fake Broken Reader
Now, we'll flip back to our tests and create an instance of the "BrokenReader" and pass it into the RetryReader constructor (in the RetryReaderTests.cs file).
After creating the RetryReader instance, we need to call the "GetPeople" method. It's really tempting to use the code here because this is the way I normally code up the "action" part of the unit test.
The danger is that it's easy to assume that "result" is the Person data. But if we inspect the variable type, we see that it is actually a Task:
A clue to how we should actually write the code is in the "Usage" section of the popup. Instead of simply calling the "GetPeople" method, we should "await" it. This will give us the "IEnumerable<Person>" that we can use for the assertion.
Here's the updated test (which is the final code in the RetryReaderTests.cs file):
Update 1/15/2019: A "retryDelay" parameter has been added to the RetryReader constructor. This lets us control the delay between retries. The updated code will be shown below.
As noted above, we now "await" the GetPeople method. Because we are using "await", we must also mark the test method with the "async" modifier.
Another thing that we need to change is the return type of the test. Previously, it was "void", but when we return "void", we lose all visibility to the Task, including any exceptions that might be thrown. So we need to change the return type to "Task". The good news is that if we forget to do this, the testing framework will remind us when we try to run the test.
In the assertion, we are checking to see that the "result" coming back from the GetPeople method is not null. We could also do a more specific check for the 2 items in the data, but we're actually more concerned that this does not throw an exception.
The last thing I did was change the name of the test to have "Broken0" in the name. I'm not sure I like this name, but it goes along with the next 2 tests. I'm thinking about changing the names of all of the tests, so they may be a little different in the final code.
The next test will see if the retry functionality works. We want the fake reader to fail on the first call, but succeed on the second one. Here's what that tests looks like (from the RetryReaderTests.cs file):
This test is almost identical to the previous test. The difference is that the "BrokenReader" class is passed value of 1, meaning we want the first call to fail, but the second call to succeed.
The rest of the test is the same. We expect that we will get data back (eventually) and that we will not get an exception.
The caveat is that this test is slooow (and that's why I gave it a category). This test takes at least 3 seconds to complete since the RetryReader waits 3 seconds before retrying the method call.
It's not great, but it's an accurate test of the functionality. The category attribute gives us a chance to filter these tests in the test runner so that we're not constantly running them with the rest of the test suite.
Update 1/15/2019: A "retryDelay" parameter has been added to the RetryReader. This lets us set the value to "0" for unit testing, so we get immediate retries, and the tests no longer run slowly. Here is the updated code for this test (similar updates were made to the other tests and are available on GitHub.)
This test sets the retry delay to "0", so this is no longer a slow test.
Testing for Failure
The last test is to see what happens when all of the retries fail. In this case, we expect that the original exception from the wrapped data reader will be thrown.
This test looks a bit different from the other 2 (this is in the same file):
This time, we give the "BrokenReader" a value of 3. So it should fail on 3 calls before returning successfully. Since our RetryReader only tries 3 times, we expect that the overall call will fail.
The "try/catch" block is set up to ensure that we get the expected exception when the "GetPeople" method is called. For more information on this pattern (and a couple others), take a look at Testing for Exceptions with NUnit. I opted to go with the option that is not test framework specific.
In the "try" block, if there is no exception thrown, then we hit the "Assert.Fail" call. This will fail the test.
If an exception is thrown, then we hit the "catch" block. The "Assert.Pass" is not strictly necessary; if we leave the catch block empty, this test will still pass. But I like to add the "Pass" to make it more clear that an exception is what we expect to happen here.
This is also a sloooooow test (it takes at least 6 seconds). So we've marked it with the "Slow" category as well.
Update 1/15/2019: A "retryDelay" parameter has been added to the RetryReader. This value can be set to "0" in the unit tests so that they are no longer slow.
All of these tests pass:
This also shows the timings, and we can see that the slow tests do take 3 seconds and 6 seconds, respectively.
Update 1/15/2019: With the "retryDelay" values specified above, the tests run much more quickly:
Async Unit Tests
Overall, having async methods in unit tests is not that much different from having async methods in our production code. We can use async/await with our test frameworks (just remember to return "Task" instead of "void" on the test methods).
When we fake up data, we often don't need to make async calls to a data store. As a cheat that keeps the code readable, we can "await Task.Delay(1)" in our fake objects. This will make the method asynchronous and automatically wrap the return value in a Task.
There's a lot more to explore with this project, including the other decorators, configuration, and how .NET Standard and .NET Framework projects interact. So stay tuned for more.