[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
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:
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.