Mastering Automated Testing and CI/CD in .NET with xUnit, Moq, and Fluent Assertions

In any software development lifecycle, testing and continuous integration/continuous delivery (CI/CD) play pivotal roles in ensuring high-quality, reliable, and scalable applications.

In .NET development, a solid testing strategy combined with a well-defined CI/CD pipeline can help streamline the development process, reduce errors, and improve the overall reliability of your applications.

In this article, we will explore unit testing with .NET, particularly when working with repository patterns, and explore how CI/CD pipelines in Azure DevOps can automate the build and deployment process.

Unit Testing in .NET with the Repository Pattern

Unit testing is a critical practice that ensures your code works as expected and helps identify bugs early in the development cycle. When building applications in .NET, one common architectural pattern used to manage data is the repository pattern. The repository pattern abstracts data access logic, which allows for easier testing, as it can be mocked or stubbed out in unit tests.

Let’s explore how to write unit tests for a service that interacts with a repository in .NET. In this example, we’ll be working with a BookService that uses a repository to manage Book entities. The service relies on the repository to retrieve, add, update, and delete books from a data store.

Setting Up the Book Service and Repository

In this scenario, BookService depends on the IBookRepository to perform operations on Book entities. Now, we will write unit tests for BookService to ensure its correctness and behavior in different scenarios.

Book Entity

The Book class represents the data model for a book, including properties such as IdTitle, and Author, which are used to define a book’s structure in the application. This class serves as the foundation for storing and manipulating book data.

public class Book
{
public int Id { get; set; }
public string Title { get; set; }
public string Author { get; set; }
}

Book Repository Interface

The IBookRepository interface defines the contract for interacting with the book data, including methods like GetByIdAsyncGetAllAsync, and AddAsync. It abstracts the data access layer, making it easier to implement different storage mechanisms and mock the repository for testing purposes.

public interface IBookRepository
{
Task<Book> GetByIdAsync(int id);
Task<IEnumerable<Book>> GetAllAsync();
Task AddAsync(Book book);
Task UpdateAsync(Book book);
Task DeleteAsync(int id);
}

Book Service

The BookService class contains business logic and uses the IBookRepository to perform operations on books, such as retrieving, adding, and updating them. It provides a higher level of abstraction for interacting with book data, handling validation, and ensuring the correct flow of operations.

public class BookService
{
private readonly IBookRepository _bookRepository;

public BookService(IBookRepository bookRepository)
{
_bookRepository = bookRepository;
}

public async Task<Book> GetBookByIdAsync(int id)
{
var book = await _bookRepository.GetByIdAsync(id);
if (book == null) throw new NotFoundException($"Book with ID {id} not found.");
return book;
}

public async Task<IEnumerable<Book>> GetAllBooksAsync()
{
return await _bookRepository.GetAllAsync();
}

public async Task AddBookAsync(Book book)
{
if (book == null) throw new ArgumentNullException(nameof(book));
await _bookRepository.AddAsync(book);
}
}

Writing Unit Tests with xUnit and Moq

We’ll use xUnit for writing tests, Moq for mocking the repository dependency, and FluentAssertions for clean, readable assertions.

Test Setup

In the test setup, we create a mock of the IBookRepository and inject it into the BookService. Now we can write tests that isolate the BookService and mock the repository’s behavior.

public class BookServiceTests
{
private readonly Mock<IBookRepository> _bookRepositoryMock;
private readonly BookService _bookService;

public BookServiceTests()
{
_bookRepositoryMock = new Mock<IBookRepository>();
_bookService = new BookService(_bookRepositoryMock.Object);
}
}

Test Case 1: Test for Getting a Book by ID

This test checks if the GetBookByIdAsync method in BookService correctly retrieves a book from the repository. We mock the GetByIdAsync method to return a predefined book and verify that the service correctly returns this book.

  • Arrange: This is the setup phase where you prepare everything needed for the test, such as creating mock objects, initializing the system under test (SUT), and configuring any dependencies.
  • Act: This is the phase where you execute the action or method you are testing. It’s the actual operation that triggers the behavior you’re trying to verify.
  • Assert: In this phase, you verify that the expected outcome has occurred. You check the results of the action taken in the “Act” phase to ensure they match the expected behavior or values.
[Fact]
public async Task GetBookByIdAsync_WhenBookExists_ReturnsBook()
{
// Arrange
var bookId = 1;
var book = new Book { Id = bookId, Title = "Test Book", Author = "Test Author" };

_bookRepositoryMock.Setup(repo => repo.GetByIdAsync(bookId)).ReturnsAsync(book);

// Act
var result = await _bookService.GetBookByIdAsync(bookId);

// Assert
result.Should().BeEquivalentTo(book);
_bookRepositoryMock.Verify(repo => repo.GetByIdAsync(bookId), Times.Once);
}

Test Case 2: Test for Book Not Found Scenario

Here, we test the case where the book is not found. We mock the repository to return null and verify that the service throws a NotFoundException.

[Fact]
public async Task GetBookByIdAsync_WhenBookDoesNotExist_ThrowsNotFoundException()
{
// Arrange
var bookId = 1;

_bookRepositoryMock.Setup(repo => repo.GetByIdAsync(bookId)).ReturnsAsync((Book)null);

// Act & Assert
await Assert.ThrowsAsync<NotFoundException>(() => _bookService.GetBookByIdAsync(bookId));
}

Test Case 3: Test for Adding a Book

These tests ensure that the AddBookAsync method behaves correctly, throwing an exception if the book is null and correctly calling the repository’s AddAsync method when a valid book is provided.

[Fact]
public async Task AddBookAsync_WhenBookIsNull_ThrowsArgumentNullException()
{
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(() => _bookService.AddBookAsync(null));
}

[Fact]
public async Task AddBookAsync_WhenValidBook_AddsBookToRepository()
{
// Arrange
var book = new Book { Title = "New Book", Author = "New Author" };

_bookRepositoryMock.Setup(repo => repo.AddAsync(book)).Returns(Task.CompletedTask);

// Act
await _bookService.AddBookAsync(book);

// Assert
_bookRepositoryMock.Verify(repo => repo.AddAsync(book), Times.Once);
}

CI/CD with Azure DevOps for .NET

Once your tests are in place, the next step is to automate the build, testing, and deployment process using CI/CD. With Azure DevOps, you can set up a pipeline that automatically builds your .NET application, runs your unit tests, and deploys it to various environments.

Setting up CI/CD Pipeline in Azure DevOps:

By setting up a CI/CD pipeline, every change to your codebase is automatically built, tested, and deployed, improving the speed and reliability of your development lifecycle.

  1. Create a Pipeline: In Azure DevOps, go to the Pipelines section and create a new pipeline for your .NET application.
  2. Define Build Steps: You can define build steps using the YAML format. Here’s an example of a simple pipeline that builds a .NET Core application, runs unit tests, and publishes the output:
trigger:
- main

pool:
vmImage: 'windows-latest'

steps:
- task: UseDotNet@2
inputs:
packageType: 'sdk'
version: '6.x'
installationPath: $(Agent.ToolsDirectory)/dotnet

- task: Restore@2
inputs:
restoreSolution: '**/*.sln'

- task: Build@1
inputs:
solution: '**/*.sln'
msbuildArgs: '/p:Configuration=Release'

- task: Test@2
inputs:
testSelector: 'testAssemblies'
testAssemblyVer2: '**/*.dll'
searchFolder: '$(System.DefaultWorkingDirectory)'

- task: PublishBuildArtifacts@1
inputs:
PathtoPublish: '$(Build.ArtifactStagingDirectory)'
ArtifactName: 'drop'

Set up Deployment

You can extend the pipeline to deploy to your staging, UAT, or production environments. Here’s an example that deploys to Azure App Service:

- task: AzureWebApp@1
inputs:
azureSubscription: '<your-azure-subscription>'
appName: '<your-app-name>'
package: $(System.DefaultWorkingDirectory)/**/drop/*.zip

Conclusion

Testing and CI/CD are foundational to building high-quality .NET applications. Unit testing ensures that your application behaves as expected, even when working with complex data access patterns like the repository pattern. With tools like xUnit and Moq, you can isolate your service logic and verify that it interacts correctly with the repository. Meanwhile, Azure DevOps simplifies CI/CD by automating your build, test, and deployment processes, ensuring that your code is always production-ready.

By incorporating automated testing and a solid CI/CD pipeline, you not only enhance the reliability of your applications but also streamline your workflow, enabling you to deliver better software faster.