All Articles

The Value of Writing Test in Software Development

The requirement for writing test cases varies between companies, and code review is sometimes used as a substitute. However, developers have a responsibility to write test cases as part of the software development process. Doing so reduces the risk of bugs occurring, cutting down financial losses.

In software development, there are two widely adopted practices to mitigate risk: code review and unit testing. However, the ratio of these practices varies depending on the company. Some companies fully embrace both practices, while others only utilize one or neither. It is important to understand the differences between these practices and recognize that while both are valuable, unit testing can be more effective in reducing risk. In fact, writing test cases is an essential step in software development that should never be overlooked. It helps to ensure that the software being developed is of high quality and meets the requirements of the stakeholders. Writing test cases also helps to identify issues early in the development process, which can save time and resources in the long run.

Another benefit of writing test cases is that it can help developers who have just joined the company to understand the code context and get up to speed quickly. When developers write test cases, they have to understand how the software is supposed to behave and what the requirements are. This can give them a better understanding of the codebase and how the different components of the software interact with each other.

Additionally, writing test cases can help developers become more confident when modifying the code. When developers modify code, there is always a risk of introducing bugs or unintended behavior. However, when developers have written comprehensive test cases, they can run these tests after making changes to ensure that the software is still behaving as expected. This can give developers the confidence to make changes to the code without fear of breaking the software.

Furthermore, having a comprehensive set of test cases can make it easier to onboard new developers. When new developers join the team, they can quickly understand how the software is supposed to behave and what the requirements are by reviewing the existing test cases. This can help new developers get up to speed faster and start contributing to the project sooner.

Software Testing

The four types of software testing are unit/component testing, integration testing, system testing, and acceptance testing. Each type of testing has a different focus and objective, and all are important for ensuring the quality of software. Here’s a brief overview of each type of testing:

  • Unit/Component Testing: This type of testing focuses on testing individual units or components of the software in isolation. The goal is to ensure that each unit or component works as expected and to catch bugs early in the development process. Unit testing is usually automated and can be performed by developers as they write the code.

  • Integration Testing: This type of testing focuses on testing how different units or components of the software work together. The goal is to ensure that the software as a whole works as expected and that there are no integration issues between different parts of the system. Integration testing can be performed manually or automated, and it usually takes place after unit testing.

  • System Testing: This type of testing focuses on testing the entire system, including all components and their interactions. The goal is to ensure that the software meets all requirements and works as expected in a real-world environment. System testing is usually performed manually and can include functional and non-functional testing.

  • Acceptance Testing: This type of testing focuses on testing the software from the perspective of the end user or customer. The goal is to ensure that the software meets all requirements and is ready for deployment. Acceptance testing is usually performed by the customer or end user, or a representative of the customer.

Of these four types of testing, unit testing is particularly useful and important because it catches bugs early in the development process when they are easier and cheaper to fix. By testing each unit or component in isolation, developers can ensure that their code works as expected and that any bugs are caught before they cause problems in the larger system. Unit tests also help to ensure that code changes do not introduce new bugs or regressions.

It is important to note that unit testing should not be confused with integration testing. While integration testing focuses on how different units or components of the system work together, unit testing focuses on testing each unit or component in isolation. Unit testing is usually automated and can be performed by developers as they write the code, while integration testing is usually performed after unit testing and may involve manual or automated testing.

Unit Testing

Unit testing focuses on the individual units of the software component. The usual approach to implementing unit tests involves following a pattern known as Arrange, Act, and Assert. This pattern involves three steps:

  1. Arrange: In this step, the test is set up by creating any necessary objects, defining input values, and configuring the environment in which the test will be run.

  2. Act: This step involves executing the code or function that is being tested, using the input values defined in the Arrange step.

  3. Assert: The final step is to compare the output of the function or code being tested with the expected output. If the output matches the expected output, the test passes. If it does not, the test fails, and the developer must revise the code.

Below are the example of how a unit test is written to test against an object:

export class User {
  constructor(public id: string, public emails: string[]) {
    this.id = id
    this.emails = emails
  }

  updateEmailAddress(index: number, updatedEmailAddress: string): void {
    if (index < 0 || index >= this.emails.length) {
      throw new Error('Invalid index');
    }

    this.emails[index] = updatedEmailAddress;
  }
}
import { User } from './user';

export class UserRepository {
  private users: User[] = [];

  save(user: User): void {
    const existingUserIndex = this.users.findIndex(u => u.id === user.id);
    if (existingUserIndex !== -1) {
      this.users.splice(existingUserIndex, 1, user);
    } else {
      this.users.push(user);
    }
  }

  findById(id: string): User | undefined {
    return this.users.find(u => u.id === id);
  }
}
import { User } from './user';
import { UserRepository } from './user-repository';

export class UserService {
  constructor(private userRepository: UserRepository) {}

  updateUserEmail(user: User, index: number, updatedEmailAddress: string): void {
    user.updateEmailAddress(index, updatedEmailAddress);
    this.userRepository.save(user);
  }
}
import { User } from './user';
import { UserRepository } from './user-repository';
import { UserService } from './user-service';

describe("UserService", () => {

  beforeEach(() => {
    jest.resetAllMocks();
  })

  describe("updateUserEmail", () => {
    // Unit test - test that updateUserEmail method works as expected
    it("should update user email address", async () => {
      // Arrange
      const user = new User("1", ["john.doe@example.com", "johndoe@example.com"]);
      jest.mock('./user-repository');
      const mockRepository = new UserRepository();

      const userService = new UserService(mockRepository);

      // Act
      await userService.updateUserEmail(user, 0, "new.john@newexample.com");

      // Assert
      expect(user.emailAddresses).toEqual(["new.john@newexample.com", "johndoe@example.com"]);
      expect(mockRepository.save).toHaveBeenCalledWith(user);
    });
  });
});

The UserService class is responsible for updating a user’s email address and storing the updated user object in the UserRepository. The UserRepository class is responsible for storing and retrieving user objects.

In the unit test, we use a mock UserRepository to isolate the test from external dependencies. We set up the mock repository with a save method utilises jest mock function. We then create a UserService instance and call the updateUserEmailAddress method with a user object, an index, and a new email address. Finally, we use assertions to ensure that the user object was updated correctly in the mock repository.

Mocking is useful during testing because it allows you to isolate your test from external dependencies. In this example, we don’t want the test to depend on a real database or file system, so we use a mock repository that verifies the user object from the input parameter instead. This makes the test faster, more reliable, and easier to set up and tear down. It also allows us to test the UserService class in isolation, without worrying about the behavior of the UserRepository class.

Isolating the behavior of the UserRepository in the unit test for UserService is important for several reasons. First, it allows us to focus specifically on the behavior of UserService without having to worry about the behavior of UserRepository. This makes it easier to understand the purpose and functionality of UserService in isolation. Second, it allows us to test the behavior of UserService even if UserRepository is not fully implemented or functioning properly. This can be useful in situations where we want to test UserService before the complete implementation of UserRepository is available. Isolating the behavior of UserRepository in the unit test ensures that any failures or errors in the test are specifically related to the behavior of UserService. This makes it easier to diagnose and fix problems in the system.

Integration Test

Integration testing focuses on the interaction between different software component. Using the example above, instead of mocking UserRepository we use the real UserRepository object as it would be running in the live environment.

import { UserService } from "./UserService";
import { UserRepository } from "./UserRepository";
import { User } from "./User";

describe("UserService", () => {
  describe("updateUserEmail", () => {
    // Unit test - test that updateUserEmail method works as expected
    it("should update user email address", async () => {
      ...
    });

    // Integration test - test that UserService and UserRepository work together as expected
    it("should update user email address in repository", async () => {
      // Arrange
      const user = new User("1", ["john.doe@example.com", "johndoe@example.com"]);
      const userRepository = new UserRepository();
      const userService = new UserService(userRepository);

      // Act
      await userService.updateUserEmail(user, 0, "new.john@newexample.com");
      const savedUser = await userRepository.findById(user.id);

      // Assert
      expect(savedUser?.emailAddresses).toEqual(["new.john@newexample.com", "johndoe@example.com"]);
    });
  });
});

In this example, we have two tests for the updateUserEmail method of the UserService. The first test is a unit test that tests the behavior of the method in isolation. The second test is an integration test that tests the behavior of the UserService and UserRepository working together. It uses a real UserRepository object to test that the method updates the user’s email addresses in the repository correctly.

By using a mock object for the unit test and a real object for the integration test, we can differentiate between the two types of testing and ensure that each type of test is testing the appropriate part of the system.

Code Review and Unit Testing

Code review and unit testing are two important practices in software development that can help reduce risk and improve the quality of the code. It involves a human reviewing code changes made by another human. The goal of code review is to catch potential issues, ensure consistency with coding standards and best practices, and identify opportunities for improvement.

Unit testing, on the other hand, involves automated tests that verify the behavior of individual units of code in isolation. The goal of unit testing is to identify defects in the code as early as possible, before they can cause problems in the larger system. While both code review and unit testing are valuable practices, there are several reasons why unit testing is more effective in reducing risk.

Firstly, unit tests can be run automatically as part of a continuous integration (CI) process, ensuring that they are run consistently and thoroughly with every code change. Code reviews, on the other hand, are typically performed manually and may not catch all issues. Secondly, unit tests can catch issues that may be missed in a code review. For example, a code review may catch issues related to coding standards and best practices, but may not catch issues related to edge cases or unexpected behavior. Finally, unit tests provide a safety net for developers as they make changes to the code. They can quickly identify any regressions or unintended consequences of their changes, allowing them to catch and fix problems before they become more serious.

However, this doesn’t mean that companies should neglect code review altogether. Code review can catch issues that may not be caught by automated tests, and can provide valuable feedback to developers. It is important to strike a balance between the two practices and ensure that both are given proper attention.

The adoption rate of unit testing and code review varies across different companies and industries. However, there have been several studies and surveys that provide insights into their adoption rates.

According to a survey conducted by GitLab in 2020, 85% of respondents reported that their organization conducts code reviews. However, the adoption rate of unit testing was lower, with only 60% of respondents reporting that their organization conducts unit testing Another survey conducted by SmartBear in 2021 found that 92% of respondents reported that their organization conducts some form of code review. However, the adoption rate of unit testing was slightly lower, with 84% of respondents reporting that their organization conducts unit testing. These surveys suggest that code review is more widely adopted than unit testing, although both practices are still widely used in software development. It is important to note that the adoption rate may vary depending on the industry, company size, and development methodology used.

Overall, it is encouraging to see that both code review and unit testing are widely used in software development, as they can help improve code quality and reduce risk. Companies should continue to prioritize these practices and ensure that they are given proper attention and resources.

Final Thought

While it is true that not all companies have the resources to fully adopt unit testing, it is important to recognize that the benefits of unit testing can still be realized by focusing on the most critical parts of the system. By identifying the most important functions or modules of the system and ensuring that they are fully covered by unit tests, companies can significantly reduce the risk of critical bugs or issues affecting their software especially with the combination of Pair Programming.

At the same time, it is important to continue conducting code reviews for the rest of the system. Code reviews can still catch potential issues or bugs, and they can also help ensure that the code is maintainable and adheres to best practices. One way to determine which parts of the system are critical and require unit testing is to conduct a risk assessment. This involves identifying the potential risks associated with different parts of the system, such as the likelihood of a bug occurring or the impact that a bug could have on the system. Functions or modules that are deemed to be high-risk should be prioritized for unit testing, while lower-risk areas could be reviewed instead.

By taking this approach, companies can get the best of both worlds by leveraging the benefits of both code review and unit testing, even if they don’t have the resources to fully adopt both practices across their entire codebase.

Published May 3, 2023