Let's break away from static methods to write unit tests # 2 DI (Dependency Injection)

Overview

In the previous Let's break away from static methods to write unit tests # 1 Principle / Wrap method, we explained the following two points.

  1. Do not make the method whose result changes depending on the execution environment static
  2. Existing code that violates the principle can be modified by dividing the steps to reduce the range of influence. --For example, you can solve it by wrapping the method

In this article, I will introduce a method called DI (Dependency Injection) as another method for modifying existing code, using the case of calling static methods of other classes as an example.

Note that this article does not mention DI container frameworks such as the Spring Framework.

problem

I can't write unit tests well for methods like LegacyClient # execute () below.

In the process of assigning the values of the variables host and port in the method, the instance method of the GlobalControl class is accessed, but it requires access to the database. Therefore, an exception will occur when executed from an inaccessible environment.

LegacyClient.java


public class LegacyClient {
    public void execute() {
        //Here is the code to be tested

        //Code in question
        String host = GlobalControl.getInstance().get("host", "localhost");
        int port = GlobalControl.getInstance().getInt("port", 8080);

        //Here is the code to be tested
    }
}

GlobalControl.java


public final class GlobalControl {
    private static final GlobalControl instance = new GlobalControl();

    public static GlobalControl getInstance() {
        return instance;
    }

    public String get(String key, String defaultValue) {
        //Processing that accesses the database
    }

    public int getInt(String key, int defaultValue) {
        //Processing that accesses the database
    }
}

Wrap method has drawbacks

In the previous article, I showed you how to wrap a method and solve it. The method can be applied in this case as well.

However, this method requires the creation of multiple wrap methods, which makes the code redundant.

Need to create multiple wrap methods


import com.google.common.annotations.VisibleForTesting;

public class LegacyClient {
    public void execute() {
        //Here is the code to be tested

        //Wrapped the method in question
        String host = get("host", "localhost");
        int port = getInt("port", 8080);

        //Here is the code to be tested
    }

    //Wrap method to overwrite in test class 1
    @VisibleForTesting
    String get(String key, String defaultValue) {
        return GlobalControl.getInstance().get(key, defaultValue);
    }

    //Wrap method overwriting with test class 2
    @VisibleForTesting
    int getInt(String key, int defaultValue) {
        return GlobalControl.getInstance().getInt(key, defaultValue);
    }
}

DI solution

Therefore, this time we will try to solve it by DI.

Just like last time, let's divide the commit and fix it step by step.

Step 0: Prepare a failing unit test

Unit tests that fail


public class LegacyClientTest {
    @Test
    public void test() {
        new LegacyClient().execute();
    }
}

Step 1: Extract the interface of the class in question

Now that the unit tests are ready, we'll start the actual fix.

Extract the instance method declared in the GlobalControl class in question (using the refactoring function of the IDE, etc.) to the interface.

Interface extracted from GlobalControl class


public interface GlobalControlHolder {
    public String get(String key, String defaultValue);
    public int getInt(String key, int defaultValue);
}

Give the extracted interface


public final class GlobalControl implements GlobalControlHolder {
    private static final GlobalControl instance = new GlobalControl();

    public static GlobalControl getInstance() {
        return instance;
    }

    @Override
    public String get(String key, String defaultValue) {
        //Processing that accesses the database
    }

    @Override
    public int getInt(String key, int defaultValue) {
        //Processing that accesses the database
    }
}

Step 2: Change static method invocations when instantiating

GlobalControl Make sure to get the instance at the time of instance creation (constructor) instead of getting it in the method. Also, the member variables at this time should be declared in the interface GlobalControlHolder extracted earlier.

Then, in the execute method, rewrite it so that it refers to the member variable globalControl.

LegacyClient.java


public class LegacyClient {
    //Move the instance from local to a member variable and keep it
    private final GlobalControlHolder globalControl;

    public LegacyClient() {
        this.globalControl = GlobalControl.getInstance();
    }

    public void execute() {
        //Here is the code to be tested

        //Member variables"globalControl"Rewritten to refer to
        String host = globalControl.get("host", "localhost");
        int port = globalControl.getInt("port", 8080);

        //Here is the code to be tested
    }
}

Step 3: Add a constructor for DI

The constructor created in step 2 had a GlobalControlHolder instance in the constructor. In addition to that, add another constructor that takes GlobalControlHolder as an argument.

LegacyClient.java


public class LegacyClient {
    private final GlobalControlHolder globalControl;

    public LegacyClient() {
        this.globalControl = GlobalControl.getInstance();
    }

    //Constructor for DI
    public LegacyClient(GlobalControlHolder globalControl) {
        this.globalControl = globalControl;
    }

    public void execute() {
        //Here is the code to be tested

        String host = globalControl.get("host", "localhost");
        int port = globalControl.getInt("port", 8080);

        //Here is the code to be tested
    }
}

This allows the caller to pass a GlobalControl instance.

Injecting a dependent module (this time GlobalControl) from the caller using the constructor is called ** constructor injection **.

Step 4: Inject dependencies in your test code

In the test code, create a Mock class that implements the GlobalControlHolder interface, and make it new and pass it to the constructor of LegacyClient.

Mock class for test code


public class MockGlobalControl implements GlobalControlHolder {
    @Override
    public String get(String key, String defaultValue) {
        //Returns the default value
        return defaultValue;
    }
    @Override
    public int getInt(String key, int defaultValue) {
        //Returns the default value
        return defaultValue;
    }
}

Modified test code


public class LegacyClientTest {
    @Test
    public void test() {
        //Inject Mock classes for test code
        new LegacyClient(new MockGlobalControl()).execute();
    }
}

This allows unit tests to replace dependent modules with implementations that do not access the database.

Summary

DI (Dependency Injection) is effective as a solution when calling a static method of another class.

Also, as in the previous time, you can write unit tests while limiting the range of influence of refactoring by modifying the steps separately.

Recommended Posts

Let's break away from static methods to write unit tests # 2 DI (Dependency Injection)
Let's break away from static methods to write unit tests # 2 DI (Dependency Injection)
Let's write how to make API with SpringBoot + Docker from 0
How to set Dependency Injection (DI) for Spring Boot
Let's write how to make API with SpringBoot + Docker from 0