Vinicius Quinafelex Alves

🌐Ler em português

[C#] Introduction to unit testing

Unit testing is a method of writing automated tests to ensure a segment of code is producing the expected results. It can be used regardless of development process, and is at the core of test-driven development.

A unit test should not rely on external resources, such as databases, APIs or hard drive files - by definition, a test that validates multiple parts working together is called integration test.

Structure

An unit test can be expressed in three parts, also known as triple A:
- Arrange: Initialize objects, resources and variables required to execute the test
- Act: Execute the function tested
- Assert: Verify if the execution produced the expected results

While there are many situations and approaches involving the writing of unit tests, below are some basic approaches implemented using the NUnit library.

Example 1 - Testing the output

Executing a method should return an output consistent with the inputs. For example, an extension method StandardDeviation should calculate the standard deviation of a set of values.

[Test]
public void StandardDeviation_CorrectValue()
{
    // Arrange
    var values = new int[] { 1, 1, 1, 2 };
    
    // Act
    var result = values.StandardDeviation();

    // Assert
    Assert.AreEqual(0.5, result);
}

The test written used simple decimal numbers, but for other scenarios, applying a float point tolerance might be necessary.

Example 2 - Testing the state

Executing a method can change a state. For example, a class DistinctList has the ability to add values, discarding any duplicated value, and every successful insert changes the element count.

[TestCase(new int[] { }, 0)]
[TestCase(new int[] { 0 }, 1)]
[TestCase(new int[] { 0, 0 }, 1)]
[TestCase(new int[] { 0, 1 }, 2)]
public void DistinctList_AddDuplicated_ChangesCount(int[] values, int expectedCount)
{
    // Arrange
    var list = new DistinctList<int>();
    
    // Act
    foreach(var value in values)
        list.Add(value);

    // Assert
    var count = list.Count();
    Assert.AreEqual(expectedCount, count);
}

This test checks the state on different scenarios: when there are no inserts, single inserts, duplicated inserts and non-duplicated inserts.

Example 3 - Testing method call

Certain functionalities should only be triggered under specific conditions. For example, an AuthenticationService should only send an e-mail if the user has an e-mail address.

[TestCase(null, 0)]
[TestCase("test@test.com", 1)]
public void DistinctList_AddDuplicated_ChangesCount(string? email, int emailsSent)
{
    // Arrange
    var mockService = new EmailServiceCount();
    var user = new User(email);

    IEmailService emailService = mockService;
    var authService = new AuthenticationService(emailService);

    // Act
    authService.Approve(user);

    // Assert
    Assert.AreEqual(emailsSent, mockService.EmailsSent);
}

For this scenario, we mock the EmailService, implementing the Send method to count how many times it was called, without actually trying to send an e-mail. Mocking can be done manually, like demonstrated below, or using a mocking library such as moq.

public class EmailServiceCount : IEmailService
{
    public int EmailsSent { get; private set; } = 0;

    public void Send(MailMessage email)
    {
        EmailsSent++;
    }
}

Creation and maintenance

Automated test libraries are usually free, but it takes time and effort from developers to write and maintain the tests implemented on a software.

However, implementing it usually pays off by detecting bugs earlier, ensuring proper implementation of new features, exposing errors when changing existing features, and making a software overall more predictable and reliable.

Automated testing is not a feature that has to replace human-testing or cover 100% of the code base. Implementing tests on the core features might be enough to reap it's benefits and optimize the quality assurance of the software.

In contrast with human-testing, automated testing can be rerun quickly at any time, making part of the testing process faster, cheaper, and enabling constant validation inside a continuous delivery process.

Code testability

Unit test is one of the cheapest forms of automated test, but while most functions can be automatically tested in some way, some coding styles are not fit for unit testing and can make it hard, costly or plainly impossible, such as:

- Functions that uses global, externally unaccessible or unpredictable variables
- Functions that directly communicates with external resources, like a database or an API
- Functions that implement too many business logics or contains a lot of alternate execution paths

Fortunately, most code can be refactored to increase it's testability and enable the use of unit tests. Code with high testatibility generates smaller and more granular functions, making it easier to understand, reuse, extend and modify.