TDD 实践-FizzFuzzWhizz(二)

标签 | TDD Java
字数 | 2728 字
java

说明:该 TDD 系列案例主要是为了巩固和记录本身 TDD 实践过程当中的思考与总结。我的认为 TDD 自己并不难,难的大部分是编程以外的技能,好比分析能力、设计能力、表达能力和沟通能力;因此在 TDD 的过程当中,我的认为 TDD 能够锻炼一我的事先思考、化繁为简、制定计划、精益求精的习惯和品质。本文的源码放在我的的 Github 上,案例需求来自于网上。git

目标收益

  1. 熟悉掌握 TDD 总体流程。
  2. 识别代码坏味道 Deplicated Code 以及重构手法。
  3. 了解 java8 特性 lambda 和部分函数式接口的使用。
  4. 获得满意的测试覆盖率。
  5. 提升对代码的自信和重构的勇气。

任务回顾

  • 学生报数。
    1. 若是是第一个特殊数字的倍数,就报 Fizz。
    2. 若是是第二个特殊数字的倍数,就报 Buzz。
    3. 若是是第三个特殊数字的倍数,就报 Whizz (当前任务)
    4. 若是同时是多个特殊数字的倍数,须要按特殊数字的顺序把对应的单词拼接起来再报出,好比 FizzBuzz、BuzzWhizz、FizzBuzzWhizz。
    5. 若是包含第一个特殊数字,只报 Fizz (忽略规则 一、二、三、4)
    6. 若是不是特殊数字的倍数,而且不包含第一个特殊数字,就报对应的序号。

代码回顾

public class Student {

    public static String countOff(Integer position, List<GameRule> gameRules) {
        if (position % gameRules.get(0).getNumber() == 0) {
            return gameRules.get(0).getTerm();
        } else if (position % gameRules.get(1).getNumber() == 0) {
            return gameRules.get(1).getTerm();
        } else if (position % gameRules.get(2).getNumber() == 0) {
            return gameRules.get(2).getTerm();
        }
        return position.toString();
    }
}
复制代码

测试驱动开发

若是有任何重复的逻辑或没法解释的代码,重构能够消除重复并提升表达能力(减小耦合,增长内聚力)。github

上一篇文章的内容,此时咱们须要解决代码中的坏味道——Duplicated Code。分析发现,代码之间只是相似,并不是彻底相同,并且代码表达的意图很不清晰,可使用 Extract Method 重构手法来解决这个问题,经过抽出 isMultiple 方法用于判学生的序号是不是特殊数的倍数,使代码意图清晰一些,很快我就完成了初步的重构:编程

public class Student {

    public static String countOff(Integer position, List<GameRule> gameRules) {
        if (isMultiple(position, gameRules.get(0).getNumber())) {
            return gameRules.get(0).getTerm();
        } else if (isMultiple(position, gameRules.get(1).getNumber())) {
            return gameRules.get(1).getTerm();
        } else if (isMultiple(position, gameRules.get(2).getNumber())) {
            return gameRules.get(2).getTerm();
        }
        return position.toString();
    }

    private static boolean isMultiple(Integer divisor, Integer dividend) {
        return divisor % dividend == 0;
    }
}
复制代码

运行自动化测试所有经过,不过取值的方式仍是有点笨,而后我把上面那种取值方式改为经过循环自动取值以下降错误率,此时代码变得更加简洁,表达的意图也更加清晰:bash

public static String countOff(Integer position, List<GameRule> gameRules) {
    for (GameRule gameRule : gameRules) {
        if (isMultiple(position, gameRule.getNumber())) {
            return gameRule.getTerm();
        }
    }
    return position.toString();
}

private static boolean isMultiple(Integer divisor, Integer dividend) {
    return divisor % dividend == 0;
}
复制代码

再次运行测试验证重构是否引入新的错误。若是没有经过,极可能是在重构时犯了一些错误,须要当即修复并从新运行,直到全部测试经过。 微信

通过自动化测试的检验,测试所有经过,此时能够放心开始下一个子任务。


  • 若是同时是多个特殊数字的倍数,须要按特殊数字的顺序把对应的单词拼接起来再报出,好比 FizzBuzz、BuzzWhizz、FizzBuzzWhizz。

从描述中能够看出第 4 个子任务也很是简单,很快我就写好了对应的单元测试,并驱动出了具体实现:ide

public class StudentTest {
    private final List<GameRule> gameRules = Lists.list(
            new GameRule(3, "Fizz"),
            new GameRule(5, "Buzz"),
            new GameRule(7, "Whizz")
    );
    ...
    @Test
    public void should_return_fizzbuzz_when_just_a_multiple_of_the_first_number_and_second_number() {
        assertThat(Student.countOff(15, gameRules)).isEqualTo("FizzBuzz");
        assertThat(Student.countOff(45, gameRules)).isEqualTo("FizzBuzz");
    }

    @Test
    public void should_return_fizzwhizz_when_just_a_multiple_of_the_first_number_and_third_number() {
        assertThat(Student.countOff(21, gameRules)).isEqualTo("FizzWhizz");
        assertThat(Student.countOff(42, gameRules)).isEqualTo("FizzWhizz");
        assertThat(Student.countOff(63, gameRules)).isEqualTo("FizzWhizz");
    }

    @Test
    public void should_return_buzzwhizz_when_just_a_multiple_of_the_second_number_and_third_number() {
        assertThat(Student.countOff(35, gameRules)).isEqualTo("BuzzWhizz");
        assertThat(Student.countOff(70, gameRules)).isEqualTo("BuzzWhizz");
    }

    @Test
    public void should_return_fizzbuzzwhizz_when_at_the_same_time_is_a_multiple_of_the_three_number() {
        List<GameRule> gameRules = Lists.list(
                new GameRule(2, "Fizz"),
                new GameRule(3, "Buzz"),
                new GameRule(4, "Whizz")
        );
        assertThat(Student.countOff(24, gameRules)).isEqualTo("FizzBuzzWhizz");
        assertThat(Student.countOff(48, gameRules)).isEqualTo("FizzBuzzWhizz");
        assertThat(Student.countOff(96, gameRules)).isEqualTo("FizzBuzzWhizz");
    }
}

public class Student {
    public static String countOff(Integer position, List<GameRule> gameRules) {
    
        if (isMultiple(position, gameRules.get(0).getNumber()) 
                && isMultiple(position, gameRules.get(1).getNumber()) 
                && isMultiple(position, gameRules.get(2).getNumber())) {
            return gameRules.get(0).getTerm() + gameRules.get(1).getTerm() + gameRules.get(2).getTerm();
        } else if (isMultiple(position, gameRules.get(0).getNumber()) 
                && isMultiple(position, gameRules.get(1).getNumber())) {
            return gameRules.get(0).getTerm() + gameRules.get(1).getTerm();
        } else if (isMultiple(position, gameRules.get(0).getNumber()) 
                && isMultiple(position, gameRules.get(2).getNumber())) {
            return gameRules.get(0).getTerm() + gameRules.get(2).getTerm();
        } else if (isMultiple(position, gameRules.get(1).getNumber()) 
                && isMultiple(position, gameRules.get(2).getNumber())) {
            return gameRules.get(1).getTerm() + gameRules.get(2).getTerm();
        }
    
        for (GameRule gameRule : gameRules) {
            if (isMultiple(position, gameRule.getNumber())) {
                return gameRule.getTerm();
            }
        }
        return position.toString();
    }
}
复制代码

此时我遇到了两个问题,一个是第四个子任务的描述缺了 FizzWhizz 这种可能,因此我先完善了任务清单;第二个是我又从代码中闻到熟悉的坏味道,所以在自动化测试的支撑下,我开始创建起自信,并解决了 if else 过于冗长的问题:函数式编程

public static String countOff(Integer position, List<GameRule> gameRules) {
    String terms = gameRules
            .stream()
            .filter(rule -> isMultiple(position, rule.getNumber()))
            .map(rule -> rule.getTerm())
            .reduce((t1, t2) -> t1 + t2)
            .orElse(null);
    if (terms != null) {
        return terms;
    }

    for (GameRule gameRule : gameRules) {
        if (isMultiple(position, gameRule.getNumber())) {
            return gameRule.getTerm();
        }
    }
    return position.toString();
}
复制代码

此时自动化测试所有经过,而后分析发现,下面的 for 循环已经变成冗余代码,由于它已经被合并到新写入的代码中,如今能够删除掉它了:函数

public static String countOff(Integer position, List<GameRule> gameRules) {
    String term = gameRules
            .stream()
            .filter(rule -> isMultiple(position, rule.getNumber()))
            .map(rule -> rule.getTerm())
            .reduce((t1, t2) -> t1 + t2)
            .orElse(position.toString());
    return term;
}
复制代码

自动化测试所有经过,这里我引入 java 8 的特性 lambel 和函数式接口,函数式编程在代码实现层面加强了代码的语义,也使得代码更加精练,现在总算获得一份满意的代码,能够开始“学生报数”的最后一个子任务。单元测试


  • 学生报数。
    1. 若是是第一个特殊数字的倍数,就报 Fizz。
    2. 若是是第二个特殊数字的倍数,就报 Buzz。
    3. 若是是第三个特殊数字的倍数,就报 Whizz (当前任务)
    4. 若是同时是多个特殊数字的倍数,须要按特殊数字的顺序把对应的单词拼接起来再报出,好比 FizzBuzz、FizzWhizz、BuzzWhizz、FizzBuzzWhizz。
    5. 若是包含第一个特殊数字,只报 Fizz (忽略规则 一、二、三、4)
    6. 若是不是特殊数字的倍数,而且不包含第一个特殊数字,就报对应的序号。

看着内心乐,最后一个子任务预计 2 分钟搞定,而后就能够把“学生报数”这个核心任务划掉。因而乎我很快的编写了对应的单元测试,并驱动出对应的具体实现:

public class StudentTest {
    private final List<GameRule> gameRules = Lists.list(
            new GameRule(3, "Fizz"),
            new GameRule(5, "Buzz"),
            new GameRule(7, "Whizz")
    );
    ...
    @Test
    public void should_return_fizz_when_included_the_first_number() {
        assertThat(Student.countOff(3, gameRules)).isEqualTo("Fizz");
        assertThat(Student.countOff(13, gameRules)).isEqualTo("Fizz");
        assertThat(Student.countOff(30, gameRules)).isEqualTo("Fizz");
        assertThat(Student.countOff(31, gameRules)).isEqualTo("Fizz");
    }
}

public class Student {

    public static String countOff(final Integer position, final List<GameRule> gameRules) {
        if (position.toString().contains(gameRules.get(0).getNumber().toString())) {
            return gameRules.get(0).getTerm();
        }
        String term = gameRules
                .stream()
                .filter(rule -> isMultiple(position, rule.getNumber()))
                .map(rule -> rule.getTerm())
                .reduce((t1, t2) -> t1 + t2)
                .orElse(position.toString());
        return term;
    }

    private static boolean isMultiple(Integer divisor, Integer dividend) {
        return divisor % dividend == 0;
    }
}
复制代码

运行自动化单元测试:

新增的单元测试经过,可是却出现其它三个单元测试执行失败,出现这种状况我下意识以为是新加入的代码有 BUG,由于是在我加入实现代码以后才出现测试失败的状况。通过分析,发现原来是最后一个子任务优先级最高,而恰好那些失败的单元测试的部分测试样本数据受到当前子任务的条件约束,解决起来很简单,删除对应的测试代码就好,如今全部单元测试运行经过,而且完成“学生报数”任务。

知识:是什么让开发人员变得更有勇气去重构代码?

这得益于 TDD 的核心思想——不可运行/可运行/重构。这样的机制能够保证拥有足够多的单元测试以便于支撑实施代码重构,在细微持续的反馈中能够很是自信的作到小步快跑,由于咱们能够很是放心的把“后背”交给自动化 BUG 侦察机。

讨论:新加入的代码是否须要再优化?

可能有人以为新加入的代码 if(...) 有点冗长,表达的含义也不是特别清晰,其实我也有很强烈的代码洁癖症(处女座一枚),不过如今的节奏我是认为很好了,若是还须要优化,我认为只需补充加上适当的注释代表代码的意图。您以为呢?期待您的建议。

反思:到目前为止,程序是否存在更加优秀的设计?

我认为是的,不过目前看起来还不错,具体等到引入游戏上下文和实现其它任务时再综合思考这个问题。

TDD 成果

任务清单:

  1. 发起游戏。
  2. 定义游戏规则。
  3. 说出 3 个不重复的个位数数字。
  4. !!! 学生报数。
    • 若是是第一个特殊数字的倍数,就报 Fizz。
    • 若是是第二个特殊数字的倍数,就报 Buzz。
    • 若是是第三个特殊数字的倍数,就报 Whizz。
    • 若是同时是多个特殊数字的倍数,须要按特殊数字的顺序把对应的单词拼接起来再报出,好比 FizzBuzz、BuzzWhizz、FizzBuzzWhizz。
    • 若是包含第一个特殊数字,只报 Fizz (忽略规则 一、二、三、4)
    • 若是不是特殊数字的倍数,而且不包含第一个特殊数字,就报对应的序号。
  5. 验证入参。

测试报告:

测试覆盖率:

截止到目前一共编写了 9 个单元测试并驱动出“学生报数”功能,测试覆盖率几乎到达 100%(除了 Student 构造函数没有被覆盖),完成了案例中最核心的功能。在这个过程经过实践不断加深对 TDD 总体流程的理解,慢慢熟悉如何识别代码中的坏味道,同时也掌握一些重构手法,有趣的是我以前一直觉得分析技术只会在需求分析和任务分解这两个阶段才会用到,如今看来在编程的过程当中常常会使用到分析技术,收获还不错,可别忘了还有一点,在这个过程当中本身变得愈来愈自信,愈来愈有勇气去写更好的代码。

阅读系列文章

源码

github.com/lynings/tdd…


欢迎关注个人微信订阅号,我将会持续输出更多技术文章,但愿咱们能够互相学习。

相关文章
相关标签/搜索