Easy way to start TDD
Kazys
Posted on January 27, 2024
Hi, my name is Kazys Račkauskas, and this is my first blog post. I'm a back-end .NET developer specializing in C#, and I'm passionate about TDD and other testing approaches. Lately, I've been focusing heavily on E2E testing at work.
While reading numerous blog posts about TDD and testing in general, I've noticed various perspectives on the subject. These range from having no tests at all to employing a multitude of test-writing approaches. This not only includes different scopes like integration, acceptance, E2E, and unit tests, but also various styles within unit testing itself, including TDD. I won't judge any of these methods, as they are likely all perfectly valid depending on factors such as coding style, project scope, utilized technologies, personal preferences, and individual personalities. Instead, I will share my approach, which works well for me, aligns with my thought process, and is effective in most cases I encounter.
Why EasyTDD? When I first started with TDD, it was slow for a couple of reasons. Firstly, the learning curve wasn't very steep, so I had to internalize the process and develop my skills. Secondly, in many cases, the test code was larger than the production code, which felt a bit awkward. As a result, I began looking for ways to make the TDD process less time-consuming and more straightforward.
In this blog post, I will discuss why I chose the London-style TDD, the test structure, and the testing flow. Additionally, I will introduce my pet project - EasyTDD. EasyTDD is a tool designed to speed up the process of writing tests by generating test templates and other useful code snippets, making the TDD process faster. This Visual Studio Extension is tailored for C# users and can be downloaded via the Extension Manager or directly from the EasyTDD - Visual Studio Marketplace. Its primary focus is to enhance the TDD experience within the Visual Studio environment.
Some backing of style I use
There are two primary schools of TDD - Detroit (also known as Chicago, Classicists, inside-out, or black-box testing) and London (also known as Mockists, outside-in, or white-box testing). This seems like a classic rivalry, similar to Windows vs. Linux vs. Mac, iPhone vs. Android, functional programming vs. object-oriented programming, or perhaps even Tesla vs. other electric cars, among others. I won't delve deeply into the pros and cons here. For a comprehensive review of both approaches, consider the following recommended readings:
Detroit and London Schools of Test-Driven Development
Test Driven Development Wars: Detroit vs London, Classicist vs Mockist
Alright, so in support of the London style, I work for a company that develops a product for the travel industry. When I started 10 years ago, it already had a significant amount of legacy code, and now the code I wrote a decade ago is considered legacy as well. To give you an idea, it's a massive solution, now comprising almost 100 projects, with numerous integrations, multiple stages of processing, rules, and so on. When I started, there were hardly any tests, and the ones that existed were incredibly slow. The functionality of the workflow depends on various factors - outputs from external sources, configurations, rules, etc. Setting up a test required a tremendous amount of work. We had an environment that was meant to simulate production but was painfully slow. That's where mocks came into play - it was like landing in the middle of a code jungle - determining where to place the new functionality, identifying dependencies, mocking those dependencies, writing tests, implementing, connecting to the jungle, and then leaving. Naturally, like a Boy Scout, I want to clean up the surroundings a bit, make it neater and better, and cover it with tests. However, I can't clean up the entire jungle; I have to stop somewhere. And where I stop, I find dependencies and mock them. That's it.
The text concerns legacy code. What about the new code? Yes, I apply the London style to the new code as well. Here are the main points I use to justify it for myself:
Outside-in, or top-down design process: This is just how my thinking works, starting with the big picture and gradually delving into the details. It helps me discover what is needed. There is a London-style branch called Discovery Testing. I'm not sure if it's an official name or how widely it's used, but reading this wiki aligns with my style of thinking: Discovery Testing · testdouble/contributing-tests Wiki · GitHub.
Deferring the decision: I don't want to delve too deeply into one aspect of the problem until I have a broader understanding of the overall picture. This approach is somewhat similar to Breadth-First Search for me.
-
SOLID object-oriented design (OOD) principles.
- Dependency inversion: This principle states that one should depend on abstractions. I trust that the abstraction will do its job, while concrete implementations are tested elsewhere.
- Liskov substitution principle: This principle states that a function or class must be able to use objects of derived classes without knowing it. A mock is a derived class, so I can use it, and the subject under test shouldn't know that.
- Single responsibility and interface segregation: Services and objects become quite small, making them easy to test. They will perform one function and have no side effects.
Are unit tests enough?
The short answer is that it depends. In some cases, unit tests are trivial and not necessary. In other instances, incorrect assumptions may be made about dependencies and their respective responsibilities, leading to malfunctions when the functionality is integrated. This is particularly true when multiple developers are working to implement the functionality. As a result, I prefer to cover the functionality with some level of integration testing.
The process of doing TDD
For those who are unfamiliar with it, TDD follows a simple mantra: red -> green -> refactor.
Red - Write a test for a piece of functionality you want to implement. The test should fail because no code covering the functionality has been written yet. If the test is green, something is wrong with the test.
Green - Write production code to cover the test case. Run the test; if it is green, proceed to the next step. If it is red, something is not right yet, either in the production code or in the test itself. Always consider that the test you wrote might contain bugs as well. If you don't see anything suspicious in the code, return to the test code and review it.
Refactor - Restructure the code to make it cleaner and more efficient. This also applies to the test code, as there might be duplicate logic in the tests, or some elements could be moved to helper methods or restructured. Treat test code as first-class citizens. Run the tests - they should still be green. If not, something went wrong and needs to be fixed. Once it is green again, start from the beginning.
How to start?
Does the test always come first? Based on the flow of actions described above, the test should come first, followed by the code. However, this can sometimes feel awkward to me. In most cases, I start by creating a class signature. Remember the top-to-bottom approach: the class signature has likely already been revealed in the form of an interface if it's not a top-level class.
So, I need a starting point. I need a name, whether it's for the test or the class. Sometimes, I feel stuck at this stage. It's a common saying that naming is one of the most challenging tasks for developers. If I can't think of anything relevant, it's okay for me to start with a class called SomeService
and a method called DoSomething
.
Let's say we have a classic calculator example: some input is provided, and the service needs to return the calculated result. To make it more challenging, let's assume that the input is a string, like "2+3"
, "3"
, "2+5.5+4-22"
, etc. However, for the sake of simplicity, let's say that it only accepts "+"
and "-"
as operations.
Test template
In my day-to-day work, I use FluentAssertions for assertions, so I incorporate them into my examples as well.
The pure TDD approach requires writing the test first, followed by the code. In this method, a class signature is created only after the tests demonstrate a need for it. However, I don't adhere to this rule. Instead, I create an empty class first, like this:
public class Calculator
{
public double Calculate(string expression)
{
throw new NotImplementedException();
}
}
Let's use EasyTdd to generate the test. Click on the "Quick action" icon or press Ctrl+. to access the quick action menu. Choose "Generate Test."
The tool will generate a test class and open it in Visual Studio. It will look something like this:
public class CalculatorTests
{
private string _expression;
[SetUp]
public void Setup()
{
_expression = string.Empty;
}
[TestCase(null)]
public void CalculateThrowsArgumentExceptionWhenExpressionIsNotValid()
{
_expression = expression;
Action action = () => CallCalculate();
action
.Should()
.Throw<ArgumentException>();
}
private double CallCalculate()
{
var sut = Create();
return sut
.Calculate(
_expression
);
}
private Calculator Create()
{
return new Calculator();
}
}
Now, I have something to work with. Let's discuss what we have here:
_expression
- I create a field for each input of the method I test.Setup()
- Here, I initialize input values, mocks, etc., with values typically used to pass the longest happy path.Create()
- This method creates a subject under the test. It is useful because I don't need to initialize the SUT with all dependencies in all tests.CallCalculate()
- This is responsible for calling the method in testing. It creates the SUT, provides all input parameters to the method in testing, and returns the value. Since each test has to do the same, I like to have it in one place.ThrowsExceptionWhenExpressionIsInvalid
- I always start with a test that establishes boundaries for the SUT (system under test). In many cases, provided values will never be used in the live system, but it is helpful for me while designing the class when I have clear boundaries, and it also serves as documentation.
Good to go, now I have runnable code. I can run the test, debug if needed, and see instant results on how it behaves.
At this point, there is no clear distinction between arrange-act-assert, but I will explain the reasoning later in a more complex example.
At this point, I have a failing test. Let's make it pass.
public class Calculator
{
public double Calculate(string expression)
{
if (expression == null)
{
throw new ArgumentNullException(nameof(expression));
}
throw new NotImplementedException();
}
}
The expression
not being null is not the only boundary I want to establish. To make it more readable, I will add more TestCase
attributes to the current test to cover all out-of-boundary cases. Most likely, I won't think of all the cases, so I will add new ones during the development process. I'll start with null, empty, and whitespace strings:
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void ThrowsExceptionWhenExpressionIsNull(string expression)
{
_expression = expression;
Action action = () => CallCalculate();
action
.Should()
.Throw<ArgumentException>();
}
Here is the implementation:
public double Calculate(string expression)
{
if (string.IsNullOrWhiteSpace(expression))
{
throw new ArgumentNullException(nameof(expression));
}
throw new NotImplementedException();
}
Now, I will add a test for single-number calculation:
[TestCase("3", 3)]
[TestCase("3.5", 3.5)]
public void ReturnsExpectedCalculatedExpression(
string expression,
double expectedResult)
{
_expression = expression;
var result = CallCalculate();
result
.Should()
.Be(expectedResult);
}
And the implementation:
public double Calculate(string expression)
{
if (string.IsNullOrWhiteSpace(expression))
{
throw new ArgumentNullException(nameof(expression));
}
return double.Parse(expression, CultureInfo.InvariantCulture);
}
Step by step, I add cases for expected and boundary tests, implementing them in the same incremental manner. For brevity, I won't provide all the implementation steps here. Step-by-step implementations can be found at easyTdd/EasyWayToStartTDD (github.com). The final test class code appears as follows:
public class CalculatorTests
{
private string _expression;
[SetUp]
public void Setup()
{
_expression = string.Empty;
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
[TestCase("a")]
[TestCase("aabb")]
[TestCase("=")]
[TestCase("+")]
[TestCase("-")]
[TestCase("35.21a")]
[TestCase("a123")]
public void ThrowsExceptionWhenExpressionIsInvalid(string expression)
{
_expression = expression;
Action action = () => CallCalculate();
action
.Should()
.Throw<ArgumentException>();
}
[TestCase("3", 3)]
[TestCase("3.5", 3.5)]
[TestCase("2+2", 4)]
[TestCase("2.5+2", 4.5)]
[TestCase("2.5+2.8", 5.3)]
[TestCase("2.5-2", 0.5)]
[TestCase("2.5-2.8", -0.3)]
[TestCase("10+20-5.5+8-3+2.8", 32.3)]
public void ReturnsExpectedCalculatedExpression(
string expression,
double expectedResult)
{
_expression = expression;
var result = CallCalculate();
result
.Should()
.BeApproximately(expectedResult, 0.001);
}
private double CallCalculate()
{
var sut = Create();
return sut
.Calculate(
_expression
);
}
private Calculator Create()
{
return new Calculator();
}
}
The calculator code appears as follows:
public class Calculator
{
public double Calculate(string expression)
{
if (string.IsNullOrWhiteSpace(expression))
{
throw new ArgumentNullException(nameof(expression));
}
var regex = new Regex(@"^\d+(\.\d+)?((\+|\-)\d+(\.\d+)?)*$");
if (!regex.IsMatch(expression))
{
throw new ArgumentException(
$"'{expression}' is not valid expression."
);
}
var tokens = Regex.Matches(expression, @"(\d+(\.\d+)?)|(\+|\-)");
if (tokens.Count == 1)
{
return ParseDouble(tokens[0].Value);
}
var result = ParseDouble(tokens[0].Value);
for (var i = 1; i < tokens.Count - 1; i += 2)
{
if (tokens[i].Value == "+")
{
result += ParseDouble(tokens[i + 1].Value);
}
else
{
result -= ParseDouble(tokens[i + 1].Value);
}
}
return result;
}
private double ParseDouble(string value)
{
return double.Parse(
tokens[i + 1].Value,
CultureInfo.InvariantCulture
);
}
}
More advanced example with test doubles
Now, I will present a more complex example to introduce other concepts I use while practicing TDD. Please note, that this example is intended for demonstration purposes and may not comprehensively cover business requirements or the optimal implementation strategy.
Let's assume that the task is to implement payment callback functionality. When a payment is completed, an external payment provider calls our endpoint to register the payment. The service has the following requirements:
The payment needs to be registered for the related invoice.
The event "Paid" should be published when the invoice is fully paid.
The event "PartiallyPaid" should be published when the paid amount is less than the invoice's total amount. It is possible that this is not the first payment, so the total paid should be compared to the invoice's total amount.
The event "UnexpectedPayment" should be published when the invoice becomes overpaid or when no invoice exists for the provided invoice number.
In this example, I assume that a message bus abstraction, IBus, already exists.
public interface IBus
{
Task PublishAsync<TMessage>(TMessage message);
}
Outside-in
Now, I want to write that initially, I would create an end-to-end (e2e) test for this feature. However, I will skip it since the focus of this blog post is on unit tests. So, moving with the outside-in, as a C# developer, I will create an ApiController for the endpoint that will be called by an external system. The tests will start here and guide me on what else I need.
Let's assume that the payment provider outlines the requirements for the payment callback request structure, which is as follows:
public class PaymentCallbackRequest
{
public string PaymentReference { get; set; }
public string InvoiceNo { get; set; }
public decimal AmountPaid { get; set; }
}
I will first create a controller class:
[Route("api/[controller]")]
[ApiController]
public class PaymentController : ControllerBase
{
[HttpPost("callback")]
public IActionResult Callback(PaymentCallbackRequest request)
{
throw new NotImplementedException();
}
}
Now, I will open the "Quick Actions" menu and select "Generate Test." To do this, the EasyTdd extension must be installed in Visual Studio.
Voilà - the test class is in place, runnable, and ready to be extended:
public class PaymentControllerTests
{
private PaymentCallbackRequest _request;
[SetUp]
public void Setup()
{
_request = default;
}
[TestCase(null)]
public void CallbackThrowsArgumentExceptionWhenRequestIsNotValid(
PaymentCallbackRequest request)
{
_request = request;
Action act = () => CallCallback();
act
.Should()
.Throw<ArgumentException>();
}
private IActionResult CallCallback()
{
var sut = Create();
return sut
.Callback(
_request
);
}
private PaymentController Create()
{
return new PaymentController();
}
}
As in the example above, I will begin with a boundary test. The [TestCase]
attribute (correspondingly, [DataRow]
in MsTest and [InlineData]
in xUnit) can only accept primitive types, so I will modify this test to use the [TestCaseSource]
attribute (MsTest and xUnit have the [DynamicData]
and [MemberData]
attributes, respectively). This enables me to provide any data type for each test case. As expected, I will utilize EasyTDD to assist me in this process. EasyTdd can generate either an in-class method or a separate class as a test case source. It also works with MsTest, NUnit, and xUnit. If the amount of test data is small, I use an in-class method as a test case source; otherwise, I opt for a separate class. Open the "Quick actions" menu on the boundary test signature, and two options will be visible there:
The following code has been generated:
[TestCase(null)]
[TestCaseSource(nameof(GetCallbackThrowsArgumentExceptionWhenRequestIsNotValidCases))]
public void CallbackThrowsArgumentExceptionWhenRequestIsNotValid(
PaymentCallbackRequest request)
{
_request = request;
Action act = () => CallCallback();
act
.Should()
.Throw<ArgumentException>();
}
private static IEnumerable<TestCaseData> GetCallbackThrowsArgumentExceptionWhenRequestIsNotValidCases()
{
yield return new TestCaseData(
default //Set value for request
)
.SetName("[Test display name goes here]");
yield return new TestCaseData(
default //Set value for request
)
.SetName("[Test display name goes here]");
}
I will remove the [TestCase(null)]
from the test and modify it to expect a BadRequest result instead of an exception. For the sake of brevity, I will include only a few cases:
[TestCaseSource(nameof(GetCallbackThrowsArgumentExceptionWhenRequestIsNotValidCases))]
public void CallbackBadRequestWhenRequestIsNotValid(
PaymentCallbackRequest request)
{
_request = request;
var result = CallCallback();
result
.Should()
.BeOfType<BadRequestResult>();
}
private static IEnumerable<TestCaseData> GetCallbackThrowsArgumentExceptionWhenRequestIsNotValidCases()
{
yield return new TestCaseData(
new PaymentCallbackRequest
{
PaymentReference = "xx",
InvoiceNo = "yy",
AmountPaid = -1
}
)
.SetName("Amount is negative");
yield return new TestCaseData(
new PaymentCallbackRequest
{
PaymentReference = "xx",
InvoiceNo = "",
AmountPaid = 10
}
)
.SetName("InvoiceNo is not set");
yield return new TestCaseData(
new PaymentCallbackRequest
{
PaymentReference = "",
InvoiceNo = "yy",
AmountPaid = 10
}
)
.SetName("PaymentReference is not set");
}
I ran those tests. They failed (red). Then, I wrote some code to make the tests pass:
[HttpPost("callback")]
public IActionResult Callback(PaymentCallbackRequest request)
{
if (request.AmountPaid < 0)
{
return BadRequest();
}
if (string.IsNullOrWhiteSpace(request.InvoiceNo))
{
return BadRequest();
}
if (string.IsNullOrWhiteSpace(request.PaymentReference))
{
return BadRequest();
}
throw new NotImplementedException();
}
I'm running tests again - they're green. Now it's time for refactoring. I will move the validation to a separate method to make the main method cleaner and easier to read.
[HttpPost("callback")]
public IActionResult Callback(PaymentCallbackRequest request)
{
if (!IsRequestValid(request))
{
return BadRequest();
}
throw new NotImplementedException();
}
private static bool IsRequestValid(PaymentCallbackRequest request)
{
if (request.AmountPaid < 0)
{
return false;
}
if (string.IsNullOrWhiteSpace(request.InvoiceNo))
{
return false;
}
if (string.IsNullOrWhiteSpace(request.PaymentReference))
{
return false;
}
return true;
}
Now, the application must register the payment and send the message. It is not the responsibility of this service to handle how the payment should be registered, so I will delegate this task to the IInvoiceRepository
service. The name might not be ideal, but it will suffice for now, and I can change it later if necessary. I have the invoice number and paid amount, which need to be registered. RegisterPaymentAsync(string invoiceNo, decimal amount)
seems like a suitable contract:
public interface IInvoiceRepository
{
Task RegisterPaymentAsync(
string invoiceNo,
decimal amount
);
}
The implementations of this service will handle the specifics - where and how this information will be stored. I will postpone the decision on how to implement it and focus on the PaymentController implementation for now. In the tests, I will use test doubles to replace IInvoiceRepository
and IBus
.
Test doubles
Test doubles are objects that facilitate testing by making it easier. In tests, we typically replace dependencies with test doubles to reduce complexity, increase test speed, simplify setup, and focus solely on the System Under Test (SUT) without distractions. Several similar terms represent different concepts, such as stubs, fakes, mocks, spies, and dummies. "Mock" has become somewhat of a generic term to describe all of these, likely due to the prevalence of mocking frameworks that cover most scenarios. Even practitioners of the London School of Test-Driven Development (TDD) are called "Mockists."
In both my professional work and personal hobby or pet projects, I use Moq. While there are other options available, the main features of these tools are more or less the same.
Happy path
I will begin by outlining the complete process of a happy path. After that, I will consider alternative routes. I ask myself - what should be the result of the payment callback? It must register the payment and send a Paid
message. When an invoice is fully paid, the happy path would involve registering the payment and sending a "Paid" message:
What should I do first? Initially, I will set up the default request:
[SetUp]
public void Setup()
{
_request = new PaymentCallbackRequest
{
PaymentReference = "xx",
InvoiceNo = "EASY0001",
AmountPaid = 1000
};
}
The first test will ensure that the payment callback returns an "OK" status:
[Test]
public void ReturnsOKWhenInvoiceIsFullyPaid()
{
var result = CallCallback();
result
.Should()
.BeOfType<OkResult>();
}
I ran tests and they failed. Then, I wrote some code to make them pass:
[HttpPost("callback")]
public IActionResult Callback(PaymentCallbackRequest request)
{
if (!IsRequestValid(request))
{
return BadRequest();
}
return Ok();
}
Nothing to refactor here. The next test would be to assert whether the payment was registered:
[Test]
public async Task PaymentIsRegisteredWhenInvoiceIsFullyPaid()
{
await CallCallback();
_invoiceRepositoryMock.Verify();
}
Now, it doesn't even compile. The mock needs to be declared, initialized, set, and passed into the constructor. Since IInvoiceRepository.RegisterPaymentAsync
has a side effect and does not return a value, I can use either a spy or a mock test double to verify the call. For this service, I will use the mock variation.
private Mock<IInvoiceRepository> _invoiceRepositoryMock;
.
.
[SetUp]
public void Setup()
{
.
.
_invoiceRepositoryMock = new Mock<IInvoiceRepository>(MockBehavior.Strict);
_invoiceRepositoryMock
.Setup(
x => x.RegisterPaymentAsync(
"EASY0001",
1000
)
)
.Returns(Task.FromResult(0))
.Verifiable(Times.Once);
}
.
.
private PaymentController Create()
{
return new PaymentController(
_invoiceRepositoryMock.Object
);
}
Once again, I ran the test and it failed as expected. Now, I will write some code to make the test pass:
private readonly IInvoiceRepository _invoiceRepository;
public PaymentController(IInvoiceRepository invoiceRepository)
{
_invoiceRepository = invoiceRepository;
}
[HttpPost("callback")]
public Task<IActionResult> Callback(PaymentCallbackRequest request)
{
if (!IsRequestValid(request))
{
return BadRequest();
}
await _invoiceRepository
.RegisterPaymentAsync(
request.InvoiceNo,
request.AmountPaid
);
return Ok();
}
Now, I need a test to verify whether IBus
with the message Paid
was called.
[Test]
public async Task PaidMessageIsSentWhenInvoiceIsFullyPaid()
{
await CallCallback();
_busMock
.Verify(
x => x.PublishAsync(
It.Is<Paid>(x => x.InvoiceNo == "EASY0001")
)
);
}
Similar to _invoiceRepositoryMock
, I'm required to declare _busMock
, perform its initialization, set it up accordingly, and subsequently pass it to the constructor. IBus doesn't return any value but has a side effect - I can use a spy or a mock for it. In this example, I'll use the spy version of a test double.
private Mock<IBus> _busMock;
.
.
[SetUp]
public void Setup()
{
.
.
_busMock = new Mock<IBus>();
_busMock
.Setup(
x => x.PublishAsync<Paid>(
It.IsAny<Paid>()
)
);
}
.
.
private PaymentController Create()
{
return new PaymentController(
_invoiceRepositoryMock.Object,
_busMock.Object
);
}
I ran the test again, and it failed. Here is some code to make it pass:
[HttpPost("callback")]
public async Task<IActionResult> Callback(PaymentCallbackRequest request)
{
if (!IsRequestValid(request))
{
return BadRequest();
}
await _invoiceRepository
.RegisterPaymentAsync(
request.InvoiceNo,
request.AmountPaid
);
await _bus
.PublishAsync(
new Paid(request.InvoiceNo)
);
return Ok();
}
Other routes
I have the following test cases:
The invoice is fully paid with one payment - register the payment, send the
Paid
message.The invoice is partially paid - register the payment, send the
PartiallyPaid
message.The invoice has already been partially paid, and with the current request, it is fully paid - register the payment, send the
Paid
message.Payment is made for an unknown invoice - do not register the payment, send the
UnexpectedPayment
message.If the payment amount is larger than the invoice amount - do not register the payment, send the
UnexpectedPayment
message.The partially paid invoice is overpaid with the current request - do not register the payment, send the
UnexpectedPayment
message.Request received for an already paid invoice - do not register the payment, send the
UnexpectedPayment
message.
I won't cover all the cases here, as going step-by-step generates a lot of text. I'd like to introduce a few more concepts and then provide the complete code in a GitHub repository. I'll take some shortcuts here to reduce the amount of code.
First, I'd like to introduce the discovery of IInvoiceRepository.GetByInvoiceNoAsync
. In the happy path scenario, I assumed that an invoice was always fully paid, so I simply registered the payment and sent the appropriate message. Now, I'll address the second case from the list. To determine if the invoice is fully or partially paid, I need to retrieve some data from storage. I've deferred the decision on how exactly this data is stored and retrieved by introducing the new method, IInvoiceRepository.GetByInvoiceNoAsync
, and creating an Invoice
class:
public class Invoice
{
public Invoice(
string invoiceNo,
decimal totalAmount,
decimal paidAmount)
{
InvoiceNo = invoiceNo;
TotalAmount = totalAmount;
PaidAmount = paidAmount;
}
public string InvoiceNo { get; }
public decimal TotalAmount { get; }
public decimal PaidAmount { get; }
}
Setting up the GetByInvoiceNoAsync
method, I would do something like this:
_invoiceRepositoryMock
.Setup(x => x.GetByInvoiceNoAsync("EASY0001"))
.ReturnsAsync(
new Invoice("EASY0001", 1000, 0)
);
The corresponding tests might appear as follows:
[Test]
public async Task PaymentIsRegisteredWhenInvoiceIsPartiallyPaid()
{
_request.AmountPaid = 500;
await CallCallback();
_invoiceRepositoryMock
.Verify();
}
[Test]
public async Task PartiallyPaidMessageIsSentWhenInvoiceIsPartiallyPaid()
{
_request.AmountPaid = 500;
await CallCallback();
_busMock
.Verify(
x => x.PublishAsync(
It.Is<PartiallyPaid>(x => x.InvoiceNo == "EASY0001")
)
);
}
And the code:
[HttpPost("callback")]
public async Task<IActionResult> Callback(PaymentCallbackRequest request)
{
if (!IsRequestValid(request))
{
return BadRequest();
}
var invoice = await _invoiceRepository
.GetByInvoiceNoAsync(request.InvoiceNo);
await _invoiceRepository
.RegisterPaymentAsync(
request.InvoiceNo,
request.AmountPaid
);
if (invoice.TotalAmount < request.AmountPaid)
{
await _bus
.PublishAsync(
new PartiallyPaid(request.InvoiceNo)
);
}
else
{
await _bus
.PublishAsync(
new Paid(request.InvoiceNo)
);
}
return Ok();
}
Although the test does not pass, it is because RegisterPaymentAsync
is set to receive 1,000, but 500 is passed. Writing the setup for each dependency in every test can become quite wordy. It seems like it's time to introduce another concept - setting up a mock to return a reference instead of a specific object. To do this, I will use default values for all variables except the invoice from the repository. Instead of having separate requests, invoices, and mock setups for each case, I will only change the invoice. By having different values in the invoice object, I can simulate all the cases. To achieve this, I will modify the setup of IInvoiceRepository.GetByInvoiceNoAsync
:
private Invoice _invoiceRepositoryResult;
[SetUp]
public void Setup()
{
.
.
_invoiceRepositoryResult = new Invoice("EASY0001", 1000, 0);
_invoiceRepositoryMock
.Setup(x => x.GetByInvoiceNoAsync("EASY0001"))
.ReturnsAsync(
() => _invoiceRepositoryResult
);
.
.
And now, tests for a partially-paid case will appear as follows:
[Test]
public async Task PaymentIsRegisteredWhenInvoiceIsFullyPaid()
{
_invoiceRepositoryResult = new Invoice("EASY0001", 1500, 0);
await CallCallback();
_invoiceRepositoryMock
.Verify();
}
[Test]
public async Task PartiallyPaidMessageIsSentWhenInvoiceIsPartiallyPaid()
{
_invoiceRepositoryResult = new Invoice("EASY0001", 1500, 0);
await CallCallback();
_busMock
.Verify(
x => x.PublishAsync(
It.Is<PartiallyPaid>(x => x.InvoiceNo == "EASY0001")
)
);
}
The PartiallyPaidMessageIsSentWhenInvoiceIsPartiallyPaid
test is still failing. Hmm.. It appears I made a mistake in the code - the condition (invoice.TotalAmount < request.AmountPaid)
should be changed to (invoice.TotalAmount > request.AmountPaid)
. I have corrected this, and now the tests are passing - hooray!
Test case bundling
For now, it appears that each test case from the list in the above segment will generate a few tests - one to assert IBus, one to assert IInvoiceRepository
, and likely one more to test the result. However, I can bundle these to produce less code. IInvoiceRepository needs to be called in some cases and not called in others. I will have two tests for that with Invoice as a parameter:
[Test]
public async Task PaymentIsRegisteredWhenInvoiceIsPaid(
Invoice invoice)
{
_invoiceRepositoryResult = invoice;
await CallCallback();
_invoiceRepositoryMock
.Verify();
}
Now, I will utilize the EasyTdd feature, "Generate Test Cases in External File":
This action generates a PaymentIsRegisteredWhenInvoiceIsPaidCases
file, where I can include all the cases when RegisterPaymentAsync
needs to be called:
public class PaymentIsRegisteredWhenInvoiceIsPaidCases : IEnumerable
{
public IEnumerator GetEnumerator()
{
yield return new TestCaseData(
new Invoice("EASY0001", 1000, 0)
)
.SetName("Invoice is fully paid");
yield return new TestCaseData(
new Invoice("EASY0001", 1500, 0)
)
.SetName("Invoice is partially paid");
yield return new TestCaseData(
new Invoice("EASY0001", 1500, 500)
)
.SetName("Partially paid invoice is fully paid");
yield return new TestCaseData(
new Invoice("EASY0001", 1500, 100)
)
.SetName("Partially paid invoice is partially paid");
}
}
The same applies to other assertions as well. As previously mentioned, I will not delve into the details in this post, but instead, I will provide step-by-step commits in the following GitHub repository.
Some notes from the commit history
I am pleased that I managed to catch two bugs while implementing these examples, which were identified by running the tests. The first bug, which I mentioned earlier, involved mixing up <
and >
. The fix for this issue can be found in the commit [2e3a6b2]
. The second bug can be found in the commit [8ba3718]
, where I mistakenly used invoice.TotalAmount
instead of invoice.PaidAmount
in an if statement.
Another important point is the rule of having a failing test first. It is crucial to start with a failing test, as it is possible to make mistakes in the test itself. One instance where I encountered this issue was in the commit [fb27deb]
, with the fix provided in [783e9a9]
. In this situation, when bundling test cases, I used the existing test and added new cases. However, I mistakenly used the same old invoice value for all tests, neglecting the new cases. If I hadn't run the test and received a green light, I might have overlooked the error in the test and potentially forgotten to write code for those specific cases.
Arrange-Act-Assert
As it is known, the Arrange part is where we prepare the System Under Test (SUT) for acting, the Act is when we instruct the SUT to act, and the Assert is when we assess whether the SUT did what it was intended to do. I think that not all parts are clearly distinguished, especially Arrange. One part of Arrange is in the setup method, where test doubles are created and default values are set. Then, there's the test itself, where default values are changed to simulate other test cases. Finally, inside the CallCallback, the SUT is created with all its dependencies, and the request is provided to the method under test. It might look awkward and overly complex at first, but I found that it produces less code for me and helps me avoid code duplication. I could identify such points about the setting:
Create a field for each input parameter
Create a field for each test double
Create a field for each result of the test double
Set those fields up to satisfy a specific test case (happy path, longest path, or some other path)
Many test cases can be covered by changing one or a couple of fields from the list above.
I will not delve deeper into Act, as it is straightforward. Regarding Assert, I can add that assertions can leak into the test cleanup method. For example, mocks could be verified thereby calling Verify, or all mocks can be verified by calling the mock repository. I usually do not do this, as I prefer the spy version of test doubles.
Conclusion
In wrapping up, this blog post marks my initial venture into writing, and its completion required considerable time and effort. While it might seem lengthy, my intention was to establish a strong foundation for future writings and discussions. I've introduced my take on Test-Driven Development (TDD), focusing on the London-style approach and the practical use of test doubles. Throughout the post, I've demonstrated TDD in various scenarios, from a basic calculator to a more complex payment callback functionality. Emphasizing the significance of initiating tests that fail initially and following the Arrange-Act-Assert pattern, I've aimed to offer a comprehensive view of TDD principles. Lastly, I've introduced EasyTDD, my Visual Studio Extension designed to simplify the TDD process. This post serves as an opportunity to seek feedback on my TDD approach and to introduce EasyTDD to the developer community.
Posted on January 27, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.