Our Back to School Sale is now Live on Dometrain. Keep reading the blog to find out how to save up to 30% on any course.

You have often heard me say that testing your project is super important. Well, in this blog post, I will introduce you to the basics of unit testing in .NET using powerful libraries like XUnit and NSubstitute, which are my preferred tools.

What is Unit Testing?

Unit testing is a software testing technique where individual components or units of a software application are tested in isolation from the rest of the application. The purpose of unit testing is to validate that each unit of the software performs as expected, ensuring that the smallest pieces of functionality are correct and reliable.

Unit testing helps identify bugs early in the development process, facilitates refactoring, and ensures that your code behaves as expected. A solid suite of unit tests acts as a safety net, giving developers confidence that changes won’t introduce new issues.

Why Unit Testing is Important?

  • Improves Code Quality: Helps catch bugs early in the development cycle.
  • Facilitates Refactoring: Safely change code with confidence that existing functionality remains unaffected.
  • Speeds Up the Development Process: Automated tests reduce manual testing time.
  • Documentation: Serves as a form of documentation for how the system is supposed to work.
  • Design Feedback: Can highlight design issues in the codebase when units are difficult to test in isolation.
  • Quick execution time: Can easily be used in CI/CD pipelines since they typically run in a very small amount of time.
  • Easy to debug: Debugging components is really easy since you do not need to run the whole application.

A unit test typically follows the Arrange-Act-Assert (AAA) pattern:

  • Arrange: Set up the test conditions.
  • Act: Execute the unit of code being tested.
  • Assert: Verify the outcome is as expected.

Examples
Let's see some examples using XUnit and NSubstitute to illustrate unit testing in action.

Suppose you have a Calculator class with a method Add:

public class Calculator
{
    public int Add(int a, int b)
    {
        return a + b;
    }
}

A corresponding unit test might look like this:

public class CalculatorTests
{
    [Fact]
    public void Add_ShouldReturnSumOfTwoNumbers()
    {
        // Arrange
        var calculator = new Calculator();

        // Act
        var result = calculator.Add(2, 3);

        // Assert
        Assert.Equal(5, result);
    }
}

In this example, we use XUnit’s Fact attribute to define a test method. The Assert.Equal method checks that the result of Add(2, 3) is 5.

Now, you are familiar with the Fact attribute, which is great for simple, straightforward tests that don't require any input parameters. But xUnit offers more than just Fact—it also provides the Theory attribute, which is incredibly powerful for running tests with multiple sets of data. While Fact is used for tests that have no parameters, Theory comes into play when you want to run the same test logic but with different input values. Instead of writing multiple test methods for each set of inputs, you can use Theory to keep your test code DRY (Don't Repeat Yourself). InlineData is an attribute that allows you to specify the input values directly within your test method. Each InlineData attribute defines a different set of parameters to pass to the test method. xUnit will execute the test once for each InlineData entry, giving you a way to easily test multiple scenarios with just one method.

public class CalculatorTests
{
    [Theory]
    [InlineData(1, 2, 3)]
    [InlineData(5, 5, 10)]
    [InlineData(0, 0, 0)]
    [InlineData(-1, -1, -2)]
    public void Add_ShouldReturnCorrectSum(int a, int b, int expectedResult)
    {
        // Arrange
        var calculator = new Calculator();

        // Act
        var result = calculator.Add(a, b);

        // Assert
        Assert.Equal(expectedResult, result);
    }
}

Another cool thing you can use is the output for tests. For example:

public class CalculatorTests
{
    private readonly ITestOutputHelper _output;

    public CalculatorTests(ITestOutputHelper output)
    {
        _output = output;
    }

    [Fact]
    public void Add_ShouldReturnSumOfTwoNumbers()
    {
        // Arrange
        var calculator = new Calculator();
        _output.WriteLine("Calculator instance created.");

        // Act
        var result = calculator.Add(2, 3);
        _output.WriteLine($"Result of Add(2, 3): {result}");

        // Assert
        Assert.Equal(5, result);
    }
}

This output can be really useful when trying to diagnose test failures. In tests that have a lot of moving parts, you can make sure that specific properties are set correctly.

Mocking with NSubstitute

Imagine we are working with a notification system. The NotificationService class is responsible for sending email notifications through an IEmailSender interface. We want to ensure that the NotificationService behaves correctly under various conditions, and we'll use NSubstitute to mock the IEmailSender.

public interface IEmailSender
{
    void Send(string to, string message);
}

public class NotificationService
{
    private readonly IEmailSender _emailSender;

    public NotificationService(IEmailSender emailSender)
    {
        _emailSender = emailSender;
    }

    public void Notify(string emailAddress, string message)
    {
        if (string.IsNullOrWhiteSpace(emailAddress))
            throw new ArgumentException("Email address cannot be empty", nameof(emailAddress));

        if (string.IsNullOrWhiteSpace(message))
            throw new ArgumentException("Message cannot be empty", nameof(message));

        _emailSender.Send(emailAddress, message);
    }
}

To test the NotificationService, we should cover a couple of cases:

  1. Basic Behavior: Does the Notify method correctly send an email when passing the correct parameters?
  2. Input Validation: Does it throw exceptions when given invalid input?
using NSubstitute;
using Xunit;

public class NotificationServiceTests
{
    [Fact]
    public void Notify_SendsEmailWithCorrectParameters()
    {
        // Arrange
        var emailSender = Substitute.For<IEmailSender>();
        var service = new NotificationService(emailSender);
        string email = "test@example.com";
        string message = "Hello, World!";

        // Act
        service.Notify(email, message);

        // Assert
        emailSender.Received(1).Send(email, message);
    }

    [Fact]
    public void Notify_ThrowsException_WhenEmailIsEmpty()
    {
        // Arrange
        var emailSender = Substitute.For<IEmailSender>();
        var service = new NotificationService(emailSender);
        string emptyEmail = "";
        string message = "Hello, World!";

        // Act & Assert
        var exception = Assert.Throws<ArgumentException>(() => service.Notify(emptyEmail, message));
        Assert.Equal("Email address cannot be empty (Parameter 'emailAddress')", exception.Message);
    }

    [Fact]
    public void Notify_ThrowsException_WhenMessageIsEmpty()
    {
        // Arrange
        var emailSender = Substitute.For<IEmailSender>();
        var service = new NotificationService(emailSender);
        string email = "test@example.com";
        string emptyMessage = "";

        // Act & Assert
        var exception = Assert.Throws<ArgumentException>(() => service.Notify(email, emptyMessage));
        Assert.Equal("Message cannot be empty (Parameter 'message')", exception.Message);
    }
}

Test Driven Development

I'm not going to go too deep into Test-Driven Development (TDD) in this post—that's a topic for another day. But, to give you a quick overview, when it comes to writing unit tests, there are basically two approaches: the traditional method and TDD. Let's break them down with some simple examples.

UnitTestingNoTDD.png

In this diagram, you can see the traditional approach: first, you write your code, and then you create tests based on that code. This way, your tests are designed to check that your existing code works as expected.

UnitTestingTDD.png

TDD, on the other hand, flips things around. Here, you start by writing tests before you even write any code. These tests are based on what you want your code to do. Then, you write just enough code to pass those tests. The idea is that the tests guide your development, helping you build exactly what you need.

Best Practices/Common Mistakes

  1. Write Independent Tests: Each test should be independent of others, ensuring that one test’s failure doesn’t affect others.
  2. Use Descriptive Names: Test method names should clearly indicate the behaviour being tested, making it easier to understand what’s broken when a test fails. In my opinion, you should be able to understand the business of the code just by looking at the tests.
  3. Avoid Over-Mocking: Only mock what’s necessary to isolate the unit of work.
  4. Keep Tests Fast: Unit tests should be fast enough to run frequently.
  5. Add runs in you CI/CD pipelines: You should include the tests in your CI/CD to make sure nothing broke without people noticing.

Conclusion

Unit testing can significantly improve the quality and maintainability of your code. By writing isolated, expressive, and fast tests, you can sleep well at night.

Now I want to hear from you. Are you writing unit tests in your projects? What challenges have you faced with them? Have you seen benefits from using unit tests? Share your thoughts in the comments, and as always, keep coding!

Next steps

If you're interested in taking your knowledge of Unit Testing in C# a step further, you can always check our "From Zero to Hero: Unit testing in C#" course on Dometrain.

From-Zero-to-Hero-Unit-testing-for-Csharp-Developers.jpg

Not only that, but we currently have our Back to School sale on Dometrain, so for the entire month of September, you can use code BTS30 to get 30% off any of our courses, BTS15 to get 15% off any of our already discounted bundles, and BTS20 to get 20% off an annual Dometrain Pro subscription.

backtoschool (1).png

With 41 courses, 17 authors and 35.000 students, you can't go wrong with Dometrain. Check our courses out here.