# TDD演示
测试驱动开发,其实就是将软件需求转化为一组自动化测试,然后再根据测试描述的场景,逐步实现软件功能的开发方法。首先,我们试着演示一下如何通过TDD的方式来完成一段完整的功能。在演示前,有一些基本原则要先熟悉一下,TDD的基本原则是:
- 当且仅当存在失败的自动化测试时,才开始编写生产代码
- 消除重复
- 消除代码的坏味道
根据TDD的原则,其实实际的开发工作也就分成了三部:红/绿/重构:
- 红:编写一个失败的小测试
- 绿:让这个测试快速通过
- 重构:消除上一步中产生的所有重复和坏味道
红/绿/重构这个循环关注的是单个测试的层面,它并没有说清楚测试从何而来,也就是如何编写第一个测试,于是尝试才有TDD的人都卡在了不知道如何写测试这个问题上。徐昊总结出了一个任务分解法,并将任务列表作为TDD的核心要素:
- 大致构思软件被使用的方式,把握对外接口的方向
- 大致构思功能的实现方式,划分所需的组件以及组件之间的关系
- 根据需求的功能描述拆分功能点,功能要考虑正确路径、边界条件以及默认缺省值
- 依照组件以及组件间的关系,将功能拆分到对应组件
- 针对拆分的结果编写测试,进入红/绿/重构循环
# 编写失败的测试
接下来,我会通过 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) {
}
}
写完这部分代码时,我们会发现当前测试的颗粒度太大了,并没办法一次性实现并测试通过它。基于此,再将当前的测试拆解,分成先单独解析Boolean
、Integer
和String
这几种类型,然后再合在一起解析。于是形成了如下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));
}
通过重构,我们消除了代码的坏味道,在保持功能不变的前提下,得到了结构更好的代码。在红/绿测试阶段,我们不关心代码结构,只关注功能的累积,在重构的过程中,因为测试的存在,我们可以时刻检查功能是否收到破坏,同时将注意力转移到如何让代码变得更好上来。
# 重组测试策略
重构后,我们会发现当前多了BooleanParser
、IntParser
、StringParser
,这三个类,那我们也应该对这几个类进行测试,测试的内容也主要围绕着正常执行、异常处理和初始化默认值这几个功能来进行构建。
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
内关于BooleanParser
、IntParser
、StringParser
的测试用例,分别挪到对应匹配的策略中去。这几个策略对应的类,就应该分别包含其happy path、sad path以及default value。
# 发现重复代码,继续重构
实现了IntParser
、StringParser
这两个类后,我们发现其代码大部分都是重复的,只有个别返回值有一些变化,所以为了处理这种坏味道,我们将继续重构这两个类的代码,并将其合并。
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如下三个特点:
- 将要完成的功能分解成一系列任务,再将任务转化为测试,以测试体现研发进度,将整个开发流程变得有序,以减少无效的劳动。
- 在修改代码时,随时执行测试以验证功能,及时发现错误,降低发现、定位错误的成本,降低修改错误的难度。
- 时刻感受到认知的提升,增强修改代码的自信,降低解决错误的恐惧。