HCN DEV

한국어로 보기

Introduction to Unit Testing

Introduction to Unit Testing feature image

1. What is Unit Testing?

In software development, testing is the act of verifying that a particular functionality works correctly. Among these tests, Unit Testing focuses on testing a specific unit of code. The definition of what constitutes a unit can vary between programming languages and individuals. From an object-oriented perspective, a unit can be a single class, while from a procedural perspective, it can be a single function. It could also be a collection of classes, a collection of functions, or a mix of classes and functions. Regardless of how a unit is defined, Unit Testing shares the following characteristics:

  1. Unit Tests focus on low-level code, testing small parts of the overall software.
  2. Unit Tests are typically written by developers using standard testing tools.
  3. Unit Tests are fast compared to other types of tests.
  4. The definition of a unit can vary from person to person:
    • In an object-oriented context, it might be a single class.
    • In a procedural context, it might be a single function.
    • Sometimes, a set of classes or functions together can be considered a unit.

Solitary vs Sociable

In Unit Testing, there are cases where a test can influence the results of other tests if it uses actual objects. For example, if two tests share external resources like a database or file system, the first test could alter the resource’s state, affecting the results of the second test.

In such situations, you can use objects defined for testing purposes (known as “Test Doubles”) to address the problem. Tests designed to use these testing objects are called “Solitary Tests.” Utilizing Test Doubles can eliminate external resource access during testing. However, if multiple tests use the same external resource and do not perform write operations on it, sharing the resource among tests might be acceptable. Additionally, if the external resource is sufficiently stable and fast, there might be no need to restrict access to it. Tests that run without removing dependencies between units are called “Sociable Tests.”

  • Solitary Tests: Tests that are designed to run without influencing other units.
  • Sociable Tests: Tests that depend on other units for their behavior.

2. The Purpose of Unit Testing

At the surface, Unit Testing involves writing tests for specific units of code and verifying their behavior. However, every unit, whether an object or a function, has a purpose and utility when it’s defined. Therefore, Unit Testing can be considered effective when it tests whether the unit behaves correctly according to its purpose. This purpose becomes especially clear when Unit Testing is applied to test “Use Cases.” Let’s briefly look at what a “Use Case” is before diving deeper.

Use Case

A use case is a list of actions or event steps typically defining the interactions between a role (known in the Unified Modeling Language (UML) as an actor) and a system to achieve a goal. The actor can be a human or other external system.

A “Use Case” represents various scenarios within software that occur to achieve specific requirements. In clean architecture, a “Use Case” is defined by three key components:

  1. The input provided by the user.
  2. The output displayed to the user.
  3. A description of the steps involved in generating the output.

Let’s consider an example:

Use Case: Convert account information obtained through HTTP requests into a usable format in the app.

- Input: Data from the URL.
- Output: Result<Success(Model), Error>
- Steps:
    1. Access the URL through an HTTP API call.
    2. Retrieve the response data in an appropriate format from the URL.
    3. Parse the response data.
    4. If the response is successful, map the data to a model object.
    5. If the response fails, return an error.

This example represents a common scenario where a client app accesses server information via an HTTP API request and needs to test whether the unit responsible for converting the HTTP response into a usable format works correctly.

The purpose of Unit Testing becomes evident when testing Use Cases, as it focuses on verifying whether the unit behaves correctly to fulfill its Use Case. Clean architecture emphasizes that Unit Testing should be possible for Use Cases, as stated:

- If the architecture puts use cases first and then keeps the framework at arm's length, you should be able to do unit tests for your use cases without invoking the framework.
- Thus, when designing the system and the tests, you need to make sure you can test the business rules without going through the GUI.
- Use cases describe application-specific business rules.
  • Source: Clean Architecture

3. Testing Targets in Unit Testing

In Unit Testing, each Test Suite should clearly define what it is testing. In this context, the primary unit being tested is referred to as the “System Under Test” or “SUT.” Additionally, the SUT can have dependencies on other units, which are referred to as “Depended On Components” or “DOC.”

Testing-oriented people like to use terms like object-under-test or system-under-test to name such a thing. Either term is an ugly mouthful to say, but as it’s a widely accepted term I’ll hold my nose and use it. Following Meszaros I’ll use System Under Test, or rather the abbreviation SUT.

Test Isolation and Test Double

When testing the SUT, there are cases where the DOC becomes problematic. This is especially true when the DOC is dependent on external resources, making testing challenging. For example, imagine testing a vending machine’s change calculation feature. The vending machine behaves like a typical one but has an added feature: it can update drink prices remotely. In other words, the vending machine’s owner can remotely change the prices of the drinks.

protocol Beverage {
    var price: Int { get }
}
struct BeverageImpl: Beverage {
    let price: Int
}

struct VendingMachine {
    private var onSaleBeverages: [Beverage]
    private var selectedBeverages: [Beverage] = []
    private var userInput: Int

    mutating func select(beverage: Beverage) {
        selectedBeverages.append(beverage)
    }

    func change(userInput: Int) -> Int {
        let totalPrices = selectedBeverages.reduce(0, { $1.price })
        return userInput - totalPrices
    }
}

In this scenario, we want to test whether the vending machine’s change calculation is correct. That is, we want to verify if the change(userInput:) method in the above code works correctly. However, there is a significant problem: the prices of the drinks can be changed remotely via the server. The result of the change(userInput:) method depends on the prices, which could be altered remotely without changing the compiled binary of the vending machine. In other words, the change result is subject to the external factor of the drink prices on the server.

In such situations, Unit Test authors need to choose a testing approach based on the circumstances. The problem mentioned above becomes an issue only when drink prices change frequently. If prices are relatively stable, this may not be a significant problem. However, if prices change frequently or even occasionally, how can we handle it?

Test Double

A solution in such cases is to use a “Test Double.” The term “Test Double” refers to the replacement of production objects for testing purposes. It’s a generic term for cases where you substitute a real object with a test-specific version to control and verify its behavior during testing.

In the vending machine example mentioned earlier, we want to isolate the impact of server price changes on our tests. This impact is removed by using a “Test Double” object (BeverageStub) to represent the price information (Beverage) in our tests. This way, we can test the change calculation method without being affected by server price changes. The price of the Test Double is set directly within the test.

struct BeverageStub: Beverage {
    var price: Int
}

In this way, during testing, we replace the DOC causing external environmental changes with a “Test Double” to ensure that the SUT is not influenced by external factors. This process is referred to as “Test Isolation,” where the DOC is replaced by a “Test Double” to make the SUT impervious to external environmental changes.

Note: It’s common for test authors to accidentally create meaningless tests by changing the SUT with a “Test Double” during testing. To avoid this, clearly define the SUT and decide which DOC to replace with a “Test Double” based on the specific testing requirements.

4. Typical Unit Testing Process

Unit Testing typically follows a 4-step process: setup, exercise, verify, and teardown. Here’s what each step involves:

Four Phase Test

Note: Unit Testing phases are sometimes expressed as “given, when, then,” which incorporates a behavior-driven perspective but aligns closely with the standard process.

1. Setup Phase

In the setup phase, you configure the environment for testing. This is often referred to as creating a “Test Fixture.” The key is to set up the state required for testing the SUT. During this phase, you might create instances, prepare input data, and, in the case of using mocks, set up expectations. When using mocks, you need to record the expected behavior (method calls) during setup.

2. Exercise Phase

In the exercise phase, you perform the specific action or behavior that you want to test in your software. This is where you trigger the SUT to execute. This phase is about changing the state of the SUT explicitly in a way that you can verify.

3. Verify Phase

In the verify phase, you check whether the test produced the expected results (State Verification). If you’re using mocks, this is where you verify whether the methods were called according to the expectations you set up during the setup phase (Behavior Verification).

4. Teardown Phase

In the teardown phase, you reset the environment back to its state before the test.

References