Ensuring Quality and Reliability with xUnit: Best Practices for Automated Testing in .NET Core
Alisson Podgurski
Posted on July 27, 2024
Introduction
Imagine building a house without checking if the doors close properly, the windows open as they should, or the electricity works. It sounds like a disaster waiting to happen, right? The same goes for software development. Automated testing is like a quality inspection that ensures everything is functioning perfectly before we deliver our "digital building." In this article, we will explore how xUnit can help us test our .NET Core code, ensuring everything is in order and working as expected.
Why test?
Automated testing is our line of defense against bugs, and it's amazing for several reasons:
- Early bug detection:We find problems before they become giant monsters.
- Living documentation: Keep an up-to-date record of system behavior (no boring paperwork!).
- Safe refactoring: We can change the code without fear of breaking everything.
- Trust and stability: We know our software works, and we can sleep easy.
What is xUnit?
xUnit is like a testing superhero in the .NET world. It is simple, efficient and powerful. Let's see how to configure and use this hero in our .NET Core project.
Configuring the environment
To get started, we need to invite our xUnit hero to the party. Let's add some packages to our testing project:
Add xunit and xunit.runner.visualstudio NuGet package:
dotnet add package xunit
dotnet add package xunit.runner.visualstudio
Add the reference to the main project in your test project:
dotnet add reference ../seu-projeto/SeuProjeto.csproj
Writing tests with xUnit
Let's create an example using an order management system, where we will test the business logic of an OrderService class that calculates the order total with discounts. Who doesn't love a good discount, right?
Order Class
public class Order
{
public List<OrderItem> Items { get; set; }
public decimal Discount { get; set; }
public Order()
{
Items = new List<OrderItem>();
}
}
public class OrderItem
{
public string Name { get; set; }
public decimal Price { get; set; }
public int Quantity { get; set; }
}
Classe OrderService
public class OrderService
{
public decimal CalculateTotal(Order order)
{
if (order == null) throw new ArgumentNullException(nameof(order));
if (order.Items == null || !order.Items.Any()) throw new ArgumentException("Order must have items.");
var subtotal = order.Items.Sum(item => item.Price * item.Quantity);
var discountAmount = subtotal * (order.Discount / 100);
var total = subtotal - discountAmount;
return total;
}
}
Tests with xUnit
Testing OrderService
using Xunit;
public class OrderServiceTests
{
[Fact]
public void CalculateTotal_ShouldReturnCorrectTotal_WithoutDiscount()
{
// Arrange
var order = new Order();
order.Items.Add(new OrderItem { Name = "Item1", Price = 10, Quantity = 2 }); // Total 20
order.Items.Add(new OrderItem { Name = "Item2", Price = 5, Quantity = 1 }); // Total 5
var orderService = new OrderService();
// Act
var total = orderService.CalculateTotal(order);
// Assert
Assert.Equal(25, total);
}
[Fact]
public void CalculateTotal_ShouldReturnCorrectTotal_WithDiscount()
{
// Arrange
var order = new Order();
order.Items.Add(new OrderItem { Name = "Item1", Price = 10, Quantity = 2 }); // Total 20
order.Items.Add(new OrderItem { Name = "Item2", Price = 5, Quantity = 1 }); // Total 5
order.Discount = 10; // 10% discount
var orderService = new OrderService();
// Act
var total = orderService.CalculateTotal(order);
// Assert
Assert.Equal(22.5m, total);
}
[Fact]
public void CalculateTotal_ShouldThrowArgumentNullException_IfOrderIsNull()
{
// Arrange
var orderService = new OrderService();
// Act & Assert
Assert.Throws<ArgumentNullException>(() => orderService.CalculateTotal(null));
}
[Fact]
public void CalculateTotal_ShouldThrowArgumentException_IfOrderHasNoItems()
{
// Arrange
var order = new Order();
var orderService = new OrderService();
// Act & Assert
Assert.Throws<ArgumentException>(() => orderService.CalculateTotal(order));
}
}
Best Practices
Descriptive Test Method Names
Name your tests as if you are telling a story. Who doesn't love a good story?
[Fact]
public void CalculateTotal_ShouldReturnCorrectTotal_WithoutDiscount()
{
// Arrange
var order = new Order();
order.Items.Add(new OrderItem { Name = "Item1", Price = 10, Quantity = 2 });
order.Items.Add(new OrderItem { Name = "Item2", Price = 5, Quantity = 1 });
var orderService = new OrderService();
// Act
var total = orderService.CalculateTotal(order);
// Assert
Assert.Equal(25, total);
}
AAA (Arrange, Act, Assert)
This is the magic formula for tests: prepare things, do the magic, and see if it worked.
The AAA (Arrange, Act, Assert) pattern is a recommended practice for writing unit tests that enhances readability, maintainability, and understanding of the tests. Below, I detail the reasons for using this pattern and how it applies to the provided example:
Clarity and Organization
Arrange: In this phase, you set up all the necessary dependencies and inputs for the test. This can include creating objects, setting states, and initializing services or mock objects.
Act: Here, you perform the action or method you want to test. It's the "magic act" where the main functionality being tested is invoked.
Assert: Finally, you verify that the outcome of the action is as expected. This is done using assertions that compare the actual result with the expected result.
Separation of Concerns
Each phase of AAA is clearly delineated, separating the test setup, execution of the test logic, and verification of the results. This makes the tests more readable and helps in identifying issues more easily.
Easier Maintenance
The clarity and organization provided by the AAA pattern make the test code easier to maintain. New developers or code reviewers can quickly understand what is being tested and how, enabling more efficient collaboration.
Efficient Diagnosis
When a test fails, it is easier to diagnose the cause. If a failure occurs in the arrange phase, you know there is an issue with the setup. If it fails in the execution, the problem is with the method under test. And if the assertion fails, there is an issue with the logic or the expected outcome.
See the example below:
[Fact]
public void CalculateTotal_ShouldReturnCorrectTotal_WithoutDiscount()
{
// Arrange
var order = new Order();
order.Items.Add(new OrderItem { Name = "Item1", Price = 10, Quantity = 2 });
order.Items.Add(new OrderItem { Name = "Item2", Price = 5, Quantity = 1 });
var orderService = new OrderService();
// Act
var total = orderService.CalculateTotal(order);
// Assert
Assert.Equal(25, total);
}
Arrange: Creating an Order object and adding items with name, price, and quantity.
Instantiating the OrderService that will be used to calculate the total.
Act: Calling the CalculateTotal method on the OrderService with the configured order.
Assert: Verifying that the calculated total is equal to 25, which is the expected sum of the item prices in the order.
Practical Advantages
More Readable Tests: Tests following the AAA pattern are easier to read and understand, allowing new developers or code reviewers to quickly grasp the test's intent.
Easier Debugging: By clearly separating each phase, it becomes easier to debug the test and identify in which phase the failure occurred.
Modularity and Reusability: With separate setup and execution phases, parts of the test code can be reused in different scenarios, increasing modularity.
Independent Tests
Tests are like small children: they need to be independent of each other to avoid making a mess.
[Fact]
public void CalculateTotal_ShouldReturnCorrectTotal_WithoutDiscount()
{
// Arrange
var order = new Order();
order.Items.Add(new OrderItem { Name = "Item1", Price = 10, Quantity = 2 });
order.Items.Add(new OrderItem { Name = "Item2", Price = 5, Quantity = 1 });
var orderService = new OrderService();
// Act
var total = orderService.CalculateTotal(order);
// Assert
Assert.Equal(25, total);
}
[Fact]
public void CalculateTotal_ShouldReturnCorrectTotal_WithDiscount()
{
// Arrange
var order = new Order();
order.Items.Add(new OrderItem { Name = "Item1", Price = 10, Quantity = 2 });
order.Items.Add(new OrderItem { Name = "Item2", Price = 5, Quantity = 1 });
order.Discount = 10;
var orderService = new OrderService();
// Act
var total = orderService.CalculateTotal(order);
// Assert
Assert.Equal(22.5m, total);
}
Using Theory and InlineData
Test multiple cases with a single shot. Two birds with one stone.
[Theory]
[InlineData(10, 2, 5, 1, 0, 25)]
[InlineData(10, 2, 5, 1, 10, 22.5)]
[InlineData(20, 1, 30, 1, 50, 25)]
public void CalculateTotal_ShouldReturnCorrectTotal_MultipleCases(decimal price1, int qty1, decimal price2, int qty2, decimal discount, decimal expected)
{
// Arrange
var order = new Order();
order.Items.Add(new OrderItem { Name = "Item1", Price = price1, Quantity = qty1 });
order.Items.Add(new OrderItem { Name = "Item2", Price = price2, Quantity = qty2 });
order.Discount = discount;
var orderService = new OrderService();
// Act
var total = orderService.CalculateTotal(order);
// Assert
Assert.Equal(expected, total);
}
Mocking Dependencies
Use mocks to pretend to be someone important. It's like theater, but for code.
public interface IDiscountService
{
decimal GetDiscount(Order order);
}
public class OrderService
{
private readonly IDiscountService _discountService;
public OrderService(IDiscountService discountService)
{
_discountService = discountService;
}
public decimal CalculateTotal(Order order)
{
if (order == null) throw new ArgumentNullException(nameof(order));
if (order.Items == null || !order.Items.Any()) throw new ArgumentException("Order must have items.");
var subtotal = order.Items.Sum(item => item.Price * item.Quantity);
var discount = _discountService.GetDiscount(order);
var total = subtotal - discount;
return total;
}
}
public class OrderServiceTests
{
[Fact]
public void CalculateTotal_ShouldReturnCorrectTotal_WithMockDiscount()
{
// Arrange
var mockDiscountService = new Mock<IDiscountService>();
mockDiscountService.Setup(service => service.GetDiscount(It.IsAny<Order>())).Returns(5);
var order = new Order();
order.Items.Add(new OrderItem { Name = "Item1", Price = 10, Quantity = 2 });
var orderService = new OrderService(mockDiscountService.Object);
// Act
var total = orderService.CalculateTotal(order);
// Assert
Assert.Equal(15, total);
}
}
Integration Tests
Besides unit tests, write integration tests to ensure different parts of the system work correctly together. Like a band, all instruments need to be in tune.
public class OrderIntegrationTests
{
[Fact]
public async Task CalculateTotalEndpoint_ShouldReturnCorrectTotal()
{
// Arrange
var client = new HttpClient();
var order = new
{
Items = new[]
{
new { Name = "Item1", Price = 10m, Quantity = 2 },
new { Name = "Item2", Price = 5m, Quantity = 1 }
},
Discount = 10
};
// Act
var response = await client.PostAsJsonAsync("https://localhost:5001/api/order/calculate", order);
// Assert
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadAsStringAsync();
Assert.Equal("22.5", result);
}
}
Testing Exceptions
Ensure exceptions are thrown correctly in invalid scenarios. Because errors happen, and we need to be prepared.
[Fact]
public void CalculateTotal_ShouldThrowArgumentNullException_IfOrderIsNull()
{
// Arrange
var orderService = new OrderService();
// Act & Assert
Assert.Throws<ArgumentNullException>(() => orderService.CalculateTotal(null));
}
Testing Asynchronous Code
Make sure to test asynchronous methods properly. After all, no one likes to wait, not even your code.
public class OrderService
{
public async Task<decimal> CalculateTotalAsync(Order order)
{
if (order == null) throw new ArgumentNullException(nameof(order));
if (order.Items == null || !order.Items.Any()) throw new ArgumentException("Order must have items.");
await Task.Delay(100); // Simulating async work
var subtotal = order.Items.Sum(item => item.Price * item.Quantity);
var discountAmount = subtotal * (order.Discount / 100);
var total = subtotal - discountAmount;
return total;
}
}
public class OrderServiceAsyncTests
{
[Fact]
public async Task CalculateTotalAsync_ShouldReturnCorrectTotal_WithoutDiscount()
{
// Arrange
var order = new Order();
order.Items.Add(new OrderItem { Name = "Item1", Price = 10, Quantity = 2 });
order.Items.Add(new OrderItem { Name = "Item2", Price = 5, Quantity = 1 });
var orderService = new OrderService();
// Act
var total = await orderService.CalculateTotalAsync(order);
// Assert
Assert.Equal(25, total);
}
}
Using Fixtures
Use IClassFixture to share setup and state between multiple tests. Like a host preparing everything for the guests.
public class OrderServiceFixture : IDisposable
{
public OrderService OrderService { get; private set; }
public OrderServiceFixture()
{
OrderService = new OrderService();
}
public void Dispose()
{
// Cleanup
}
}
public class OrderServiceTests : IClassFixture<OrderServiceFixture>
{
private readonly OrderServiceFixture _fixture;
public OrderServiceTests(OrderServiceFixture fixture)
{
_fixture = fixture;
}
[Fact]
public void CalculateTotal_ShouldReturnCorrectTotal_WithoutDiscount()
{
// Arrange
var order = new Order();
order.Items.Add(new OrderItem { Name = "Item1", Price = 10, Quantity = 2 });
order.Items.Add(new OrderItem { Name = "Item2", Price = 5, Quantity = 1 });
// Act
var total = _fixture.OrderService.CalculateTotal(order);
// Assert
Assert.Equal(25, total);
}
}
Conclusion
Automated tests are essential to ensure that our software works perfectly. By using xUnit in .NET Core projects, we can create clear and efficient tests, following best practices that guarantee code maintainability and scalability. While the topic of automated testing is vast and could fill volumes, this article aims to highlight the most critical aspects to help you develop high-quality tests in your application. Incorporating these principles and techniques into your workflow will bring long-term benefits, increasing the reliability and robustness of your software.
Posted on July 27, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.