DEV Community

Caio Ragazzi
Caio Ragazzi

Posted on

Comprehensive Unit Testing: A Line-by-Line Approach

Hello Dev's,

I hope you are having a wonderful day.

In this article, I'd like to share my recent approach to unit testing. I believe this method is highly effective in testing my classes and ensuring comprehensive coverage of my business rules. Additionally, I hope this article can provide you with valuable insights or even an opportunity to exchange tips and ideas for improving our testing practices.

Before we dive into the specifics, let me introduce the tools that will be used in this example. (Perhaps in the future, I can create a separate article dedicated to explaining why I prefer these tools):


Method being tested

Let's take a simple example of a method that adds an entity to a database. Typically, this method might look something like this:

public async Task<BeerDtoResponse> AddBeer(BeerDtoRequest beerDtoRequest)
{
    var beer = _mapper.Map<Beer>(beerDtoRequest);
    beer.Id = Guid.NewGuid();

    await _beerRepository.Insert(beer);

    return _mapper.Map<BeerDtoResponse>(beer);
}
Enter fullscreen mode Exit fullscreen mode

A straightforward method that maps a request DTO to an entity, adds a GUID to the ID, inserts it into the database, and returns a mapped DTO.

Objective

My primary objective is to comprehensively test every line of the method, a practice I affectionately call "Shielding the Method." Why do I adhere to this principle? Quite simply, if I've written a particular line of code, it's because it serves a purpose, and I want to ensure that it continues to function as intended. Furthermore, whenever we introduce new features or make changes, our tests must evolve in sync with the codebase to maintain its integrity.

How do I do it?

To achieve this level of comprehensive testing, I typically begin by creating a base class that houses commonly used methods. I leverage the power of AutoMock and AutoFixture to streamline this process. Here's a glimpse of what this base class might look like:

public class BaseTest
{
    private readonly Fixture _fixture = new();
    protected readonly AutoMocker _mocker = new();

    protected T CreateFixture<T>()
    {
        return _fixture.Create<T>();
    }

    protected IEnumerable<T> CreateManyFixture<T>(int count)
    {
        return _fixture.CreateMany<T>();
    }
}
Enter fullscreen mode Exit fullscreen mode

A straightforward class that provides you with an instance of AutoMocker and methods to create fixtures.

Testing

Alright, then my initial approach involves creating a test method that successfully runs the AddBeer method without encountering any errors. This requires us to set up our test class correctly. Here's the reasoning behind this approach:

Method steps

We need to create the service instance, generate the DTO that the method expects, set up mocking for the mapping from DTO to entity, mock the repository, and establish mocking for the mapping from entity to DTO. Let's proceed with these steps:

Configuring the test class

1) Creating the service instance:

Our AddBeer method is part of a service. Therefore, our first step is to instantiate this service for testing purposes. To streamline this process and maintain simplicity and speed, I rely on a library called AutoMock. AutoMock helps by creating an instance of the service and resolving all its dependencies. I initialize this service instance in the constructor and assign it to a global variable within the test class. This ensures that the service can be accessed by other test methods. Here's how it's implemented:

private readonly BeerService _beerService;

public BeerServiceTest()
{
    _beerService = _mocker.CreateInstance<BeerService>();
}
Enter fullscreen mode Exit fullscreen mode

By using the CreateInstance method, we can specify the type for which we want to create an instance. Just like magic, this method provides us with the desired instance, complete with all of its resolved dependencies.

2) Creating the DTO:

As this DTO may be utilized by multiple test methods, I typically construct it in the constructor and assign it to a global variable within the test class. To create this DTO efficiently, I rely on AutoFixture, which simplifies the process:

private readonly BeerService _beerService;
private readonly BeerDtoRequest _beerDtoRequest;

public BeerServiceTest()
{
    _beerService = _mocker.CreateInstance<BeerService>();
    _beerDtoRequest = CreateFixture<BeerDtoRequest>();
}
Enter fullscreen mode Exit fullscreen mode

In this context, we are making use of a helper method from the base class, which utilizes AutoFixture to generate instances of the required classes. We achieve this by invoking the AutoFixture method Create.

3) Mocking the first map (DTO to entity)

To mock the mapping method, we also leverage AutoMock. This can be accomplished by invoking the GetMock method and then configuring the Setup and Return as follows:

_beer = new Beer
{
    Name = "Beer",
    Brand = "Brand",
    Score = 1,
    User = "User"
};
_mocker.GetMock<IMapper>().Setup(m => m.Map<Beer>(_beerDtoRequest)).Returns(_beer);
Enter fullscreen mode Exit fullscreen mode

In this step, it's crucial to create the beer entity and assign it to a global variable within our test class. This entity will be used in future assertions, making it an essential part of our testing setup.

4) Mocking the repository

We will apply the same concept here as in step 3, utilizing AutoMock and configuring the Setup. The only difference is that we don't need to configure the Return because our repository method Insert does not return anything:

_mocker.GetMock<IBeerRepository>().Setup(m => m.Insert(_beer));
Enter fullscreen mode Exit fullscreen mode

5) Mocking the other map (entity to DTO)

Similar to step 3, we'll once again employ AutoMock, configuring the Setup and Return. Of course, we also need to create an instance of our response, for which we can utilize AutoFixture and assign the result to a global variable:

_beerDtoResponse = CreateFixture<BeerDtoResponse>();
_mocker.GetMock<IMapper>().Setup(m => m.Map<BeerDtoResponse>(_beer)).Returns(_beerDtoResponse);
Enter fullscreen mode Exit fullscreen mode

Here's how the constructor and variables will look like in their final form:

private readonly BeerService _beerService;
private readonly BeerDtoRequest _beerDtoRequest;
private readonly BeerDtoResponse _beerDtoResponse;
private readonly Beer _beer;

public BeerServiceTest()
{
    _beerService = _mocker.CreateInstance<BeerService>();
    _beerDtoRequest = CreateFixture<BeerDtoRequest>();

    _beer = new Beer
    {
        Name = "Beer",
        Brand = "Brand",
        Score = 1,
        User = "User"
    };

    _mocker.GetMock<IMapper>().Setup(m => m.Map<Beer>(_beerDtoRequest)).Returns(_beer);
    _mocker.GetMock<IBeerRepository>().Setup(m => m.Insert(_beer));

    _beerDtoResponse = CreateFixture<BeerDtoResponse>();
    _mocker.GetMock<IMapper>().Setup(m => m.Map<BeerDtoResponse>(_beer)).Returns(_beerDtoResponse);
}
Enter fullscreen mode Exit fullscreen mode

That's it, now we have our method ready to be used. Lets start writing our test methods.

First test "happy path"

As mentioned earlier, for the first test method, I typically ensure that no exceptions are thrown, often referred to as navigating the "happy path." The method is structured like this:

[Fact]
public async Task ItShouldRunWithoutErrors()
{
    Func<Task> act = () => _beerService.AddBeer(_beerDtoRequest);
    await act.Should().NotThrowAsync();
}
Enter fullscreen mode Exit fullscreen mode

Let's break down what's happening in this code:

  • We start by invoking our AddBeer method using the service instance we created with AutoMocker, and we assign the result to a Func.
  • We utilize FluentAssertion to verify that our function executes without throwing any errors. This is achieved by chaining the methods Should() and NotThrowAsync().

When we run dotnet test from the command line, we can observe that our first test passes successfully.

Test passes

Indeed, we've made significant progress. We now have a callable method that is functional. Our next task is to ensure that every line of our AddBeer method remains intact. If any changes are made, our test methods should break and alert the developer that corrective actions are required. This will help us maintain code integrity and ensure that future modifications are thoroughly tested.

Testing line by line

For this, let's go line by line in the AddBeer method:

1) First Line:

var beer = _mapper.Map<Beer>(beerDtoRequest);
Enter fullscreen mode Exit fullscreen mode

To ensure that the first line, where we map the DTO request to an entity, is consistently used with AutoMapper and the same DTO request, we can employ AutoMock. We can use the Verify method and configure it to assert that the method is called with specific parameters and even specify how many times it should be executed. The test method would be structured like this:

[Fact]
public async Task ItShouldMapBeerDtoRequestToBeer()
{
    await _beerService.AddBeer(_beerDtoRequest);

    _mocker.GetMock<IMapper>().Verify(m => m.Map<Beer>(_beerDtoRequest), Times.Once);
}
Enter fullscreen mode Exit fullscreen mode

In the Verify method, the first parameter specifies how we expect the method to be called, and the second parameter indicates how many times it should be executed. This allows us to precisely define the expected behavior of the method being tested.

-- Here's the interesting part --

Imagine someone decides to refactor the AddBeer method and opts to manually perform the mapping instead of using AutoMapper, something like this (for the sake of example):

method refactor

The AddBeer method might still work after the refactoring, but the reason we initially chose to use AutoMapper may be due to specific profile configurations or other factors that could potentially alter the method's behavior. However, with our test in place, let's observe what happens when we execute it:

Image description

The test result clearly indicates that the AutoMap.Map method was expected to be called, but it wasn't, which is indeed valuable feedback for maintaining the integrity of our code.

Let's continue with the examination of the AddBeer method.

2) Second Line:

beer.Id = Guid.NewGuid();
Enter fullscreen mode Exit fullscreen mode

In the line where we assign a GUID to our mapped entity's ID property, we can create a test method to ensure that this line is always executed. Here's an example of how such a test method could be structured:

[Fact]
public async Task BeerIdShouldHaveAGUIDAssigned()
{
    await _beerService.AddBeer(_beerDtoRequest);

    _beer.Id.Should().NotBeEmpty();
}
Enter fullscreen mode Exit fullscreen mode

In this test method, we begin by invoking our AddBeer method and subsequently verify that _beer.Id is not empty using the FluentAssert methods Should() and NotBeEmpty().

This approach serves as an effective safeguard. If, during a refactoring process, someone forgets to assign the GUID, our test method will promptly raise an issue, ensuring that the critical aspect of our code is not overlooked.

removing guid
test error

3) Third line:

await _beerRepository.Insert(beer);
Enter fullscreen mode Exit fullscreen mode

For testing the line where we call the repository and insert our entity into the database, we can utilize the AutoMock library to verify that the Insert method is invoked with the desired parameter and the correct number of times. Here's an example of how such a test method could be structured:

[Fact]
public async Task ItShouldInserBeer()
{
    await _beerService.AddBeer(_beerDtoRequest);

    _mocker.GetMock<IBeerRepository>().Verify(m => m.Insert(_beer), Times.Once);
}
Enter fullscreen mode Exit fullscreen mode

If someone accidentally removes this line from our AddBeer method, our test will promptly identify the omission and trigger an exception. This acts as a safety measure against unintentional code alterations or deletions.

remove line

test error

4) Fourth and last line

This line provides an opportunity to create multiple tests. We can use it to verify:

  • Whether the mapping is executed correctly.
  • If the return type matches our expected type.
  • The contents of the DTO being returned.

By segmenting these aspects into separate tests, we can thoroughly assess the functionality of this line in our AddBeer method.

[Fact]
public async Task ItShoulMapBeerDtoResponseToBeer()
{
    await _beerService.AddBeer(_beerDtoRequest);

    _mocker.GetMock<IMapper>().Verify(m => m.Map<BeerDtoResponse>(_beer), Times.Once);
}
Enter fullscreen mode Exit fullscreen mode
[Fact]
public async Task ItShouldReturnTypeBeerDtoResponse()
{
    var result = await _beerService.AddBeer(_beerDtoRequest);

    result.Should().BeOfType<BeerDtoResponse>();
}
Enter fullscreen mode Exit fullscreen mode
[Fact]
public async Task ItShouldReturnBeerDtoResponse()
{
    var result = await _beerService.AddBeer(_beerDtoRequest);

    result.Should().Be(_beerDtoResponse);
}
Enter fullscreen mode Exit fullscreen mode

For these tests:

  • To ensure that the mapping is executed, we employ the AutoMock Verify method as we did previously, validating that the Map method is called once.
  • For type validation, we use FluentAssertion's BeOfType method to confirm that the return type matches our expected type.
  • To verify that the returned beer matches our expectations, we use FluentAssertion's Be method.

Conclusion:

When I approach testing a method, I follow a systematic approach where I create one test method for each line of the method being tested, adhering to the Single Responsibility Principle (SRP). Additionally, I often create one test class for each method I want to test. For instance, if a service class contains four methods (representing simple CRUD operations), I'll likely have four test classes, each dedicated to one method.

I usually start my tests by creating a basic test that runs the method under test and ensures it doesn't throw any errors. Afterward, I meticulously test each line of the method, as we've explored earlier.

When it comes to naming my test methods, I aim for maximum specificity. I don't worry about the length of the name; instead, I want it to clearly explain what the method is testing, and I stick to testing just one thing per method.

As you can see, we were able to create seven test methods for a straightforward AddBeer function, which aligns perfectly with the goal of unit testing. The aim here is to have as many tests as possible for a single unit, ensuring comprehensive coverage and robust validation.

I hope this approach proves helpful to you in your testing efforts. If you have any insights or feedback to share, please feel free to do so.

Wishing you a fantastic day!

Top comments (2)

Collapse
 
yogini16 profile image
yogini16

I really liked the post. Great step by step explanation.
Sometimes, it is useful to comment in code for Arrange, Act and Assert.
I personally prefer it, when we have mode objects to mock and multiple assert in a test

Collapse
 
caioragazzi profile image
Caio Ragazzi

Thanks @yogini16
Thank you also for the tip for organizing the method by arrange, act, and assert!