跳转至

21 DI Container(9):怎样重构测试代码?

你好,我是徐昊。今天我们继续使用TDD的方式实现注入依赖容器。

回顾代码与任务列表

到目前为止,我们的代码是这样的:

ContextConfig.java:

package geektime.tdd.di;

import java.util.*;
import static java.util.List.of;

public class ContextConfig {
    private Map<Class<?>, ComponentProvider<?>> providers = new HashMap<>();

    public <Type> void bind(Class<Type> type, Type instance) {
        providers.put(type, new ComponentProvider<Type>() {
            @Override
            public Type get(Context context) {
                return instance;
            }
            @Override
            public List<Class<?>> getDependencies() {
                return of();
            }
        });
    }

    public <Type, Implementation extends Type>
    void bind(Class<Type> type, Class<Implementation> implementation) {
        providers.put(type, new ConstructorInjectionProvider<>(implementation));
    }

    public Context getContext() {
        providers.keySet().forEach(component -> checkDependencies(component, new Stack<>()));
        return new Context() {
            @Override
            public <Type> Optional<Type> get(Class<Type> type) {
                return Optional.ofNullable(providers.get(type)).map(provider -> (Type) provider.get(this));
            }
        };
    }

    private void checkDependencies(Class<?> component, Stack<Class<?>> visiting) {
        for (Class<?> dependency: providers.get(component).getDependencies()) {
            if (!providers.containsKey(dependency)) throw new DependencyNotFoundException(component, dependency);
            if (visiting.contains(dependency)) throw new CyclicDependenciesFoundException(visiting);
            visiting.push(dependency);
            checkDependencies(dependency, visiting);
            visiting.pop();
        }
    }

    interface ComponentProvider<T> {
        T get(Context context);
        List<Class<?>> getDependencies();
    }
}

ConstructorInjectionProvider.java:

package geektime.tdd.di;

import jakarta.inject.Inject;
import java.lang.reflect.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static java.util.Arrays.stream;
import static java.util.stream.Stream.concat;

class ConstructorInjectionProvider<T> implements ContextConfig.ComponentProvider<T> {
    private Constructor<T> injectConstructor;
    private List<Field> injectFields;
    private List<Method> injectMethods;

    public ConstructorInjectionProvider(Class<T> component) {
        this.injectConstructor = getInjectConstructor(component);
        this.injectFields = getInjectFields(component);
        this.injectMethods = getInjectMethods(component);
    }

    @Override
    public T get(Context context) {
        try {
            Object[] dependencies = stream(injectConstructor.getParameters())
                    .map(p -> context.get(p.getType()).get())
                    .toArray(Object[]::new);
            T instance = injectConstructor.newInstance(dependencies);
            for (Field field : injectFields)
                field.set(instance, context.get(field.getType()).get());
            for (Method method : injectMethods)
                method.invoke(instance, stream(method.getParameterTypes()).map(t -> context.get(t).get())
                        .toArray(Object[]::new));
            return instance;
        } catch (InvocationTargetException | InstantiationException | IllegalAccessException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public List<Class<?>> getDependencies() {
        return concat(concat(stream(injectConstructor.getParameters()).map(Parameter::getType),
                        injectFields.stream().map(Field::getType)),
                injectMethods.stream().flatMap(m -> stream(m.getParameterTypes()))
        ).toList();
    }

    private static <T> List<Method> getInjectMethods(Class<T> component) {
        List<Method> injectMethods = new ArrayList<>();
        Class<?> current = component;
        while(current != Object.class) {
            injectMethods.addAll(stream(current.getDeclaredMethods()).filter(m -> m.isAnnotationPresent(Inject.class))
                            .filter(m -> injectMethods.stream().noneMatch(o -> o.getName().equals(m.getName()) &&
                                    Arrays.equals(o.getParameterTypes(), m.getParameterTypes())))
                            .filter(m -> stream(component.getDeclaredMethods()).filter(m1 -> !m1.isAnnotationPresent(Inject.class))
                                    .noneMatch(o -> o.getName().equals(m.getName()) &&
                                            Arrays.equals(o.getParameterTypes(), m.getParameterTypes())))
                    .toList());
            current = current.getSuperclass();
        }
        Collections.reverse(injectMethods);
        return injectMethods;
    }

    private static <T> List<Field> getInjectFields(Class<T> component) {
        List<Field> injectFields = new ArrayList<>();
        Class<?> current = component;
        while (current != Object.class) {
            injectFields.addAll(stream(current.getDeclaredFields()).filter(f -> f.isAnnotationPresent(Inject.class))
                    .toList());
            current = current.getSuperclass();
        }
        return injectFields;
    }

    private static <Type> Constructor<Type> getInjectConstructor(Class<Type> implementation) {
        List<Constructor<?>> injectConstructors = stream(implementation.getConstructors())
                .filter(c -> c.isAnnotationPresent(Inject.class)).collect(Collectors.toList());
        if (injectConstructors.size() > 1) throw new IllegalComponentException();
        return (Constructor<Type>) injectConstructors.stream().findFirst().orElseGet(() -> {
            try {
                return implementation.getDeclaredConstructor();
            } catch (NoSuchMethodException e) {
                throw new IllegalComponentException();
            }
        });
    }
}

Context.java:
package geektime.tdd.di;

import java.util.Optional;

public interface Context {
    <Type> Optional<Type> get(Class<Type> type);
}

测试代码为:

package geektime.tdd.di;
import jakarta.inject.Inject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.platform.commons.annotation.Testable;
import org.mockito.Mockito;
import org.mockito.internal.util.collections.Sets;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.eq;
public class ContainerTest {
    ContextConfig config;
    @BeforeEach
    public void setup() {
        config = new ContextConfig();
    }
    @Nested
    public class ComponentConstruction {
        @Test
        public void should_bind_type_to_a_specific_instance() {
            Component instance = new Component() {
            };
            config.bind(Component.class, instance);
            Context context = config.getContext();
            assertSame(instance, context.get(Component.class).get());
        }

        @Test
        public void should_return_empty_if_component_not_defined() {
            Optional<Component> component = config.getContext().get(Component.class);
            assertTrue(component.isEmpty());
        }
        @Nested
        public class ConstructorInjection {
            //TODO: abstract class
            //TODO: interface
            @Test
            public void should_bind_type_to_a_class_with_default_constructor() {
                config.bind(Component.class, ComponentWithDefaultConstructor.class);
                Component instance = config.getContext().get(Component.class).get();
                assertNotNull(instance);
                assertTrue(instance instanceof ComponentWithDefaultConstructor);
            }
            @Test
            public void should_bind_type_to_a_class_with_inject_constructor() {
                Dependency dependency = new Dependency() {
                };
                config.bind(Component.class, ComponentWithInjectConstructor.class);
                config.bind(Dependency.class, dependency);
                Component instance = config.getContext().get(Component.class).get();
                assertNotNull(instance);
                assertSame(dependency, ((ComponentWithInjectConstructor) instance).getDependency());
            }
            @Test
            public void should_bind_type_to_a_class_with_transitive_dependencies() {
                config.bind(Component.class, ComponentWithInjectConstructor.class);
                config.bind(Dependency.class, DependencyWithInjectConstructor.class);
                config.bind(String.class, "indirect dependency");
                Component instance = config.getContext().get(Component.class).get();
                assertNotNull(instance);
                Dependency dependency = ((ComponentWithInjectConstructor) instance).getDependency();
                assertNotNull(dependency);
                assertEquals("indirect dependency", ((DependencyWithInjectConstructor) dependency).getDependency());
            }
            @Test
            public void should_throw_exception_if_multi_inject_constructors_provided() {
                assertThrows(IllegalComponentException.class, () -> {
                    config.bind(Component.class, ComponentWithMultiInjectConstructors.class);
                });
            }
            @Test
            public void should_throw_exception_if_no_inject_nor_default_constructor_provided() {
                assertThrows(IllegalComponentException.class, () -> {
                    config.bind(Component.class, ComponentWithNoInjectConstructorNorDefaultConstructor.class);
                });
            }
            @Test
            public void should_throw_exception_if_dependency_not_found() {
                config.bind(Component.class, ComponentWithInjectConstructor.class);
                DependencyNotFoundException exception = assertThrows(DependencyNotFoundException.class, () -> config.getContext());
                assertEquals(Dependency.class, exception.getDependency());
                assertEquals(Component.class, exception.getComponent());
            }
            @Test
            public void should_throw_exception_if_transitive_dependency_not_found() {
                config.bind(Component.class, ComponentWithInjectConstructor.class);
                config.bind(Dependency.class, DependencyWithInjectConstructor.class);
                DependencyNotFoundException exception = assertThrows(DependencyNotFoundException.class, () -> config.getContext());
                assertEquals(String.class, exception.getDependency());
                assertEquals(Dependency.class, exception.getComponent());
            }
            @Test
            public void should_throw_exception_if_cyclic_dependencies_found() {
                config.bind(Component.class, ComponentWithInjectConstructor.class);
                config.bind(Dependency.class, DependencyDependedOnComponent.class);
                CyclicDependenciesFoundException exception = assertThrows(CyclicDependenciesFoundException.class, () -> config.getContext());
                Set<Class<?>> classes = Sets.newSet(exception.getComponents());
                assertEquals(2, classes.size());
                assertTrue(classes.contains(Component.class));
                assertTrue(classes.contains(Dependency.class));
            }
            @Test
            public void should_throw_exception_if_transitive_cyclic_dependencies_found() {
                config.bind(Component.class, ComponentWithInjectConstructor.class);
                config.bind(Dependency.class, DependencyDependedOnAnotherDependency.class);
                config.bind(AnotherDependency.class, AnotherDependencyDependedOnComponent.class);
                CyclicDependenciesFoundException exception = assertThrows(CyclicDependenciesFoundException.class, () -> config.getContext());
                List<Class<?>> components = Arrays.asList(exception.getComponents());
                assertEquals(3, components.size());
                assertTrue(components.contains(Component.class));
                assertTrue(components.contains(Dependency.class));
                assertTrue(components.contains(AnotherDependency.class));
            }
        }
        @Nested
        public class FieldInjection {
            static class ComponentWithFieldInjection {
                @Inject
                Dependency dependency;
            }
            static class SubclassWithFieldInjection extends ComponentWithFieldInjection {
            }
            @Test
            public void should_inject_dependency_via_field() {
                Dependency dependency = new Dependency() {
                };
                config.bind(Dependency.class, dependency);
                config.bind(ComponentWithFieldInjection.class, ComponentWithFieldInjection.class);
                ComponentWithFieldInjection component = config.getContext().get(ComponentWithFieldInjection.class).get();
                assertSame(dependency, component.dependency);
            }
            @Test
            public void should_inject_dependency_via_superclass_inject_field() {
                Dependency dependency = new Dependency() {
                };
                config.bind(Dependency.class, dependency);
                config.bind(SubclassWithFieldInjection.class, SubclassWithFieldInjection.class);
                SubclassWithFieldInjection component = config.getContext().get(SubclassWithFieldInjection.class).get();
                assertSame(dependency, component.dependency);
            }
            //TODO throw exception if field is final
            @Test
            public void should_include_field_dependency_in_dependencies() {
                ConstructorInjectionProvider<ComponentWithFieldInjection> provider = new ConstructorInjectionProvider<>(ComponentWithFieldInjection.class);
                assertArrayEquals(new Class<?>[]{Dependency.class}, provider.getDependencies().toArray(Class<?>[]::new));
            }
        }
        @Nested
        public class MethodInjection {
            static class InjectMethodWithNoDependency {
                boolean called = false;
                @Inject
                void install() {
                    this.called = true;
                }
            }
            @Test
            public void should_call_inject_method_even_if_no_dependency_declared() {
                config.bind(InjectMethodWithNoDependency.class, InjectMethodWithNoDependency.class);
                InjectMethodWithNoDependency component = config.getContext().get(InjectMethodWithNoDependency.class).get();
                assertTrue(component.called);
            }
            static class InjectMethodWithDependency {
                Dependency dependency;
                @Inject
                void install(Dependency dependency) {
                    this.dependency = dependency;
                }
            }
            @Test
            public void should_inject_dependency_via_inject_method() {
                Dependency dependency = new Dependency() {
                };
                config.bind(Dependency.class, dependency);
                config.bind(InjectMethodWithDependency.class, InjectMethodWithDependency.class);
                InjectMethodWithDependency component = config.getContext().get(InjectMethodWithDependency.class).get();
                assertSame(dependency, component.dependency);
            }
            static class SuperClassWithInjectMethod {
                int superCalled = 0;
                @Inject
                void install() {
                    superCalled++;
                }
            }
            static class SubclassWithInjectMethod extends SuperClassWithInjectMethod {
                int subCalled = 0;
                @Inject
                void installAnother() {
                    subCalled = superCalled + 1;
                }
            }
            @Test
            public void should_inject_dependencies_via_inject_method_from_superclass() {
                config.bind(SubclassWithInjectMethod.class, SubclassWithInjectMethod.class);
                SubclassWithInjectMethod component = config.getContext().get(SubclassWithInjectMethod.class).get();
                assertEquals(1, component.superCalled);
                assertEquals(2, component.subCalled);
            }
            static class SubclassOverrideSuperClassWithInject extends SuperClassWithInjectMethod {
                @Inject
                void install() {
                    super.install();
                }
            }
            @Test
            public void should_only_call_once_if_subclass_override_inject_method_with_inject() {
                config.bind(SubclassOverrideSuperClassWithInject.class, SubclassOverrideSuperClassWithInject.class);
                SubclassOverrideSuperClassWithInject component = config.getContext().get(SubclassOverrideSuperClassWithInject.class).get();
                assertEquals(1, component.superCalled);
            }
            static class SubclassOverrideSuperClassWithNoInject extends SuperClassWithInjectMethod {
                void install() {
                    super.install();
                }
            }
            @Test
            public void should_not_call_inject_method_if_override_with_no_inject() {
                config.bind(SubclassOverrideSuperClassWithNoInject.class, SubclassOverrideSuperClassWithNoInject.class);
                SubclassOverrideSuperClassWithNoInject component = config.getContext().get(SubclassOverrideSuperClassWithNoInject.class).get();
                assertEquals(0, component.superCalled);
            }
            @Test
            public void should_include_dependencies_from_inject_method() {
                ConstructorInjectionProvider<InjectMethodWithDependency> provider = new ConstructorInjectionProvider<>(InjectMethodWithDependency.class);
                assertArrayEquals(new Class<?>[]{Dependency.class}, provider.getDependencies().toArray(Class<?>[]::new));
            }
            //TODO throw exception if type parameter defined
        }
    }
    @Nested
    public class DependenciesSelection {
    }
    @Nested
    public class LifecycleManagement {
    }
}
interface Component {
}
interface Dependency {
}
interface AnotherDependency {
}
class ComponentWithDefaultConstructor implements Component {
    public ComponentWithDefaultConstructor() {
    }
}
class ComponentWithInjectConstructor implements Component {
    private Dependency dependency;
    @Inject
    public ComponentWithInjectConstructor(Dependency dependency) {
        this.dependency = dependency;
    }
    public Dependency getDependency() {
        return dependency;
    }
}
class ComponentWithMultiInjectConstructors implements Component {
    @Inject
    public ComponentWithMultiInjectConstructors(String name, Double value) {
    }
    @Inject
    public ComponentWithMultiInjectConstructors(String name) {
    }
}
class ComponentWithNoInjectConstructorNorDefaultConstructor implements Component {
    public ComponentWithNoInjectConstructorNorDefaultConstructor(String name) {
    }
}
class DependencyWithInjectConstructor implements Dependency {
    private String dependency;
    @Inject
    public DependencyWithInjectConstructor(String dependency) {
        this.dependency = dependency;
    }
    public String getDependency() {
        return dependency;
    }
}
class DependencyDependedOnComponent implements Dependency {
    private Component component;
    @Inject
    public DependencyDependedOnComponent(Component component) {
        this.component = component;
    }
}
class AnotherDependencyDependedOnComponent implements AnotherDependency {
    private Component component;
    @Inject
    public AnotherDependencyDependedOnComponent(Component component) {
        this.component = component;
    }
}
class DependencyDependedOnAnotherDependency implements Dependency {
    private AnotherDependency anotherDependency;
    @Inject
    public DependencyDependedOnAnotherDependency(AnotherDependency anotherDependency) {
        this.anotherDependency = anotherDependency;
    }
}

任务列表状态为:

  • 无需构造的组件——组件实例
  • 如果注册的组件不可实例化,则抛出异常

  • 抽象类

  • 接口
  • 构造函数注入

  • 无依赖的组件应该通过默认构造函数生成组件实例

  • 有依赖的组件,通过Inject标注的构造函数生成组件实例
  • 如果所依赖的组件也存在依赖,那么需要对所依赖的组件也完成依赖注入
  • 如果组件有多于一个Inject标注的构造函数,则抛出异常
  • 如果组件没有Inject标注的构造函数,也没有默认构造函数(新增任务)
  • 如果组件需要的依赖不存在,则抛出异常
  • 如果组件间存在循环依赖,则抛出异常
  • 字段注入

  • 通过Inject标注将字段声明为依赖组件

  • 如果字段为final则抛出异常
  • 依赖中应包含Inject Field声明的依赖
  • 方法注入

  • 通过Inject标注的方法,其参数为依赖组件

  • 通过Inject标注的无参数方法,会被调用
  • 按照子类中的规则,覆盖父类中的Inject方法
  • 如果方法定义类型参数,则抛出异常
  • 依赖中应包含Inject Method声明的依赖
  • 对Provider类型的依赖

  • 注入构造函数中可以声明对于Provider的依赖

  • 注入字段中可以声明对于Provider的依赖
  • 注入方法中可声明对于Provider的依赖
  • 自定义Qualifier的依赖

  • 注册组件时,可额外指定Qualifier

  • 注册组件时,可从类对象上提取Qualifier
  • 寻找依赖时,需同时满足类型与自定义Qualifier标注
  • 支持默认Qualifier——Named
  • Singleton生命周期

  • 注册组件时,可额外指定是否为Singleton

  • 注册组件时,可从类对象上提取Singleton标注
  • 对于包含Singleton标注的组件,在容器范围内提供唯一实例
  • 容器组件默认不是Single生命周期
  • 自定义Scope标注

  • 可向容器注册自定义Scope标注的回调

视频演示

让我们进入今天的部分:

思考题

在进入下节课之前,希望你能认真思考如下两个问题,并选择最有感触的一道进行回答。

  1. 要怎样将大粒度的测试,重构为等效的小粒度测试代码?可以分享一下你的大致思路。
  2. 请对比一下重构前后的代码结构,体会其中的改变。这会让你逐渐养成习惯,建立重构的大局观。

欢迎把你的想法分享在留言区,也欢迎把你的项目代码的链接分享出来。相信经过你的思考与实操,学习效果会更好!

精选留言(5)
  • Flynn 👍(0) 💬(1)

    DI这个项目会在下个项目用上不

    2022-04-28

  • aoe 👍(3) 💬(0)

    - 通过 TDD 获得的测试,可以驱动我们的开发,但不代表获得的是一个良好的 Test Case 组合 - TDD 主要是为我们开发生产代码提供驱动力 - 天然得出的结果并不能认为是很好的 Test Case - 所以需要对 Test Case 进行重构 - 消除在构造 TDD 过程中留下的不一样的印记(架构选择、设计决策等) - 使 Test Case 能真实反应代码的意图 - 按测试意图将零散的测试方法收集到一起(放入同一个 Nested 中或者单独的测试类中) - 同一个上下文中,测试粒度尽量保持一致 - 清理没有用的测试

    2022-05-03

  • 👍(0) 💬(0)

    老师您好,请问为什么把测试类往外面移的时候,要先把它声明为static?

    2023-03-14

  • davix 👍(0) 💬(0)

    才知道TDD寫過的cases要重新組織,之前看到的TDD介紹太淺,都未提過。 請教老師,developer寫的test cases的好標準是啥?有延展閱讀嗎?

    2022-05-25

  • 枫中的刀剑 👍(0) 💬(0)

    本篇总结: 测试重构的目的:让TDD的 Test Case 更好的反映出我们代码的意图,而不仅仅是单纯展示实现功能的过程。 测试代码的坏味道: 主要体现在「不一致」。 1. 设计决策变化导致的测试冗余。 2. 同类型功能不同架构选择导致的不一致。(同一类功能测试中包含不同上下文) 3. 不同功能,相似结构的测试中表现的不一致。其中某些特有功能可能不在属于当前上下文。(这种情况稍微难发现一些)。 体会:测试的重构也很重要,结构优良的Test Case 更加清晰地呈现实现代码的真实意图。

    2022-05-02