# TDD演示

测试驱动开发,其实就是将软件需求转化为一组自动化测试,然后再根据测试描述的场景,逐步实现软件功能的开发方法。首先,我们试着演示一下如何通过TDD的方式来完成一段完整的功能。在演示前,有一些基本原则要先熟悉一下,TDD的基本原则是

  1. 当且仅当存在失败的自动化测试时,才开始编写生产代码
  2. 消除重复
  3. 消除代码的坏味道

根据TDD的原则,其实实际的开发工作也就分成了三部:红/绿/重构:

  • :编写一个失败的小测试
  • 绿:让这个测试快速通过
  • 重构:消除上一步中产生的所有重复和坏味道

红/绿/重构这个循环关注的是单个测试的层面,它并没有说清楚测试从何而来,也就是如何编写第一个测试,于是尝试才有TDD的人都卡在了不知道如何写测试这个问题上。徐昊总结出了一个任务分解法,并将任务列表作为TDD的核心要素:

  1. 大致构思软件被使用的方式,把握对外接口的方向
  2. 大致构思功能的实现方式,划分所需的组件以及组件之间的关系
  3. 根据需求的功能描述拆分功能点,功能要考虑正确路径、边界条件以及默认缺省值
  4. 依照组件以及组件间的关系,将功能拆分到对应组件
  5. 针对拆分的结果编写测试,进入红/绿/重构循环

# 编写失败的测试

接下来,我会通过 TDD 来实现命令行参数解析的功能。这个练习源自 Robert C. Martin 的 《Clean Code》 第十四章的一个例子。需求描述如下:

简单地处理一下传入main函数的字符串数组。 传递给程序的参数由标志和值组成。 标志应该是一个字符,前面有一个减号(-)。 每个标志都应该有零个或多个与之相关的值。 例如:-l -p 8080 -d /usr/logs “l”(日志)没有相关的值,它是一个布尔标志,如果存在则为 true,不存在则为 false“p”(端口)有一个整数值,“d”(目录)有一个字符串值。 如果参数中没有指定某个标志,那么解析器应该指定一个默认值。

接下来我们试着结合任务分解法,使用TDD来完成这个需求,首先我们需要考虑别人将以何种方式来使用这段代码,这段代码作为对外的接口,如何调用才能更舒适,在这里,我们可以通过写测试的方法,来感受API的友好程度,我们可以假设功能已经实现了,在测试代码中调用方法来感受这段代码,看一看他整体的风格是不是我们喜欢的,首先我想到了如下这种传统的写法:

public class ArgsTest {
    // -l -p 8080 -d /usr/logs -g this is a list -d 1 2 -3 5
    @Test
    public void should() {
        Argument argument = Args.parse("l:boolean,p:int,d:string", "-l", "-p", "8080", "-d", "/usr/logs");
        argument.getBool("l");
        argument.getInt("p");
        argument.getString("d");
    }
}

由于当前Java更流行注解,我又想到了下面这种写法:

public class ArgsTest {
    // -l -p 8080 -d /usr/logs -g this is a list -d 1 2 -3 5
    @Test
    public void should() {
        Options options = Args.prase(Options.class,"-l", "-p", "8080", "-d", "/usr/logs");
        options.logging();
        options.port();
        options.directory();
    }
    static record Options(@Option("l") boolean logging, @Option("p") int port, @Option("d") String directory) {}
}

在确定了API设计的方向之后,我们就可以根据需求的描述来对功能进行分解了,当前需求整体上可以理解为将命令行解析成各个参数,根据这个需求,我们首先想到的就是先实现解析参数,然后再实现解析列表这个过程,根据这个过程形成了如下的测试代码:

public class ArgsTest {
    // -l -p 8080 -d /usr/logs -g this is a list -d 1 2 -3 5

    @Test
    public void example_1() {
        // 输入-l -p 8080 -d /usr/logs,将内容进行分割,并提取参数
        Options options = Args.prase(Options.class, "-l", "-p", "8080", "-d", "/usr/logs");
        Assertions.assertTrue(options.logging());
        Assertions.assertEquals(8008, options.port());
        Assertions.assertEquals("/usr/logs", options.directory());

    }

    @Test
    public void example_2() {
        // 输入g this is a list -d 1 2 -3 5,将内容进行分割,并提取参数列表
        val listOptions = Args.prase(ListOptions.class, "-g", "this", "is", "a", "list", "-d", "1", "2", "3");
        Assertions.assertEquals(new String[]{"this", "is", "a", "list"}, listOptions.group());
        Assertions.assertEquals(new int[]{1, 2, 3}, listOptions.decimals());

    }

    static record Options(@Option("l") boolean logging, @Option("p") int port, @Option("d") String directory) {
    }

    static record ListOptions(@Option("g") String[] group, @Option("d") int[] decimals) {
    }
}

写完这部分代码时,我们会发现当前测试的颗粒度太大了,并没办法一次性实现并测试通过它。基于此,再将当前的测试拆解,分成先单独解析BooleanIntegerString这几种类型,然后再合在一起解析。于是形成了如下TODO:

    // happy path
    // TODO 输入:-l, 输出:options.logging() = true
    // TODO 输入:-p 8080,输出: options.port()=8080
    // TODO 输入:-d /usr/logs,输出: options.directory()=/usr/logs
    // TODO 输入:-l -p 8080 -d /usr/logs,输出:options(logging=true,port=8080,directory="/usr/logs")
    
    // default value
    // TODO -p 默认=0
    // TODO -d 默认=""
    // TODO -l 默认=false

第一步的测试,我们分别测单一参数的结果是否正确,我们分别单独输入"-l","-p 8080"和"-d /usr/logs",期待能够得到一个正常的结果,测试如下:


    @Test
    public void should_get_boolean_option_to_true_if_flag_present(){
        val option = Args.parse(BooleanOption.class, "-l");
        Assertions.assertTrue(option.logging());
    }
    @Test
    public void should_get_boolean_option_to_false_if_flag_present(){
        val option = Args.parse(BooleanOption.class);
        Assertions.assertFalse(option.logging());
    }
    static record BooleanOption(@Option("l")boolean logging){}
    @Test
    public void should_get_int_option_if_flag_present(){
        val option = Args.parse(IntOption.class, "-p", "8080");
        Assertions.assertEquals(8080,option.port());
    }
    @Test
    public void should_get_zero_option_if_input_empty_value(){
        val option = Args.parse(IntOption.class, "-p");
        Assertions.assertEquals(0,option.port());
    }
    static record IntOption(@Option("p")int port){}
    @Test
    public void should_get_string_option_if_flag_present(){
        val option = Args.parse(StringOption.class, "-d", "/usr/logs");
        Assertions.assertEquals("/usr/logs",option.directory());
    }
    @Test
    public void should_get_null_if_input_empty_value(){
        val option = Args.parse(StringOption.class, "-d");
        Assertions.assertEquals("",option.directory());
    }
    static record StringOption(@Option("d")String directory){}
    

# 修复失败的测试,编写代码

基于测试,可以实现如下代码,实现的思路很简单,就是对着测试逐一使其不再报错,程序运行时将红色的警告转为绿色通过:

public class Args {
    public static <T> T parse(Class<T> optionsClass, String... args) {
        val constructor = optionsClass.getDeclaredConstructors()[0];
        try {
            val parameter = constructor.getParameters()[0];
            val option = parameter.getAnnotation(Option.class);
            val arguments = Arrays.asList(args);
            Object value = null;

            if (parameter.getType() == boolean.class) {
                value = arguments.contains("-" + option.value());
            }
            if (parameter.getType() == int.class) {
                int index = arguments.indexOf("-" + option.value());
                value = arguments.size() == 1 ? 0 : Integer.parseInt(arguments.get(index + 1));
            }
            if (parameter.getType() == String.class) {
                int index = arguments.indexOf("-" + option.value());
                value = arguments.size() == 1 ? "" : String.valueOf(arguments.get(index + 1));
            }
            return (T) constructor.newInstance(value);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

如果要将参数一并提取,可以基于上述代码进行一定的转化,转化后的parseArguments方法,其实就是由上面一组测试驱动出来的实现代码:

public class Args {
    public static <T> T parse(Class<T> optionsClass, String... args) {
        val constructor = optionsClass.getDeclaredConstructors()[0];
        val arguments = Arrays.asList(args);
        try {
            val values = Arrays.stream(constructor.getParameters())
                    .map(parameter -> parseArguments(arguments, parameter)).toArray();
            val instance = constructor.newInstance(values);
            return (T) instance;
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    private static Object parseArguments(List<String> arguments, Parameter parameter) {
        val option = parameter.getAnnotation(Option.class);
        Object value = null;
        if (parameter.getType() == boolean.class) {
            value = arguments.contains("-" + option.value());
        }
        if (parameter.getType() == int.class) {
            int index = arguments.indexOf("-" + option.value());
            value = arguments.size() == 1 ? 0 : Integer.parseInt(arguments.get(index + 1));
        }
        if (parameter.getType() == String.class) {
            int index = arguments.indexOf("-" + option.value());
            value = arguments.size() == 1 ? "" : String.valueOf(arguments.get(index + 1));
        }
        return value;
    }
}

# 重构代码

在通过若干次红/绿循环后,问你完成了一个可以处理多个参数的功能,此时我们可以看一下代码选择是否重构。是否进入重构有两个先决条件,第一个条件是测试都是通过的,当前功能正常,第二个是可以明显的看到代码中的坏味道。我们目前的代码已经全部通过测试,而且有多个分支语句,看起来可以对其进行重构。可以使用“利用多态替换条件分支”的的手法对其重构。在重构的过程中,我们需要保持小步骤且稳定的节奏,不断的运行测试以保证当前的代码不会产生负面影响。

我们首先要做的是将分支内的方法抽取出来,使其能够对其进行隔离。第一步,我们可以先将分支语句中的代码,利用接口进行独立实现:

 private static Object parseArguments(List<String> arguments, Parameter parameter) {
        val option = parameter.getAnnotation(Option.class);
        Object value = null;
        if (parameter.getType() == boolean.class) {
            value = new BooleanParser().parse(arguments, option);
        }
        if (parameter.getType() == int.class) {
            value = new IntParser().parse(arguments, option);
        }
        if (parameter.getType() == String.class) {
            value = new StringParser().parse(arguments, option);
        }
        return value;
    }

    interface ArgumentParser {
        Object parse(List<String> arguments, Option option);
    }

    static class BooleanParser implements ArgumentParser {
        @Override
        public Object parse(List<String> arguments, Option option) {
            return arguments.contains("-" + option.value());
        }
    }

    static class IntParser implements ArgumentParser {
        @Override
        public Object parse(List<String> arguments, Option option) {
            int index = arguments.indexOf("-" + option.value());
            return arguments.size() == 1 ? 0 : Integer.parseInt(arguments.get(index + 1));
        }
    }

    static class StringParser implements ArgumentParser {
        @Override
        public Object parse(List<String> arguments, Option option) {
            int index = arguments.indexOf("-" + option.value());
            return arguments.size() == 1 ? "" : String.valueOf(arguments.get(index + 1));
        }
    }

这时我们会发现,分支中的代码几乎都是重复的,我们继续抽取:

// 抽取前
    private static Object parseArguments(List<String> arguments, Parameter parameter) {
        val option = parameter.getAnnotation(Option.class);
        Object value = null;
        if (parameter.getType() == boolean.class) {
            ArgumentParser parser = new BooleanParser();
            value = parser.parse(arguments, option);
        }
        if (parameter.getType() == int.class) {
            ArgumentParser parser = new IntParser();
            value = parser.parse(arguments, option);
        }
        if (parameter.getType() == String.class) {
            ArgumentParser parser = new StringParser();
            value = parser.parse(arguments, option);
        }
        return value;
    }
    
    //抽取后
       private static Object parseArguments(List<String> arguments, Parameter parameter) {
        val option = parameter.getAnnotation(Option.class);
        Class<?> type = parameter.getType();
        ArgumentParser parser = getArgumentParser(type);
        return parser.parse(arguments, option);
    }

    private static ArgumentParser getArgumentParser(Class<?> type) {
        ArgumentParser parser = null;
        if (type == boolean.class) {
            parser = new BooleanParser();
        }
        if (type == int.class) {
            parser = new IntParser();
        }
        if (type == String.class) {
            parser = new StringParser();
        }
        return parser;
    }
    
    //将条件分支抽取成Map,继续重构会得到如下代码
        private static Map<Class<?>, ArgumentParser> PARSERS = Map.of(boolean.class, new BooleanParser(),
            int.class, new IntParser(),
            String.class, new StringParser());
            
    private static Object parseArguments(List<String> arguments, Parameter parameter) {
        return PARSERS.get(parameter.getType()).parse(arguments, parameter.getAnnotation(Option.class));
    }

通过重构,我们消除了代码的坏味道,在保持功能不变的前提下,得到了结构更好的代码。在红/绿测试阶段,我们不关心代码结构,只关注功能的累积,在重构的过程中,因为测试的存在,我们可以时刻检查功能是否收到破坏,同时将注意力转移到如何让代码变得更好上来。

# 重组测试策略

重构后,我们会发现当前多了BooleanParserIntParserStringParser,这三个类,那我们也应该对这几个类进行测试,测试的内容也主要围绕着正常执行、异常处理和初始化默认值这几个功能来进行构建。

class BooleanParserTest {

    // 输入:-l t f,输出:异常提示
    @Test
    public void should_not_allow_extra_arguments_for_bool_option() {
        Assertions.assertThrows(TooManyArgumentsException.class, () -> {
            new BooleanParser().parse(Arrays.asList("-l", "t", "f"), option());
        });
    }

    @Test
    public void should_set_a_default_value_for_bool_option() {
        Assertions.assertFalse(new BooleanParser().parse(List.of(), option()));
    }

    static Option option() {
        return new Option() {
            @Override
            public Class<? extends Annotation> annotationType() {
                return Option.class;
            }

            @Override
            public String value() {
                return "l";
            }
        };
    }
}
class IntParserTest {

    @Test
    public void should_not_allow_extra_arguments_for_bool_option() {
        Assertions.assertThrows(TooManyArgumentsException.class, () -> {
            new IntParser().parse(Arrays.asList("8080","8801"), option());
        });
    }

    @Test
    public void should_set_a_default_value_for_bool_option() {
        Assertions.assertEquals(0,new IntParser().parse(List.of(), option()));
    }


    static Option option() {
        return new Option() {
            @Override
            public Class<? extends Annotation> annotationType() {
                return Option.class;
            }
            @Override
            public String value() {
                return "p";
            }
        };
    }
}
class StringParserTest {

    @Test
    public void should_not_allow_extra_arguments_for_bool_option() {
        Assertions.assertThrows(TooManyArgumentsException.class, () -> {
            new StringParser().parse(Arrays.asList("/user/asd","/user/as"), option());
        });
    }

    @Test
    public void should_set_a_default_value_for_bool_option() {
        Assertions.assertEquals("",new StringParser().parse(List.of(), option()));
    }


    static Option option() {
        return new Option() {
            @Override
            public Class<? extends Annotation> annotationType() {
                return Option.class;
            }

            @Override
            public String value() {
                return "d";
            }
        };
    }

}

不难发现,新一轮的红绿循环又开始了,我们要基于测试的需求,分别对实现类进行完善,使其通过测试:

class BooleanParser implements ArgumentParser {
    @Override
    public Boolean parse(List<String> arguments, Option option) {
        int index = arguments.indexOf("-" + option.value());
        if (index + 1 < arguments.size()
                && !arguments.get(index + 1).startsWith("-")) throw new TooManyArgumentsException();
        return index != -1;
    }
}
class IntParser implements ArgumentParser {
    @Override
    public Object parse(List<String> arguments, Option option) {
        int index = arguments.indexOf("-" + option.value());
        if (arguments.size() == index + 1 || arguments.get(index + 1).startsWith("-")) return 0;
        if (index + 2 < arguments.size()
                && !arguments.get(index + 2).startsWith("-")) throw new TooManyArgumentsException();
        return parseValue(arguments, index);
    }

    protected Object parseValue(List<String> arguments, int index) {
        return Integer.parseInt(arguments.get(index + 1));
    }
}
class StringParser implements ArgumentParser {
    @Override
    public Object parse(List<String> arguments, Option option) {
        int index = arguments.indexOf("-" + option.value());
        if (arguments.size() == index + 1 || arguments.get(index + 1).startsWith("-")) return "";
        if (index + 2 < arguments.size()
                && !arguments.get(index + 2).startsWith("-")) throw new TooManyArgumentsException();
        return parseValue(arguments, index);
    }

    protected Object parseValue(List<String> arguments, int index) {
        return String.valueOf(arguments.get(index + 1));
    }
}

功能实现完毕后,我们再跑一下测试,看整体实现对原有的测试是否会产生影响。写到这里,不难发现ArgTest类里的内容,需要根据不同的策略进行重组,要将ArgTest内关于BooleanParserIntParserStringParser的测试用例,分别挪到对应匹配的策略中去。这几个策略对应的类,就应该分别包含其happy path、sad path以及default value。

# 发现重复代码,继续重构

实现了IntParserStringParser这两个类后,我们发现其代码大部分都是重复的,只有个别返回值有一些变化,所以为了处理这种坏味道,我们将继续重构这两个类的代码,并将其合并。

class SingleValuedParser implements ArgumentParser {

    private Object defaultValue;
    private Function<String, Object> valueParser;

    public SingleValuedParser(Object defaultValue, java.util.function.Function<String, Object> valueParser) {
        this.defaultValue = defaultValue;
        this.valueParser = valueParser;
    }

    @Override
    public Object parse(List<String> arguments, Option option) {
        int index = arguments.indexOf("-" + option.value());
        if (arguments.size() == index + 1 || arguments.get(index + 1).startsWith("-")) return defaultValue;
        if (index + 2 < arguments.size()
                && !arguments.get(index + 2).startsWith("-")) throw new TooManyArgumentsException();
        val value = arguments.get(index + 1);
        return parseValue(value);
    }

    protected Object parseValue(String value) {
        return valueParser.apply(value);
    }
}

重构了SingleValuedParser之后,我们再将其与BooleanParser合并:


class ArgumentParsers implements ArgumentParser {

    final private Object defaultValue;
    final private Function<String, Object> valueParser;
    int expectedSize;

    public ArgumentParsers(Object defaultValue, java.util.function.Function<String, Object> valueParser, int expectedSize) {
        this.defaultValue = defaultValue;
        this.valueParser = valueParser;
        this.expectedSize =expectedSize;
    }

    @Override
    public Object parse(List<String> arguments, Option option) {
        int index = arguments.indexOf("-" + option.value());
        if (option.value().equals("l") && index == -1) return defaultValue;
        val values = values(arguments, index);
        if (values.size() < expectedSize ) return defaultValue;
        if (values.size() > expectedSize) throw new TooManyArgumentsException();
        String value = arguments.get(index + expectedSize);
        return parseValue(value);
    }
      List<String> values(List<String> arguments, int index) {
        int followingFlag = IntStream.range(index + 1, arguments.size())
                .filter(it -> arguments.get(it).matches("^-[a-zA-Z-]+$"))
                .findFirst()
                .orElse(arguments.size());
        return arguments.subList(index + 1, followingFlag);
    }

    protected Object parseValue(String value) {
        return valueParser.apply(value);
    }
}

最后再修改一下:

class ArgumentParsers implements ArgumentParser{

     private final Object defaultValue;
     private final Function<String, Object> valueParser;
     int expectedSize;

     public ArgumentParsers(Object defaultValue, java.util.function.Function<String, Object> valueParser, int expectedSize) {
        this.defaultValue = defaultValue;
        this.valueParser = valueParser;
        this.expectedSize = expectedSize;
    }
    static ArgumentParsers  bool(){
       return new ArgumentParsers(false, (it) -> Objects.equals(it, "-l"), 0);
    }
    static ArgumentParsers unary(Object defaultValue, java.util.function.Function<String, Object> valueParser){
         return new ArgumentParsers(defaultValue,valueParser,1);
    }

    @Override
     public Object parse(List<String> arguments, Option option) {
        int index = arguments.indexOf("-" + option.value());
        if (isBooleanOptionNull(option, index)) return defaultValue;
        if (isValueSlopOver(arguments, index)) return defaultValue;
        String value = arguments.get(index + expectedSize);
        return valueParser.apply(value);
    }

    private boolean isValueSlopOver(List<String> arguments, int index) {
        val values = values(arguments, index);
        if (values.size() < expectedSize) return true;
        if (values.size() > expectedSize) throw new TooManyArgumentsException();
        return false;
    }

    private static boolean isBooleanOptionNull(Option option, int index) {
        return option.value().equals("l") && index == -1;
    }

    static List<String> values(List<String> arguments, int index) {
        int followingFlag = IntStream.range(index + 1, arguments.size())
                .filter(it -> arguments.get(it).matches("^-[a-zA-Z-]+$"))
                .findFirst()
                .orElse(arguments.size());
        return arguments.subList(index + 1, followingFlag);
    }

}

写到这里不难发现,代码是伴随着测试和重构动态调整的,它的内容体现了我们最新的认知,它的变化记录了我们认知变化的过程。这份代码目前看还有很多坏味道,但是这正代表了当前的编码水平,因为有了测试的保护,使得这份代码可以以后在我们编程水平精进后再重构调整。根据这次TDD的演示,我们应该能感受到TDD如下三个特点:

  1. 将要完成的功能分解成一系列任务,再将任务转化为测试,以测试体现研发进度,将整个开发流程变得有序,以减少无效的劳动。
  2. 在修改代码时,随时执行测试以验证功能,及时发现错误,降低发现、定位错误的成本,降低修改错误的难度。
  3. 时刻感受到认知的提升,增强修改代码的自信,降低解决错误的恐惧。