Introduction

Software testing is the art of measuring and maintaining software quality to ensure that the user expectations and requirements, business values, non-functional requirements (such as security, reliability and recoverability) and operational policies are all met. Testing is a team effort to accomplish the well-understood and agreed-upon minimum quality bar and definition of “done”.

TDD stands for Test Driven Development, and it’s a design process in software development. It relies on the repetition of a very short development cycle, and the requirements are turned into very specific test cases.

Figure: TDD Life Cycle

There are a couple of steps in the TDD process:

  1. Write a unit test that fails.
  2. Write enough code to make the test pass — at this step we don’t care about good code.
  3. Refactor our code from the previous step

TDD is a happening term in the industry these days, especially among those software development organizations that are practicing the agile development methodology. TDD is an evolutionary approach and mindset towards software development that enforces writing Unit Tests as we are coding the functionality or feature. TDD gives an opportunity to think as a “user of the code” instead of an “implementer of the code”. Eventually, TDD helps in better design, quality and test coverage.

 

Benefits of TDD

First of all, we get a better understanding of the actual code before we write it. This is one of the greatest benefits of TDD. When we write the test cases first, we think more clearly about the system requirements and more critically about the corner cases.

Also, when talking about dependencies, it’s important to mention that working with TDD lets us focus on the logic of our classes. This way we keep all the dependencies outside of our classes. It’s also important to mention that our code will run more safely since the logic will not have to handle difference dependencies such as database connections, file systems, and so on.

It’s also a safer way to refactor the code. When writing TDD there are tests for a certain piece of logic. When we refactor the code we might break something, but with this approach we know the tests will have our back.

When we use TDD, we also have a faster way to understand what the code does. When we start working on a piece of code that we are unfamiliar with, we can read the test cases of that piece of code and understand its purpose. Those tests are also the documentation for our code.

And finally, we can focus on building the smaller components in the best way and avoid the headache of the big picture. So how does this help? We’ll write one failing test, and focus solely on that to get it passing. It forces us to think about smaller chunks of functionality at a time rather than the application as a whole. Then we can incrementally build on a passing test, rather than trying to tackle the bigger picture from the get-go, which will probably result in more bugs.

 

Boundary Conditions

Boundary conditions are very critical to the success of any software code ever written. All our business validation/rules are actually a candidate for Boundary Unit Tests.

Identifying boundary conditions is very important for unit testing, such as:

  1. Empty or missing values (such as 0, “”, or null).
  2. Inappropriate values those are not realistic from a business point of view, such as a person’s age of -1 or 200 years or so.
  3. Date of birth is tomorrow’s date or a time in the future.
  4. Duplicates in lists that shouldn’t have duplicates.
  5. The password is the same as either First name or Last name
  6. Special characters or case related conditions.
  7. Formatting of data, for example name must be capitalized.
  8. Type of acceptable values in a field. For example name can’t hold a numeric and age can’t hold letters.
  9. Range is another critical thing to test and it’s often coded as business validation rules.

 

Error Conditions

Building a real-world application causes real-world errors at run-time and errors do happen. Hence, we should be able to test that our code handles all such errors, for example think of the following scenarios:

  1. Can’t handle DivideByZeroException
  2. Consider scenario of AccessDenied
  3. Don’t ignore NullReferenceExceptions
  4. Check for existence; FileNotFoundException, DirectoryNotFoundException and so on

 

Structure of TDD

An ideal unit test code is divided into the following three main sections:

  1. Arrange: Set up all conditions needed for testing (create any required objects, allocate any needed resources and so on).
  2. Act: Invoke the method to be tested with possible parameters or values.
  3. Assert: Verify that the tested method returns the output as expected.

 

Properties of good unit test

Good unit test consist of following key qualities

  1. It should be automated and repeatable.
  2. It should be easy to implement.
  3. Once it’s written, it should remain for future use.
  4. Anyone should be able to run it.
  5. It should run quickly.

 

System Under Test (SUT)

System Under Test (SUT) is the system that will be tested by Unit Tests for code accuracy and possible scenarios that might either break the functionality at runtime or not produce legitimate results.

Assume we are working on a Bank Application’s Business Logic and our code looks as in this:

namespace Bank.Savings
{
  public interface IAccount
  {
    double AccountBalance { get; set; }

    bool AccountStatus { get; set; }

    bool IsAccountActive(string accountNumber);

    double Deposit(string accountNumber, double amount);

    double Withdrawal(string accountNumber, double amount);

    bool ActivateAccount(string accountNumber, Roles userRole);
  }
}


using System;

namespace Bank.Savings
{
  public class Account : IAccount
  {
    public double AccountBalance { get; set; }

    public bool AccountStatus { get; set; }

    public bool IsAccountActive(string accountNumber)
    {
      return AccountStatus;
    }

    public double Deposit(string accountNumber, double amount)
    {
      return AccountBalance = AccountBalance + amount;
    }

    public double Withdrawal(string accountNumber, double amount)
    {
      return AccountBalance = AccountBalance - amount;
    }

    public bool ActivateAccount(string accountNumber, Roles userRole)
    {
      AccountStatus = true;
      return AccountStatus;
    }
  }
}

 

namespace Bank.Savings
{
  public enum Roles
  {
    Customer,
    Administrator
  }
}

 

This Bank Application should work properly, assuming all the right data and parameters are provided. But when we start thinking from a Test Driven Development (TDD) perspective and we put it under test then we start detecting the areas of further improvement and refactoring.

In the solution, we will add a new test project ‘Bank.Savings.Tests’. From the SUT we have, let’s focus on a piece of production code.

 

public bool IsAccountActive(string accountNumber)
{
   return AccountStatus;
}

To test this code, we add [TestMethod] TestAccountStatus_Active_Success() to our TDD suite.

    [TestMethod]
    public void TestAccountStatus_Active_Success()
    {
      var account = new Account();
      var accountNumber = "1234";

      Assert.IsTrue(account.IsAccountActive(accountNumber), "Failed. Account is not active.");
    }

 

We’ll run the test and observe its output.

 

1
Figure: TestAccountStatus_Active_Success () fails

The test fails, because we have not yet implemented the code. Let’s implement the IsAccountActive() in the code to make it pass.

New code additions are:

 

public bool IsAccountActive(string accountNumber)
{
      if (accountNumber != null)
        AccountStatus = true;
      else
        AccountStatus = false;
      return AccountStatus;
}

 

Now, let’s re-run the test and observe its output.

 

2
Figure: TestAccountStatus_Active_Success() passed

 

The test will pass. Now, let’s repeat it with more validation and business rules. What would be the response from the caller of the API if null is passed as an accountNumber? Suppose, API must throw an ArgumentException. To verify this, we will add new test method TestAccountStatus_ArgumentException_Success().

 

[TestMethod]
public void TestAccountStatus_ArgumentException_Success()
{
  var account = new Account();

  Assert.ThrowsException<ArgumentException>(() => account.IsAccountActive(null));
}

 

If we run the test and observe the result, we will get an error:

 

3
Figure: TestAccountStatus_ArgumentException_Success fails

 

Let’s refactor our development code to make the above test case pass. The new code additions are:

 

public bool IsAccountActive(string accountNumber)
    {
      if (accountNumber != null)
        AccountStatus = true;
      else
      {
        if(accountNumber==null)
          throw new ArgumentException("Account number can't be null");
        else
          AccountStatus = false;
      }
      return AccountStatus;
    }

 

Now if we re-run the test TestAccountStatus_ArgumentException_Success() and observe the results, it will pass.

 

Figure: TestAccountStatus_ArgumentException_Success()
Figure: TestAccountStatus_ArgumentException_Success() passed

 

Now let’s verify the behavior if we pass whitespace as an accountNumber and expect an ArgumentException is thrown. We will add following test code.

 

    [TestMethod]
    public void TestAccountStatus_AccountNumberWhiteSpace_ArgumentException_Success()
    {
      var account = new Account();

      Assert.ThrowsException<ArgumentException>(() => account.IsAccountActive(" "));
    }

 

We’ll run the test and we will see the result as shown in the image below.

 

Figure: TestAccountStatus_AccountNumberWhiteSpace_ArgumentException_Success() fails
Figure: TestAccountStatus_AccountNumberWhiteSpace_ArgumentException_Success() fails

 

Let’s refactor the code and it will look such as shown below. The new code additions are:

 

public bool IsAccountActive(string accountNumber)
    {
      if (accountNumber != null)
        AccountStatus = true;
      else
      {
        if(string.IsNullOrWhiteSpace(accountNumber))
          throw new ArgumentException("Account number can't be null or have whitespaces.");
        else
          AccountStatus = false;
      }
      return AccountStatus;
    }

 

Let’s re-run the test and observe the results.

 

Figure: TestAccountStatus_AccountNumberWhiteSpace_ArgumentException_Success() fails again
Figure: TestAccountStatus_AccountNumberWhiteSpace_ArgumentException_Success() fails again

 

The test still fails, though we added the right condition to verify whitespace. So what we discover after debugging is that the first condition accountNumber != null is true even if whitespace has been passed as the accountNumber. Hence, it is an opportunity to refactor the development code and re-run the tests. The refactored code is:

 

    public bool IsAccountActive(string accountNumber)
    {
      if (string.IsNullOrWhiteSpace(accountNumber))
        throw new ArgumentException("Account number can't be null or have whitespaces.");
      else
        AccountStatus = true;
      return AccountStatus;
    }

 

Now let’s re-run the test and observe the result. This time, the test will pass.

 

Figure: TestAccountStatus_AccountNumberWhiteSpace_ArgumentException_Success() passed
Figure: TestAccountStatus_AccountNumberWhiteSpace_ArgumentException_Success() passed

 

Now let’s move on to the ActivateAccount() method. In its first scenario, suppose an ArgumentException is thrown when accountNumber is either null or whitespace. We will write a test case as:

 

    [TestMethod]

    public void TestActivateAccount_NullOrWhiteSpace_ArgumentException()
    {
      var account = new Account();

      Assert.ThrowsException<ArgumentException>(() => account.ActivateAccount(null, Roles.Administrator));

      Assert.ThrowsException<ArgumentException>(() => account.ActivateAccount(" ", Roles.Administrator));

      Assert.ThrowsException<ArgumentException>(() => account.ActivateAccount(string.Empty, Roles.Administrator));
    }

 

Figure: TestActivateAccount_NullOrWhiteSpace_ArgumentException() fails
Figure: TestActivateAccount_NullOrWhiteSpace_ArgumentException() fails

 

When we’ll run the test, it will fail since the logic is yet to be implemented. In order to make this case pass, the new code is added:

 

public bool ActivateAccount(string accountNumber, Roles userRole)
    {
      if (string.IsNullOrWhiteSpace(accountNumber))
        throw new ArgumentException("Account number should be null or whitespace");

      AccountStatus = true;

      return AccountStatus;
    }

 

Let’s re-run the test TestActivateAccount_NullOrWhiteSpace_ArgumentException() and observe the result.

 

Figure: TestActivateAccount_NullOrWhiteSpace_ArgumentException() passed
Figure: TestActivateAccount_NullOrWhiteSpace_ArgumentException() passed

 

Now let’s verify the behavior when we try to activate account with Customer role. Suppose we expect UnauthorizedAccessException when we try to activate account with Customer’s role. We’ll add the following test case:

 

    [TestMethod]

    public void TestActivateAccount_CustomerActivation()
    {
      var account = new Account();

      string accountNumber = "123456789";

      Assert.ThrowsException<UnauthorizedAccessException>(() => account.ActivateAccount(accountNumber, Roles.Customer));
    }

 

Let’s run the test and observe the output.

 

Figure: TestActivateAccount_CustomerActivation() fails
Figure: TestActivateAccount_CustomerActivation() fails

 

The test fails since we have yet to implement logic for provided case. A code will be refactored as:

 

public bool ActivateAccount(string accountNumber, Roles userRole)
    {
      if (string.IsNullOrWhiteSpace(accountNumber))
        throw new ArgumentException("Account number should be null or whitespace");
      if (userRole == Roles.Customer)
        throw new UnauthorizedAccessException("You are not authorized");

      AccountStatus = true;

      return AccountStatus;
    }

 

If we re-run the test TestActivateAccount_CustomerActivation(), we will get the following output.

 

Figure: TestActivateAccount_CustomerActivation() passed
Figure: TestActivateAccount_CustomerActivation() passed

 

In the same way, after each test addition or refactoring session, we’ll need to re-run all the tests in test explorer to ensure that previously passing tests haven’t begun failing. Further implementation code can be seen in accessed as Savings.Tests and Savings.Account in pastebin. Or you can also access the code on Github.

 

Conclusion

Just like any other concepts or practice in software development, TDD takes practice. The more we use TDD the easier TDD becomes. We should always remember to keep our tests simple, easy to understand and easy to maintain. The goal of TDD is not to write a lot of complex code, it’s to write code and API’s that are easy to work with and maintain. TDD provides its best results when the code is constantly improved. It leads to more modularized, flexible and extensible code. That is due to the fact that the methodology requires that the developers think of the software in terms of small units that can be written and tested independently and integrated together later. This leads to smaller, more focused classes, looser coupling and cleaner interfaces.

 

References

Leave a Reply

Your email address will not be published. Required fields are marked *