DEV Community

Cover image for Ensuring Quality and Reliability with xUnit: Best Practices for Automated Testing in .NET Core
Alisson Podgurski
Alisson Podgurski

Posted on • Edited on

Ensuring Quality and Reliability with xUnit: Best Practices for Automated Testing in .NET Core

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
Enter fullscreen mode Exit fullscreen mode

Add the reference to the main project in your test project:

dotnet add reference ../seu-projeto/SeuProjeto.csproj
Enter fullscreen mode Exit fullscreen mode

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; }
}

Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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));
    }
}

Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}

Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)