Keeping Tests Valuable: Avoid Problems In The Act Blocks

Keeping Tests Valuable: Avoid Problems In The Act Blocks

Unit testing is like a microscope that allows you to examine the smallest details of your code

The Arrange-Act-Assert (A-A-A) convention is widely used when creating unit tests to ensure organization and clarity. Probably most developers have worked with this convention. But even though it is a great convention, we need to be careful about how we write and manipulate each block. The purpose of this article is to remind you of some cautions that we must have in the SUT action section. We will explore some of the pitfalls that we can fall into and how to avoid them. This way we will make our tests more valuable for the software.

The purpose

The goal of the ACT (Action) block in unit tests is to execute the action or behavior that is being tested in the method, i.e. in this step we want to simulate the execution of the code under test. This part of the test is crucial because it aims to verify that the system behavior is according to what was defined, ensuring that the code is working correctly and according to the requirement specifications.

We can make a simple analogy. Think of a cake recipe. Let's assume that Arrange is the moment when we separate the ingredients needed for the preparation of the recipe. We can also compare Assert as being the moment when we finally taste the cake and make sure it is good. But what can we say about the act section? The Act would be the moment when we put our hands in the dough and prepare the cake, that is, we mix the ingredients, follow the steps of the recipe, and finally, transform the separate elements into a delicious cake.

The Act would be the moment when we put our hands in the dough and prepare the cake, that is, we mix the ingredients, follow the steps of the recipe!

The importance of this stage for a successful unit test is very big, like preparing and thinking exactly how to mix the ingredients. It is the central phase of a unit test. If we do not make the action step adequate, the tests will not be effective and we will not be able to trust the results obtained. Therefore, this part of the test is essential to ensure the validity of the tests.

We write a lot of tests, but sometimes we don't realize the risks we are running due to a lack of attention to both the arrange and action sections. Especially when passing inputs. Let's see some care we should take in the Act block as well as some examples in code.

Avoid Act section with more than one line

It is common to see in most tests just one method call, with the passing of values. But see the test below:

[Fact]
public void Withdrawal_succeeds_when_enough_funds()
{
// Arrange
var account = new BankAccount();
account.Deposit(1000);
var atm = new ATM();

// Act
bool success = atm.Withdraw(account, 500);
account.UpdateBalance(success, 500);

// Assert
Assert.True(success);
Assert.Equal(500, account.GetBalance());
}

After calling the Withdraw method of the ATM class, the bank account balance is updated using the UpdateBalance method. What is the problem with this test? The action section has two lines that seek to achieve a single goal, this can be difficult to read for the next developer who needs to analyze and change the code. Furthermore, these two calls in just one test demonstrate that the code design is suffering from a lack of encapsulation. If these actions are not encapsulated in a single method, inconsistencies can arise if the code calls only one of the methods, resulting in an invariant violation.

Invariant Violation:
When an invariant is violated, it means that the program has entered an inconsistent state, which can cause unexpected behavior or errors. Therefore, it is essential to ensure that invariants are enforced and maintained throughout the execution of the program to avoid such violations.

The problem gets even worse when we think about queries to the database, as they can generate corrupted data that becomes a significant problem that requires manual intervention to be solved. The act block must contain only one method action that is being tested, as this makes it easier to isolate and identify any problems or failures that may occur. When multiple actions are combined in the block of acts, it is not clear which action caused a specific failure, making it difficult to troubleshoot the problem.

So we see the problems that this can generate in our tests. The best way would be to correct the encapsulation of the class to prevent two important operations from causing corrupted or inaccurate data in the database. Both results must be achieved together, which means that there must be a single public method that does both. Furthermore, it would be important for the test to focus on a single SUT execution without having to execute two to achieve the expected behavior.

Avoid unexpected errors in Act section

I have seen these situations happen a few times, the problem is that we lost a few minutes of debugging to find what was causing an unexpected error in the test. Has it ever happened in your project? Having to look for a variable or list that is not filled out correctly that throws a NullReferenceException or even a System.InvalidOperationException. This costs time, we know that many times we do not have enough time for this. The worst thing is that many times these errors are not very descriptive and end up confusing us, if the test had failed in the Assert, it would be easier to detect or to start debugging. Let's see an example of how this can happen. I will use .NET (C#), but in other languages, this problem can appear in the same way.

public class FruitService
{
    private List<string> _fruits;

    public FruitService()
    {
        _fruits = new List<string>() { "banana", "apple", "grape" };
    }

    public string GetFruitStartingWithP()
    {
        var fruit = _fruits.First(f => f.StartsWith("P"));
        return fruit;
    }
}

Now see the test:

    [Fact]
    public void Given_FruitService_When_GetFruitStartingWithP_Then_Return_Pear()
    {
        // Arrange
        var fruitService = new FruitService();

        // Act
        var result = fruitService.GetFruitStartingWithP();

        // Assert
        Assert.Equal("pear", result);
    }

The test and code are pretty easy to understand, but what happens if this list does not have a word containing the first letter P ? Here is the error returned for this test:

So we have a not descriptive error. Now imagine this error occurring in a highly complex enterprise application. Where there are multiple classes with mocks, sometimes even a high degree of coupling or bad code design. Why is the Assert not failing for this test? The execution is interrupted earlier by a sloppy implementation. The First method used to find the first matching element is responsible for this error, see the description itself:

Interesting! So that this does not happen and the test arrives in Assert and fails properly there, we must use another overload method, FirstOrDefault:

    public string GetFruitStartingWithP()
    {
        var fruit = _fruits.FirstOrDefault(f => f.StartsWith("P"));
        return fruit;
    }

So now we have a method FirstOrDefault returns a null reference, and the test will fail in the assert as we expect:

The error in assert is much more descriptive, the method return is now null, so now we have to just check the root cause. This can save a lot of time because we are not walking around in the dark looking for errors that are not descriptive in day-to-day life we know that our unit tests involve very complex and critical business rules. I hope this little example has shown you the importance of thinking carefully about how your methods return and also how you are using the features of the programming language you are using. And this is something you should never ignore! Let's continue now with other examples, looking at incorrect inputs and logic that can occur in the Act block.

Incorrect logic and inputs

We know that incorrect logic when writing the test can generate false positives. But what does this have to do with the action section in a test? Everything! We know that the Arrange phase is the preparation of the input values, and yes, it is necessary to pay attention to this part as well. But the SUT is the one that is really on the test. So we need to be careful not to enter invalid values for it. It is important to understand well what kind of behavior we are testing and how we expect the return from this test. Let's see this in practice.

Suppose we have an IsPalindrome method that checks if a string is a palindrome (if it can be read both from left to right and right to left and still be the same string). Nice, we know that then the word level is a Palindrome. So let's write the code and then the test:

 public static bool IsPalindrome(string input)
    {
        if (string.IsNullOrEmpty(input))
            return false;

        int start = 0;
        int end = input.Length - 1;

        while (start < end)
        {
            if (input[start] != input[end])
                return false;

            start++;
            end--;
        }

        return true;
    }

Look closely at this code and see if you figure out your problem. I will paste here the unit test for this method:

    [Fact]
    public void IsPalindrome_ReturnsTrue_ForPalindromeString()
    {
        //Arrange
        string input = "level";
        // Act 
        var result = IsPalindrome(input);

        // Assert
        Assert.True(result);
    }

Apparently all right. If you execute this test, everything will run successfully. But what if the developer inserts that input below?

var result = IsPalindrome("LeveL");

The curious developer runs the test and succeeds again! Cool, then we can send the code to production and get rid of this task and pull another one because that is what your manager wants, right? Well after a few hours, a user complains that the word level is not recognized as a palindrome. The developer who wrote the feature is startled and thinks; "How is this possible? I even ran the unit tests!". However hypothetical this situation may be, it happens quite often. The problem here is that to the human eye, these variations of the same word below are the "same thing", and have the same meaning:

"Level", "LeveL", "leveL", "level" // differents

But to a machine, there are differences. Is the word written leveL the same as the string level for a human? To a non-programmer, it could be, but those who have programming knowledge know that programming languages can make distinctions between these two strings. So what happens if the input leveL is entered into the test? Take a look at the picture:

The test fails because it does not transform the strings to input.ToLower(). This indicates that there is a flaw in the conversion process of our implementation code, we should have transformed the input to lowercase before comparing the strings. We should never do this in the tests, it would only generate more false positives and the problem would not be solved. Unit testing proved that the Act section needs to be revised after the test is written and also after the test fails because the code being tested does not correctly convert all inputs entered! So we should go further in our testing and think clearly about what the Act execution block is going to do, we should always think:

  • Does passing this input make sense?

  • Would an input with an empty list make my Act return what I expect?

The incentive here is to think beyond the happy paths and not be satisfied with the few results generated. The business rule, in this case, does not care if the letters are capitalized, for the user the word leveL and level have the same meaning, regardless of whether they are capitalized or not, the same goes for other words. So this is why we ignore uppercase characters and when receiving the information the input is converted to a lowercase string. I'm going to leave the code refactored, and if you want to test it, you'll see that with the string leveL, now the test will pass!

public static bool IsPalindrome(string input)
    {
        if (string.IsNullOrEmpty(input))
            return false;

        input = input.ToLower(); // fix applied by the developer
        int start = 0;
        int end = input.Length - 1;

        while (start < end)
        {
            if (input[start] != input[end])
                return false;

            start++;
            end--;
        }

        return true;
    }

// Unit Test
 [Fact]
    public void IsPalindrome_ReturnsTrue_ForPalindromeString()
    {
        //Arrange
        string input = "leveL";
        // Act 
        var result = IsPalindrome(input);
        // Assert
        Assert.True(result);
    }

I hope this example has helped you visualize the importance of checking and thinking carefully about how to pass inputs to the Action block. In addition, it is always important to check that the logic of the code under test is correct

Tips to keep your Act block reliable!

We can understand while reading that the Act block is a fundamental step for our tests to remain reliable and accurate. So we can list some tips, which can help you daily:

  • Keep the Act simple and direct: The Act block should be clear and straightforward. Any unnecessary complexity can make the test difficult to understand and can lead to maintenance problems in the future.

  • Analyze what is being tested: Before or during the process of organizing the test, think about exactly what is being tested and the expected behavior. Check if the output generated in the test conforms to the expected behavior.

  • Avoid multiple lines in the second act of the test: Multiple action lines in a single test indicate that the code has encapsulation faults that can bring inconsistencies and generate inaccurate or even corrupted data. The test is also impaired and not fully reliable and can generate false positives.

  • Provide only required inputs: Ensure that only required inputs are provided. Check that each input, whether string or any other type, has the necessary logic for the SUT.

  • Define clear input and output parameters: Define clear input and output parameters for your Act block. This will make it easier to test and ensure that the block works as expected.

  • Test all possible inputs: Test your Act block with all possible inputs, including edge cases and error conditions. This will help ensure that the block works correctly in all scenarios.

Conclusion

In conclusion, it is crucial to pay attention to the Act block in a test, as it plays an important role in obtaining accurate and reliable results. By avoiding problems in this block, such as ensuring the correct method call order and handling exceptions properly, developers can keep their tests valuable and reliable. Otherwise, inconsistent and unreliable results can make it difficult to identify and fix problems in the code. Following best practices, we can use testing as an effective tool to maintain code quality as well as software quality.

Thank you for reading to the end and if you enjoyed it please share it with others. See you next post! ๐Ÿ˜‰

ย