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
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!