What happens as our app gets more complex?
Let's go back to our CalculatorProgram functionality.
Instead of declaring the code for the operations, first, make the test cases and then implement the functionality from there. As you may remember, you start with test-failing scenarios (red), then implement the tests and make them pass (green), and then refactor the code as needed.
Our next functionalities will include the divide and multiply operations. By thinking of the test cases first, you can identify the following:
In the context of our calculator program, a multiply operation will be supported to receive an array of numbers.
The same as the InMemory support feature, we can pass a new number to the multiply operation, and the calculator will keep the result in memory and available for subsequent operations.
The same features above will be supported for the divide operation, with special attention in the case of having divide by zero operations, which might result in an exception to our program.
Let's implement the multiply and divide operations using TDD. We will start by creating a new test class:
using System;
using CalculatorProgram;
using Xunit;
namespace UnitTests
{
public class MultiplyDivideTests
{
}
}
Then we add a new [Fact] called Test_MultiplyTwoElements
and Test_MultiplyManyElements
for expecting the results of the multiply operation over a set of numbers. In the case of the InMemory feature, we can have a separate test for accepting one number and expect the results to be stored, cleared, and so on.
[Fact]
public void Test_MultiplyTwoElements()
{
// 1) Arrange
var calculator = new Calculator();
// Act (the actual operation)
var result = calculator.Multiply(10, 5);
// Then, Assert
Assert.Equal(50, result);
}
[Fact]
public void Test_MultiplyManyElements()
{
// 1) Arrange
var calculator = new Calculator();
// Act (the actual operation)
var result = calculator.Multiply(0.5M, 1, 2, 3, 4, -5.5M);
// Then, Assert
Assert.Equal(-66, result);
Assert.IsType<decimal>(result);
}
The same applies to the divide operation, so our tests will end up like the following:
[Fact]
public void Test_DivideTwoElements()
{
// 1) Arrange
var calculator = new Calculator();
// Act (the actual operation)
var result = calculator.Divide(10, 5);
// Then, Assert
Assert.Equal(2, result);
}
[Fact]
public void Test_DivideManyElements()
{
// 1) Arrange
var calculator = new Calculator();
// Act (the actual operation)
var result = calculator.Divide(100, 2, 4);
// Then, Assert
Assert.Equal((decimal)12.5, result);
Assert.IsType<decimal>(result);
}
Since our code is not implemented, we can add both of the new methods and implement them by default as NotImplementedException, so they will make our test base fail:
Now that we have added our test-failing cases using TDD, let's make the implementation so we can test the final code again and get some feedback:
public decimal Multiply(params decimal[] numbers)
{
if (numbers.Length == 1)
{
Current *= numbers[0];
return Current;
}
var result = numbers[0];
for (int i = 1; i <= numbers.Length - 1; i++)
{
result *= numbers[i];
}
return result;
}
public decimal Divide(params decimal[] numbers)
{
if (numbers.Length == 1)
{
Current /= numbers[0];
return Current;
}
var result = numbers[0];
for (int i = 1; i <= numbers.Length - 1; i++)
{
result /= numbers[i];
}
return result;
}
Sweet! TDD in practice. 😉
Expect exceptions
Let's handle the divide by zero support for the calculator now starting from a single test:
Wait, why is that failing? 😳
Actually, that's to be expected. As mentioned previously, the Divide
action method continues running even if users send a divide by zero operation. Instead of allowing our program to fail, we can make an exception support occur and handle the exception properly.
Handle exceptions with the Assert.Throws method
We can reflect this exception in our test cases by using the Assert.Throws\<T\>
method, which accepts a generic type that can be used with the exception of our needs (i.e., Assert.Throws\<System.DivideByZeroException\>()
). So our test code ends up like the following:
[Fact]
public void Test_DivideByZero()
{
// 1) Arrange
var calculator = new Calculator();
Assert.Throws<DivideByZeroException>(() => {
// Act (the actual operation)
var result = calculator.Divide(1, 0);
// Then, Assert
Assert.IsType<decimal>(result);
});
}
By making that we can see our test case becomes green now:
Much better! 😁
Handle multiple exception cases with a test suite
For cases where different kinds of exceptions may occur in your program, you can set multiple test cases from multiple classes. As mentioned previously, this combination of test cases is what's called a test suite (also, a validation suite).
Let's say that we want to perform certain validations in our CalculatorProgram for given parameters like decimal.MaxValue
(in order to prevent a System.OverflowException
). We can do this by throwing an InvalidOperationException
. We can have a dedicated test case to handle that:
[Fact]
public void Test_Validations()
{
// 1) Arrange
var calculator = new Calculator();
Exception exception = Assert.Throws<InvalidOperationException>(() =>
{
// Act (the actual operation)
var result = calculator.Multiply(decimal.MaxValue, decimal.MaxValue);
// Then, Assert
Assert.IsType<decimal>(result);
});
Assert.Equal("Not a valid number to use", exception.Message);
exception = Assert.Throws<DivideByZeroException>(() =>
{
// Act (the actual operation)
var result = calculator.Divide(decimal.MaxValue, 0);
// Then, Assert
Assert.IsType<decimal>(result);
});
Assert.IsType<DivideByZeroException>(exception);
}
Alternatively, we can check on the exception being thrown in the test case and perform any checks at the exception level to make sure it gets handled properly with enough details. Then, by having our test fail, we can make it green by providing the implementation:
public decimal Multiply(params decimal[] numbers)
{
ValidateNumbers(numbers);
if (numbers.Length == 1)
{
Current *= numbers[0];
return Current;
}
var result = numbers[0];
for (int i = 1; i <= numbers.Length - 1; i++)
{
result *= numbers[i];
}
return result;
}
private void ValidateNumbers(decimal[] numbers)
{
if (numbers.Any(number => number == decimal.MaxValue))
{
throw new InvalidOperationException("Not a valid number to use");
}
}
By making multiple exception cases in your code, you can handle scenarios where logic needs to be enforced.
Let's recap!
Identifying exceptions in your software features is important for dealing with non-happy path scenarios without having to wait for your software to be tested by final users.
With unit testing and xUnit for .NET, you can set those exceptions as part of your test cases and have specific code that makes sure they are handled as expected.
The
Assert.Throws
method is very useful for handling exceptions in your code.