Mastering Unit Testing in .NET Core: A Practical Guide with xUnit, Moq, and FluentAssertions

Developing robust and easily maintainable software hinges significantly on effective unit testing. This guide will provide a detailed, step-by-step exploration of crafting unit tests within the .NET Core ecosystem. We’ll leverage powerful libraries like xUnit for the testing framework, Moq for creating mock objects, and FluentAssertions for highly readable assertions, all demonstrated through a practical blog application example.

1. Creating the Test Project

To begin, envision an existing API project named BlogApp. Our objective is to establish a dedicated test project designed to validate the functionality of a crucial service class: PostService. Follow these commands to set up your test environment:

dotnet new xunit -n BlogApp.Tests
dotnet add BlogApp.Tests reference ../BlogApp/BlogApp.csproj

The resulting folder structure should resemble this:

BlogApp/
 ┣ Controllers/
 ┣ Services/
 ┣ Models/
 ┣ BlogApp.csproj
BlogApp.Tests/
 ┣ PostServiceTests.cs
 ┣ BlogApp.Tests.csproj

2. Installing Dependencies

Our unit testing setup will incorporate several widely-used testing libraries. Install them using the following commands:

dotnet add package Moq
dotnet add package FluentAssertions

Here’s a brief overview of their roles:

  • Moq → This library is instrumental in generating fake or ‘mocked’ dependencies, enabling you to isolate the specific component under test from its real collaborators.
  • FluentAssertions → It transforms your test assertions into highly readable and expressive statements, improving the clarity of your test code.

3. The Service We’re Going to Test

Below is the PostService class that we intend to test. This service is responsible for retrieving post data from an IPostRepository.

public interface IPostRepository
{
    Task<Post> GetByIdAsync(int id);
    Task<List<Post>> GetAllAsync();
}

public class PostService
{
    private readonly IPostRepository _repo;

    public PostService(IPostRepository repo)
    {
        _repo = repo;
    }

    public async Task<Post> GetPostByIdAsync(int id)
    {
        if (id <= 0)
            throw new ArgumentException("Invalid ID");

        var post = await _repo.GetByIdAsync(id);
        if (post == null)
            throw new KeyNotFoundException("Post not found");

        return post;
    }
}

4. Writing Unit Tests with xUnit, Moq, and FluentAssertions

Now, let’s craft three distinct unit tests to thoroughly cover both valid and edge-case scenarios for our PostService. These tests will reside in the BlogApp.Tests/PostServiceTests.cs file:

using Xunit;
using Moq;
using System.Threading.Tasks;
using System.Collections.Generic;
using FluentAssertions;

public class PostServiceTests
{
    [Fact]
    public async Task GetPostByIdAsync_ShouldReturnPost_WhenIdIsValid()
    {
        // Arrange
        var mockRepo = new Mock<IPostRepository>();
        mockRepo.Setup(r => r.GetByIdAsync(1))
                .ReturnsAsync(new Post { Id = 1, Title = "Test Post" });

        var service = new PostService(mockRepo.Object);

        // Act
        var result = await service.GetPostByIdAsync(1);

        // Assert
        result.Should().NotBeNull();
        result.Title.Should().Be("Test Post");
    }

    [Fact]
    public async Task GetPostByIdAsync_ShouldThrowArgumentException_WhenIdIsInvalid()
    {
        var mockRepo = new Mock<IPostRepository>();
        var service = new PostService(mockRepo.Object);

        Func<Task> act = async () => await service.GetPostByIdAsync(0);
        await act.Should().ThrowAsync<ArgumentException>()
            .WithMessage("Invalid ID");
    }

    [Fact]
    public async Task GetPostByIdAsync_ShouldThrowKeyNotFoundException_WhenPostNotFound()
    {
        var mockRepo = new Mock<IPostRepository>();
        mockRepo.Setup(r => r.GetByIdAsync(2))
                .ReturnsAsync((Post)null);

        var service = new PostService(mockRepo.Object);

        Func<Task> act = async () => await service.GetPostByIdAsync(2);
        await act.Should().ThrowAsync<KeyNotFoundException>()
            .WithMessage("Post not found");
    }
}

5. Running the Tests

To initiate the execution of all your newly created tests, simply issue the following command:

dotnet test

A successful run should yield output similar to this:

Starting test execution...
Passed!  - 3 passed, 0 failed

Best Practices for Unit Testing

Adhering to best practices is crucial for effective unit testing:

  • Ensure Test Isolation: Your unit tests should operate independently, meaning they should never rely on external resources such as actual databases or live APIs.
  • Employ Descriptive Naming: Choose clear, self-explanatory names for your tests, like GetPostByIdAsync_ShouldReturnPost_WhenIdIsValid, to instantly convey their purpose.
  • Strive for Determinism: Tests must produce consistent results – they should either reliably pass or reliably fail under the same conditions every time.
  • Integrate with CI/CD: Incorporate your unit tests into Continuous Integration/Continuous Deployment (CI/CD) pipelines (e.g., GitHub Actions, Azure DevOps) to automate validation.
  • Leverage FluentAssertions: Utilize FluentAssertions to write more expressive and easily understandable assertion statements, enhancing code readability.

Bonus Tip — When to Use Integration Tests

While unit tests excel at validating isolated segments of business logic, there are scenarios where a broader scope of testing is required. If your goal is to verify complete end-to-end functionality, encompassing interactions between multiple components such as an API, database, and repository, then integration tests are the appropriate choice.

Summary

Ultimately, unit testing transcends mere code coverage; it’s about instilling confidence in your application’s behavior. By skillfully employing xUnit as your framework, Moq for dependency mocking, and FluentAssertions for clear assertions, you can effortlessly craft clean, maintainable tests. This practice significantly enhances the reliability and long-term maintainability of your .NET Core applications.

Recommended Resources

Get sample code from GitHub

Author

I’m Morteza Jangjoo and “Explaining things I wish someone had explained to me.”
🌐 Follow me on Hashnode or GitHub

💬 If you found this helpful, give it a ❤️ and share your favorite testing tip in the comments!

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.
You need to agree with the terms to proceed