Enhancing Application Stability with Unit Testing in .NET

Rafael Araujo de Lima
7 min readNov 7, 2024

--

Source: thecodebuzz.com

During the development of a corporate application, I encountered several unexpected results and code breaks at runtime. Through multiple meticulous debugging sessions, I identified recurring issues, all stemming from a lack of specific return validations and proper handling, especially concerning data retrieval from the database.

The testing process was carried out as follows: code was developed, the application was executed in a local environment, basic use case tests were conducted, and upon positive test results, the code was committed and pushed to the repository. From there, deployment to a testing environment occurred automatically. This testing environment allows users to access and validate the changes.

Notice that this approach, aside from being time-consuming, leaves many gaps for unvalidated cases, which are likely to cause application breakdowns. With this in mind, I decided to implement unit tests for the repositories. To accomplish this, I chose to use xUnit, a widely-used framework in .NET applications for testing.

Structure

Following best practices, I created a separate project from the main one but with dependencies on it, such as Entities, Repositories, etc. This helps maintain clean code and adheres to development best practices, such as SOLID principles.

Since I work with multiple repositories that all have a common RepositoryBase, I created a RepositoryTestBase, which initializes the current context, defines the DbSet at runtime, and provides access to the specific repository of the entity being tested.

public class RepositoryTestsBase<TRepository, TEntity>
where TRepository : IRepositoryBase<TEntity>
where TEntity : class
{
protected readonly ApplicationDbContext _context;
protected readonly DbSet<TEntity> _dbSet;
protected readonly TRepository? _repository;

protected RepositoryTestsBase(IRepositoryFactory<TRepository> factory)
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase($"TestDatabase_{Guid.NewGuid()}")
.Options;

_context = new ApplicationDbContext(options);
_dbSet = _context.Set<TEntity>();
_repository = factory.Create(_context);
_context.Database.EnsureDeleted();
_context.Database.EnsureCreated();
}

protected void RestartInMemoryDatabase()
{
_context.Database.EnsureDeleted();
_context.Database.EnsureCreated();
}
}

With this setup, all specific test classes for each repository entity inherit from RepositoryTestBase, which is responsible for instantiating the context and the DbSet, as well as creating an in-memory database for testing purposes. This enables code reuse, maintainability, and separation of responsibilities.

Here’s an example implementation of tests:

public class NeighborhoodRepository: RepositoryBase<Neighborhood>, INeighborhoodRepository
{
private readonly DbSet<Neighborhood> _dbSet;

public NeighborhoodRepository(ApplicationDbContext context) : base(context)
{
_dbSet = context.Set<Neighborhood>();
}

public async Task<string?> GetNameByNeighborhoodIdAsync(int id)
{
string? name = await _dbSet
.Where(n => n.Id == id)
.Select(n => n.Name)
.SingleOrDefaultAsync();

return name;
}
}
public class NeighborhoodRepositoryTests : RepositoryTestsBase<INeighborhoodRepository, Neighborhood>
{
public NeighborhoodRepositoryTests() : base(new NeighborhoodRepositoryFactory())
{
// Arrange
NeighborhoodMock.ConfigureDbSetNeighborhoodMock(_context);
}

[Fact]
public async Task GetNameByNeighborhoodIdAsync_ShouldReturnNull_WhenNoNeighborhoodWithGivenIdExist()
{
// Act
string name = await _repository!.GetNameByNeighborhoodIdAsync(NeighborhoodMock.NeighborhoodListMockCount + 1);

// Assert
name.Should().BeNull("because it should return null when the neighborhood is not found");
}
}

Notice that the repository inherits from RepositoryBase, a base repository that contains the main CRUD operations (Create, Read, Update, Delete), which are common for all entities. This practice, again following development best practices, allows for code reuse and maintainability.

Test Structure

For a clear understanding of the test, the method name should be self-explanatory; in other words, we usually follow patterns that clarify what the test is validating with the following signature: MethodName_ExpectedResult_ExecutionCondition()

Example:

GetNameByNeighborhoodIdAsync_ShouldReturnNull_WhenNoNeighborhoodWithGivenIdExists()

  • Tested Method: GetNameByNeighborhoodIdAsync
  • Expected Result: ShouldReturnNull
  • Execution Condition: WhenNoNeighborhoodWithGivenIdExists

This means that the test simulates a condition where an ID of a neighborhood that doesn’t exist in the database is provided. In this case, the return of this method should be null. If the method returns something other than null during testing, then the test fails.

Attributes

For tests in xUnit, we have a few attributes used on methods to define the test:

  • [Fact]: the method is a standalone test, which doesn’t rely on external parameters. In other words, it is a FACT.
  • [Theory]: the method depends on external parameters and is used to run multiple times with different data sets. It is a THEORY.

A fact means that it has already been validated and should result in what was defined. A theory is something that needs to be tested in various ways to be validated. There are other attributes that are used along with these, like [InlineData] or [Collection]. We will cover these on another post.

Internal Structure of a Test

The test is divided into three parts: Arrange, Act, and Assert.

  • Arrange: defines the data that will be used in the test for validation.
  • Act: the action to be executed, calling the method to be tested and receiving the result.
  • Assert: validates the result. Here, it’s determined whether the test passed or failed.

Notice that there is no Arrange section in this test. This is because the arrangement was already done in the test class constructor.

public NeighborhoodRepositoryTests() : base(new NeighborhoodRepositoryFactory())
{
// Arrange
NeighborhoodMock.ConfigureDbSetNeighborhoodMock(_context);
}

I use a Mock class that inserts data into the in-memory database created in RepositoryTestsBase.

public static class NeighborhoodMock
{
public const int NeighborhoodListMockCount = 6;

public static List<Neighborhood> NeighborhoodListMock()
{
return
[
new Neighborhood { Id = 1, CityId = 1, CreationDate = DateTime.Parse("01/01/2020"), Name = "Center" },
new Neighborhood { Id = 2, CityId = 1, CreationDate = DateTime.Parse("01/01/2020"), Name = "North" },
new Neighborhood { Id = 3, CityId = 2, CreationDate = DateTime.Parse("01/01/2020"), Name = "Rural" },
new Neighborhood { Id = 4, CityId = 3, CreationDate = DateTime.Parse("01/01/2020"), Name = "West" },
new Neighborhood { Id = 5, CityId = 1, CreationDate = DateTime.Parse("01/01/2020"), Name = "South" },
new Neighborhood { Id = 6, CityId = 2, CreationDate = DateTime.Parse("01/01/2020"), Name = "Center" }
];
}

public static void ConfigureEmptyDbSetNeighborhoodMock(ApplicationDbContext context)
{
context.Database.EnsureDeleted();
context.Database.EnsureCreated();
}

public static void ConfigurarDbSetNeighborhoodMock(ApplicationDbContext context)
{
var data = NeighborhoodListMock();

context.Set<Neighborhood>().AddRange(data);
context.SaveChanges();
}
}

However, the arrangement can be done within the test method if necessary, as is the case in other methods, where additional data is needed for validation. After that, the Act is called to execute the method being tested, and we receive the result.

// Act
var result = await _repository!.GetNameByNeighborhoodIdAsync(NeighborhoodMock.NeighborhoodListMockCount + 1);

Here I pass to the method an ID that I know does not exist, since my Mock only has 6 values; I pass an 6 + 1 (7), which would be an ID beyond what actually exists.

So, what should the value of the result be? Exactly, null. According to the test signature, this method call should return null.

Now, in Assert, we validate the result using the FluentAssertions framework, which allows for intuitive validations.

// Assert
result.Should().BeNull("because the neighborhood with the given ID does not exist in the repository");
}

We can read the requested command very fluidly, almost like a sentence: “result should be null because the neighborhood with the given ID does not exist in the repository.”

The Should() method indicates that a validation will be executed, so we call the expected result, in this case, BeNull(). If result is not null, the test will fail and throw the specified message. We can make multiple validations on result; it does not have to be just one.

Test Window in Visual Studio 2022 — Before Test

Notice that in Visual Studio 2022, when you add the [Fact] attribute, the IDE recognizes it and automatically adds it to the built-in test queue within Visual Studio, centralizing and simplifying the testing process. You can run the tests here and validate them immediately.

Test Window in Visual Studio 2022 — After Test

Why Testing?

But if we know what the return should be, why create tests that validate something we already know?

Because in robust applications with several developers, or even just a few, this functionality is not always validated by the developer. They will simply develop a new feature or fix a bug, and may not pay attention to this method and its return. The test will do this for us. If the test fails, it means that the developed functionality violates a business rule or application flow and must be checked. It is not necessary to change the base method that returns the desired value but rather the new functionality that caused the test to fail.

This way, we ensure that the application follows business rules, flow rules, and data consistency.

Moreover, tests can be implemented in the CI/CD pipeline. During a Pull Request, we can configure Automated Actions that run the tests before merging with a specific Branch. If any test fails, the merge is not performed, and the PR is canceled, reporting the error. This prevents code that could break the application from being deployed without proper validation.

Conclusion

Incorporating unit tests into the repository layers allows for early identification of potential issues in CRUD operations and other funcionalities. This approach ensures robust validation, mitigates risks in higher environments, and saves time by addressing failures promptly. By adopting xUnit and a structured test architecture, we gain code reusability and adherence to development principles, resulting in a more stable and maintainable application.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

--

--

Rafael Araujo de Lima
Rafael Araujo de Lima

Written by Rafael Araujo de Lima

A Senior Software Engineer with over 10 years of experience, specializing in software development with a focus on .NET Core, ASP.NET Core, C#.

No responses yet

Write a response