6 Key Principles to Elevate Testability and Maintainability in C#

Testable code stands as a cornerstone in the realm of quality software development. Crafting code that can be effortlessly verified for correctness through automated testing is not just a skill, but an art. In this blog post, we will embark on a journey through six fundamental principles that serve as the pillars of writing testable and maintainable code in C#. Whether you are an experienced software engineer or taking your first steps in the C# landscape, these principles will equip you with the tools needed to sculpt your code into a masterpiece of quality and reliability.

1. Single Responsibility Principle (SRP): Each class or method should have a single responsibility, meaning it should only have one reason to change. If a class or method does too many things, it becomes harder to test because each test must consider the different paths through the code.

2. Use Dependency Injection: Dependency injection is a technique where an object's dependencies (the other objects it needs to do its job) are provided to it, rather than creating them itself. This allows the dependencies to be swapped out with fake or mock objects in tests, making the code easier to test. For example, you could inject a database service into your class, and replace it with a mock database service in your tests.

public class OrderService
{
    private readonly IOrderDatabase _database;

    public OrderService(IOrderDatabase database)
    {
        _database = database;
    }

    public void PlaceOrder(OrderedParallelQuery order)
    {
        // ... some logic ...
        _database.AddOrder(order);
    }

    // ... rest of class ...
}

[TestClass]
public class OrderServiceTests
{
    [TestMethod]
    public void PlaceOrder_OrderIsValid_AddsOrderToDatabase()
    {
        // Arrange
        var mockDatabase = new Mock<IOrderDatabase>();
        var orderService = new OrderService(mockDatabase.Object);
        var order = new Order { /* initialize order here */ };

        // Act
        orderService.PlaceOrder(order);

        // Assert
        mockDatabase.Verify(db => db.AddOrder(order), Times.Once, "Order should be added to database once");
    }
}

 

3. Use Interfaces and Abstractions: Relying on concrete classes makes your code more tightly coupled and harder to test. By depending on abstractions (like interfaces or abstract classes), you can easily substitute real implementations with mock ones for testing.

4. Avoid Static and Singleton Classes: Static methods and singleton classes cannot be substituted with mock implementations, making your code harder to test. They also hold state between tests, leading to tests that are not independent.

Static and Singleton classes can hinder testability by preventing substitution with mock implementations, making testing more difficult and relying on real-world dependencies. Additionally, static methods and singleton classes can hold state between tests, leading to interdependent tests and potential inconsistencies.

While static and singleton classes should usually be avoided, there are cases where using static classes is appropriate. This includes scenarios such as defining utility functions or extension methods, which encapsulate reusable pure functions or extend the behavior of existing types without modifying their source code. In these cases, static classes can provide organization and improve code readability.

For more explicit examples on when using static methods see the last part: 6. Write Pure Functions When Possible

5. Favor Composition Over Inheritance: Composition refers to building complex objects by combining simpler ones, while inheritance describes a relationship between parent and child classes. Composition is more flexible and easier to test because you can replace parts of the composed object with mocks.
This example highlights the benefits of using composition for achieving better testable code:

// Composed object that uses composition instead of inheritance
public class ComposedObject : ISimpleObject
{
    private readonly ISimpleObject _simpleObject;

    public ComposedObject(ISimpleObject simpleObject)
    {
        _simpleObject = simpleObject;
    }

    public void PerformAction()
    {
        // Additional logic can be added here
        Console.WriteLine("Performing action in ComposedObject");

        // Delegating the action to the composed object
        _simpleObject.PerformAction();
    }
}

[TestClass]
public class ComposedObjectTests
{
    [TestMethod]
    public void PerformAction_Should_Call_PerformAction_Method_On_SimpleObject()
    {
        // Arrange
        var mockSimpleObject = new Mock<ISimpleObject>();
        var composedObject = new ComposedObject(mockSimpleObject.Object);

        // Act
        composedObject.PerformAction();

        // Assert
        mockSimpleObject.Verify(m => m.PerformAction(), Times.Once);
    }
}

 

By favoring composition over inheritance, the code becomes more flexible and easier to test. The example demonstrates how the ComposedObject class is constructed by injecting dependencies through its constructor, allowing for easy substitution of real implementations with mock objects during testing. This decoupling of dependencies enables isolated testing, focusing solely on the behavior of the ComposedObject without being tightly coupled to the implementation details of its dependencies. The ability to inject and replace dependencies with mocks or stubs provides greater control over the testing environment and leads to more reliable and maintainable tests. Embracing composition enhances testability and promotes modular and testable code.

6. Write Pure Functions When Possible: A pure function's output is solely determined by its input, and it does not have any side effects (like changing global variables). This makes it easy to test, because you just need to check the output for a given input.

Take this example where the method “StoreAverageOfPrimes” is extremely hard to test:

 

 
    public void StoreAverageOfPrimes(int number)
    {
        List<int> primeNumbers = new List<int>();

        using (SqlConnection connection = new SqlConnection(_connectionString))
        {
            connection.Open();

            for (int i = 2; i <= number; i++)
            {
                bool isPrime = true;

                for (int j = 2; j < i; j++)
                {
                    if (i % j == 0)
                    {
                        isPrime = false;
                        break;
                    }
                }

                if (isPrime)
                {
                    primeNumbers.Add(i);

                    SqlCommand command = new SqlCommand("INSERT INTO PrimeNumbers (Value) VALUES (@Value)", connection);
                    command.Parameters.AddWithValue("@Value", i);
                    command.ExecuteNonQuery();
                }
            }

            SqlCommand command = new SqlCommand("INSERT INTO Results (Value) VALUES (@Value)", connection);
            command.Parameters.AddWithValue("@Value", primeNumbers.Average());
            command.ExecuteNonQuery();
        }
    }

 

This code suffers from the same challenges when it comes to testing the calculation logic in isolation. The code tightly couples the calculation logic with the database access code, making it difficult to write focused unit tests. The lack of separation and abstraction hinders code reusability and maintainability.

In the refactored code here under, the StoreAverageOfPrimes method has been modified to separate the calculation logic from the data access code:

 
public static List<int> GetPrimeNumbers(int number)
    {
        List<int> primeNumbers = new List<int>();

        for (int i = 2; i <= number; i++)
        {
            if (IsPrime(i))
            {
                primeNumbers.Add(i);
            }
        }

        return primeNumbers;
    }

    public static bool IsPrime(int number)
    {
        if (number < 2)
            return false;

        for (int i = 2; i <= Math.Sqrt(number); i++)
        {
            if (number % i == 0)
                return false;
        }

        return true;
    }

    public void StoreAverageOfPrimes(int number)
    {
        List<int> primeNumbers = GetPrimeNumbers(number);
        double average = primeNumbers.Average();

        using (SqlConnection connection = new SqlConnection(_connectionString))
        {
            connection.Open();

            StorePrimeNumbers(primeNumbers, connection);
            StoreResult(average, connection);
        }
    }

    private void StorePrimeNumbers(List<int> primeNumbers, SqlConnection connection)
    {
        foreach (int number in primeNumbers)
        {
            SqlCommand command = new SqlCommand("INSERT INTO PrimeNumbers (Value) VALUES (@Value)", connection);
            command.Parameters.AddWithValue("@Value", number);
            command.ExecuteNonQuery();
        }
    }

    private void StoreResult(double result, SqlConnection connection)
    {
        SqlCommand command = new SqlCommand("INSERT INTO Results (Value) VALUES (@Value)", connection);
        command.Parameters.AddWithValue("@Value", result);
        command.ExecuteNonQuery();
    }

 

In this updated code, the pure functions GetPrimeNumbers and IsPrime have been made static. By making them static, they can be accessed directly without an instance of the CalculationService class.

The static nature of these functions allows them to be more easily testable and promotes code reusability, as they can be used independently without requiring an instance of the class.

When writing testable code, it is important to consider the nature of the functions you are working with. Pure functions, which have the characteristic of producing the same output for a given set of inputs and having no side effects, are particularly beneficial for testability.

In the context of testability, pure functions can be made static to further enhance their usability. By making pure functions static, they can be accessed and used directly without the need to create an instance of the class containing them. This eliminates the dependency on the object's state, making the functions more independent and self-contained.

The static nature of pure functions simplifies the testing process. Since they rely only on the provided input and produce a deterministic output, you can easily write focused unit tests for these functions without the need to set up complex object states or manage external dependencies. You can simply pass different inputs to the functions and verify their outputs against expected results.

In contrast, functions with side effects, such as those interacting with databases or modifying global variables, are more difficult to test due to their reliance on external resources and potential interference with the test environment. These functions often require complex setup and teardown procedures to isolate and restore the system state, which can complicate the testing process.

By separating pure functions and making them static, you create modular and testable units of code that are easier to verify in isolation. This promotes code maintainability, reusability, and testability, allowing you to write more effective and reliable tests for your applications.

 

Add comment