TDD基础-红绿重构练习之“井字游戏“

TDD基础-红绿重构练习之"井字游戏"

“红灯-绿灯-重构”流程是TDD的基石,以极快的速度在测试和实现代码之间切换。期间将失败,然后成功,然后重构,测试,整个过程不断重复,以此打造高质量代码和高质量产品功能。

1. 红绿重构过程

红绿重构源自代码在周期内的状态:处于红灯状态时,代码不管用;处于绿灯状态时,一切如预期一样工作,但并不一定是最佳;到了重构阶段,由于我们有良好的测试覆盖了各项功能,这个时候可以充满信心的去重构

1.1 编写一个测试

每次添加新功能时,先编写一个测试,这么做的目的是编写代码前专注于需求和代码设计,理清为了实现目标,需要设计什么样的功能,功能需要什么样的输入及输出。维护良好的测试亦是维护可执行的文档,在日后可以帮助理解代码目的和背后的意图。

在这个阶段,我们处于红灯状态,测试应该是不能通过的,如果测试通过反而预示着问题,测试本身设计不足以检查改功能或该功能已经被实现。

1.2 运行所有测试并确认新增测试未通过

运行所有测试的目的是确保已有的功能都没有被影响,确保最后一个未通过是由于当前依然处于红灯状态。该阶段由于是新增了一个测试,也可只运行当前新增测试,保证其不能通过。

1.3 编写实现代码

该阶段目标是使1.2种未通过的测试通过,该阶段的核心在于让测试通过,不必试图让代码完美(事实上,很难在第一次做功能实现时就可以实现的极其完美),不必花太多时间,也不必引入功能之外的设计,即便写的不好或不是最后的也没关系,后面还有改进的机会(后面不是指交付以后),这里的核心意图是打造一个由测试构成的安全基线,改不过不要引入新的功能,如果需要引入回到第一步,重新去考虑需求和代码设计,该阶段依然处于红灯。

1.4 运行所有测试

运行所有测试,保证新编写的代码不会破坏其他功能,过程中如果整个测试执行速度缓慢,就意味着测试设计的不好或者耦合度太高,耦合度高将导致难以隔离外部依赖,进而增加执行测试的时间。当所有测试通过后,这时就处于绿灯状态。

1.5 重构

这一步是可选的,虽然不是每个周期结束后都会进行重构,但迟早甚至必须会这么做(需求的迭代,复杂度的提升),没有明确的规定说什么时候该重构、什么时候不用重构。不过一旦认为可以用更佳或更优的方式重写代码,那就是重构的最佳时机。

什么样的代码需要重构呢?这个问题不好回答,因为重构的原因有很多:代码难以理解、代码位置不合理、代码重复、名称没有清晰阐述意图、方法太长、类的功能太多等——这个清单可 不断列下去。不管原因是什么,最重要的规则是重构不能改变任何既有功能

1.6 重复

以上步骤完成后,再重复它们,整个过程虽然看起来很长,很复杂,但实际并非如此,不知道大家是否有过在有单测保障的情况下,重构代码的体验,整个过程是非常流畅和快速的,红绿重构的周期应该类似,并且随着实践经验的丰富,整个周期将越来越短。保持快速前进,快速失败并更正,然后再重复是整个过程的关键。

2. 红绿重构练习-井字游戏需求

2.1 需求描述

井子游戏是一种儿童游戏,两个人轮流在一个3X3的网格中画X和O,最先在水平、垂直对角线上将自己的3个标记连起来的玩家获胜。这个练习中将拆分明确的需求,并根据需求编写测试,再编写满足测试期望的代码,最后如有必要进行重构,并且将重复这个过程,对同一需求编写多个测试,一个一个的实现所有需求。实际写代码时不会预先制定练习中详细的需求列表(不可能定义每一个需要的方法),而是直接编写用作需求和验证的测试(直接撸测试+实现)。另外练习中的实现只是众多实现路径中的一种。

2.2 开发井字游戏

2.2.1 需求1:首先定义边界、以及棋子放在哪里是合法,哪里是不合法的。

首先设计一个棋盘,定义一个井字游戏类TicTacToe,并使用3X3的二维数组模拟网格,添加play方法,模拟下棋。

public class TicTacToe {

    private int[][] board = {{0, 0, 0}, {0, 0, 0}, {0, 0, 0}};
    
    public void play(int x, int y) {

    }
}

根据需求可分成三个测试(验证输入参数是否合法):

  • 如果棋子放在超出了X轴边界的地方,就引发RuntimeException异常;
  • 如果棋子放在超出了Y轴边界的地方,就引发RuntimeException异常;
  • 如果棋子放在已经有棋子的地方,就引发RuntimeException异常。
按红绿重构流程,编写第一个测试
public class TicTacToeTest {

    private TicTacToe ticTacToe;

    @Before
    public void before() {
        ticTacToe = new TicTacToe();
    }

    @Test(expected = RuntimeException.class)
    public void whenOutXSideThenThrowRuntimeException() {
        ticTacToe.play(5, 2);
    }
}

给测试方法指定描述性名称,好处之一是有助于理解测试的目标,帮助掌握有些测试失败的原因以及在什么情况下增加测试可提高代码覆盖率,在测试方法名中,应明确指出测试前设置的条件、执行的操作以及期望的结果,给测试方法命名的方式很多,推荐BDD场景使用的given/when/then语法给它们命名,其中Given描述前置条件,When描述操作,而Then描述 期望的结果。如果测试没有前置条件(这些条件通常是使用注解@Before和 @BeforeClass设置的),则可省略Given,不要完全依靠注释指出测试的目标,因为使用IDE执行测试时,注释不会出现,它们也不会出现在CI工具或构建工具生成的报告中。

运行第一个测试

运行maven test,而不是在测试类上运行指定方法,以此保证所有测试都可以通过,实际项目中可能由于测试用例缺乏维护,可采用maven-surefire-plugin插件,指定得到维护的测试运行,该插件可生成txt和xml的测试用例包报告,如果想生成html的使用maven-surefire-report-plugin插件,如果需要更详细的测试覆盖率等信息,推荐使用jacoco,本例中运行后如下,红灯失败:

-------------------------------------------------------
 T E S T S
-------------------------------------------------------
Running TicTacToeTest
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.081 sec <<< FAILURE!
whenOutXSideThenThrowRuntimeException(TicTacToeTest)  Time elapsed: 0.007 sec  <<< FAILURE!
java.lang.AssertionError: Expected exception: java.lang.RuntimeException
        at org.junit.internal.runners.statements.ExpectException.evaluate(ExpectException.java:34)
        at org.junit.internal.runners.statements.RunBefores.evaluate(RunBefores.java:26)
        at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
        at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
        at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
        at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
        at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
        at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
        at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
        at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
        at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
        at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
        at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
        at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
        at org.apache.maven.surefire.junit4.JUnit4Provider.execute(JUnit4Provider.java:252)
        at org.apache.maven.surefire.junit4.JUnit4Provider.executeTestSet(JUnit4Provider.java:141)
        at org.apache.maven.surefire.junit4.JUnit4Provider.invoke(JUnit4Provider.java:112)
        at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.lang.reflect.Method.invoke(Method.java:498)
        at org.apache.maven.surefire.util.ReflectionUtils.invokeMethodWithArray(ReflectionUtils.java:189)
        at org.apache.maven.surefire.booter.ProviderFactory$ProviderProxy.invoke(ProviderFactory.java:165)
        at org.apache.maven.surefire.booter.ProviderFactory.invokeProvider(ProviderFactory.java:85)
        at org.apache.maven.surefire.booter.ForkedBooter.runSuitesInProcess(ForkedBooter.java:115)
        at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:75)


Results :

Failed tests:   whenOutXSideThenThrowRuntimeException(TicTacToeTest): Expected exception: java.lang.RuntimeException

Tests run: 1, Failures: 1, Errors: 0, Skipped: 0

添加第一个测试的实现
public void play(int x, int y) {
    if (x > 3 || x < 1) {
        throw new RuntimeException("X is outSide board");
    }
}
再次运行测试,绿灯通过
-------------------------------------------------------
 T E S T S
-------------------------------------------------------
Running TicTacToeTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.058 sec

Results :

Tests run: 1, Failures: 0, Errors: 0, Skipped: 0

同理添加另外两个测试及实现

实际开发时可以同时实现这三个测试,TDD有建议标准,但在保证结果时以最符合自己实践的方式加以改造,行成自己的套路

// 测试
public class TicTacToeTest {

    private TicTacToe ticTacToe;

    @Before
    public void before() {
        ticTacToe = new TicTacToe();
    }

    @Test(expected = RuntimeException.class)
    public void whenOutXSideThenThrowRuntimeException() {
        ticTacToe.play(5, 2);
    }

    @Test(expected = RuntimeException.class)
    public void whenOutYSideThenThrowRuntimeException() {
        ticTacToe.play(1, 6);
    }

    @Test(expected = RuntimeException.class)
    public void whenOccupiedThenThrowRuntimeException() {
        ticTacToe.play(2, 1);
        ticTacToe.play(2, 1);
    }
}

// 实现
public class TicTacToe {
    // 初始棋盘坐标点为0,落子后为1
    private int[][] board = {{0, 0, 0}, {0, 0, 0}, {0, 0, 0}};

    public void play(int x, int y) {
        if (x > 3 || x < 1) {
            throw new RuntimeException("X is outside board");
        }
        if (y > 3 || y < 1) {
            throw new RuntimeException("Y is outside board");
        }
        if (board[x-1][y-1] != 0) {
            throw new RuntimeException("box is occupied");
        } else {
            board[x-1][y-1] = 1;
        }
    }
}
完成本需求的绿灯
-------------------------------------------------------
 T E S T S
-------------------------------------------------------
Running TicTacToeTest
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.062 sec

Results :

Tests run: 3, Failures: 0, Errors: 0, Skipped: 0

重构

现有实现代码虽然满足了测试指定的需求,但可读性不佳。其他人阅读这些代码,会搞不清 楚方法play的目的,因此重构方法:

public void play(int x, int y) {
    checkAxis(x);
    checkAxis(y);
    setBox(x, y);
}

private void checkAxis(int axis) {
    if (axis > 3 || axis < 1) {
        throw new RuntimeException("axis is outside board");
    }
}

private void setBox(int x, int y) {
    if (board[x-1][y-1] != 0) {
        throw new RuntimeException("box is occupied");
    } else {
        board[x-1][y-1] = 1;
    }
}

该重构没有更改任何已有代码功能,但代码的可读性增强了,且由于有覆盖所有功能的测试,因此只要保证所有测试都可以通过,且重构时没有引入新的行为,就可以放心大胆的重构。使用jacoco生成测试报告如下:

// pom中增加jacoco依赖
<dependency>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.8</version>
</dependency>

// 配置插件
<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.8</version>
    <configuration>
        <includes>
            <include>org/melody/tictactoe/**</include>
        </includes>
        <excludes>
            <exclude>org/melody/practice/**</exclude>
        </excludes>
    </configuration>
    <executions>
        <execution>
            <id>pre-test</id>
            <goals>
                <goal>prepare-agent</goal>
            </goals>
        </execution>
        <execution>
            <id>post-test</id>
            <phase>test</phase>
            <goals>
                <goal>report</goal>
            </goals>
        </execution>
    </executions>
</plugin>

// 执行测试
mvn clean test

测试报告在target的site目录下:

在这里插入图片描述


在这里插入图片描述


在这里插入图片描述

2.2.2 需求2:提供方法,可以判断接下来轮到谁落子。

设计测试:

  • 玩家A先下
  • 如果上一次是B先下,则接下来轮到A,反之则轮到B

通过先编写或修改测试,开发的人可在编写代码前专注于需求。这是与完成 实现后再编写测试的主要差别所在。测试先行的另一个好处是,可避免原本应为 质量保证的测试沦为质量检查。

测试:

@Test
public void givenFirstTurnWhenNextPlayerThenX() {
    Assert.assertEquals('A',ticTacToe.nextPlayer());
}

实现:

public char nextPlayer() {
    return 'A';
}

测试:

@Test
public void givenLastTurnWasAWhenNextPlayerThenB() {
    // A先下
    ticTacToe.play(2, 2);
    // 接下来是B
    Assert.assertEquals('B',ticTacToe.nextPlayer());
}

实现:

private char lastPlayer = ' ';

public void play(int x, int y) {
    checkAxis(x);
    checkAxis(y);
    setBox(x, y);
    lastPlayer = nextPlayer();
}

public char nextPlayer() {
    if (lastPlayer == 'A') {
        return 'B';
    }
    return 'A';
}

实现另一个测试,如果上一次B落子,下一个是A:

@Test
public void givenLastTurnWasBWhenNextPlayerThenA() {
    // A先下
    ticTacToe.play(2, 2);
    // B再下
    ticTacToe.play(1, 2);
    // 接下来是B
    Assert.assertEquals('A',ticTacToe.nextPlayer());
}

这时发现改测试在没有修改实现的情况下就能通过,说明该测试没有用处,应该删除。

2.2.3 需求3:最先在水平、垂直或对角线上将自己的3个标记连起来的玩家获胜。

测试,通过play的返回值判断是否获胜:

@Test
public void whenPlayThenNoWinner() {
    String result = ticTacToe.play(1, 1);
    assertEquals("No winner", result);
}

实现:

public String play(int x, int y) {
    checkAxis(x);
    checkAxis(y);
    setBox(x, y);
    lastPlayer = nextPlayer();
    return "No winner";
}

测试,水平方向获胜:

public String play(int x, int y) {
    checkAxis(x);
    checkAxis(y);
    lastPlayer = nextPlayer();
    setBox(x, y,lastPlayer);
    for (int index = 0; index < 3; index++) {
        if (board[0][index] == lastPlayer && board[1][index] == lastPlayer && board[2][index] == lastPlayer) {
            return lastPlayer + " is winner";
        }
    }
    return "No winner";
}

private void setBox(int x, int y,int lastPlayer) {
    if (board[x-1][y-1] != 0) {
        throw new RuntimeException("box is occupied");
    } else {
        board[x-1][y-1] = lastPlayer;
    }
}

重构,保持主流程的简洁:

private static final int SIZE = 3;

public String play(int x, int y) {
    checkAxis(x);
    checkAxis(y);
    lastPlayer = nextPlayer();
    setBox(x, y, lastPlayer);
    if (isWin()) {
        return lastPlayer + " is winner";
    }
    return "No winner";
}

private boolean isWin() {
    for (int index = 0; index < SIZE; index++) {
        if ((board[0][index] + board[1][index] + board[2][index]) == (lastPlayer * SIZE)) {
            return true;
        }
    }
    return false;
}

测试,垂直方向获胜:

@Test
public void whenPlayAndWholeVerticalLineThenWinner() {
    // A
    ticTacToe.play(2, 1);
    // B
    ticTacToe.play(1, 1);
    // A
    ticTacToe.play(3, 1);
    // B
    ticTacToe.play(1, 2);
    // A
    ticTacToe.play(2, 2);
    // B
    String result = ticTacToe.play(1, 3);
    assertEquals("B is winner", result);
}

实现:

private boolean isWin() {
    // 判断水平线胜负
    for (int i = 0; i < SIZE; i++) {
        if ((board[0][i] + board[1][i] + board[2][i]) == (lastPlayer * SIZE)) {
            return true;
        }
    }
    // 判断垂直线胜负
    for (int i = 0; i < SIZE; i++) {
        if ((board[i][0] + board[i][1] + board[i][2]) == (lastPlayer * SIZE)) {
            return true;
        }
    }
    return false;
}

测试,对角线获胜:

@Test
public void whenPlayAndTopBottomDiagonalLineThenWinner() {
    // A
    ticTacToe.play(1, 1);
    // B
    ticTacToe.play(1, 2);
    // A
    ticTacToe.play(2, 2);
    // B
    ticTacToe.play(1, 3);
    // A
    String result = ticTacToe.play(3, 3);
    assertEquals("A is winner", result);
}

实现:

// 左上到右下对角线
if ((board[0][0] + board[1][1] + board[2][2]) == (lastPlayer * SIZE)) {
    return true;
}

测试,左下到右上对角线:

// 左下到右上对角线
if ((board[0][2] + board[1][1] + board[2][0]) == (lastPlayer * SIZE)) {
    return true;
}

重构:

private boolean isWin() {
    int total = lastPlayer * SIZE;
    // 左下到右上对角线
    int diagonalBottomLeftToTopRight = 0;
    // 左上到右下对角线
    int diagonalTopLeftToBottomRight = 0;
    for (int i = 0; i < SIZE; i++) {
        diagonalBottomLeftToTopRight += board[i][i];
        diagonalTopLeftToBottomRight += board[i][SIZE - 1 - i];
        if ((board[0][i] + board[1][i] + board[2][i]) == total) {
            // 判断水平线胜负
            return true;
        } else if ((board[i][0] + board[i][1] + board[i][2]) == total) {
            // 判断垂直线胜负
            return true;
        }
    }
    if (diagonalBottomLeftToTopRight == total || diagonalTopLeftToBottomRight == total) {
        return true;
    }
    return false;
}

2.2.4 需求4:处理平局的情况。

测试,棋盘全都填满时则为平局:

@Test
public void whenAllBoxesAreFilledThenDraw() {
    ticTacToe.play(1, 1);
    ticTacToe.play(1, 2);
    ticTacToe.play(1, 3);
    ticTacToe.play(2, 1);
    ticTacToe.play(2, 3);
    ticTacToe.play(2, 2);
    ticTacToe.play(3, 1);
    ticTacToe.play(3, 3);
    String result = ticTacToe.play(3, 2);
    assertEquals("result is draw", result);
}

实现:

public String play(int x, int y) {
    checkAxis(x);
    checkAxis(y);
    lastPlayer = nextPlayer();
    setBox(x, y, lastPlayer);
    if (isWin()) {
        return lastPlayer + " is winner";
    } else if (isDraw()) {
        return "result is draw";
    }
    return "No winner";
}
    
private boolean isDraw() {
    for (int i = 0; i < SIZE; i++) {
        for (int j = 0; j < SIZE; j++) {
            if (board[i][j] == 0) {
                return false;
            }
        }
    }
    return true;
}

重构,只判断落子的水平、垂直和对角情况即可:

private boolean isWin(int x, int y) {
    int total = lastPlayer * SIZE;
    int diagonalBottomLeftToTopRight = 0, diagonalTopLeftToBottomRight = 0, horizontal = 0, vertical = 0;
    for (int i = 0; i < SIZE; i++) {
        diagonalBottomLeftToTopRight += board[i][i];
        diagonalTopLeftToBottomRight += board[i][SIZE - 1 - i];
        horizontal += board[i][y - 1];
        vertical += board[x - 1][i];
    }
    if (diagonalBottomLeftToTopRight == total
            || diagonalTopLeftToBottomRight == total
            || vertical == total
            || horizontal == total) {
        return true;
    }
    return false;
}

通常代码编写后立即进行 重构最容易也最快,但重构几天、几月甚至几年前编写的代码更可贵。发现可让代码更好就是重构它的最佳时机,至于代码是谁写的以及什么时候写的都不重要,毕竟让代码变得更好总是一件值得去做的好事。

最后看下测试覆盖率:

在这里插入图片描述

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。

相关推荐


学习编程是顺着互联网的发展潮流,是一件好事。新手如何学习编程?其实不难,不过在学习编程之前你得先了解你的目的是什么?这个很重要,因为目的决定你的发展方向、决定你的发展速度。
IT行业是什么工作做什么?IT行业的工作有:产品策划类、页面设计类、前端与移动、开发与测试、营销推广类、数据运营类、运营维护类、游戏相关类等,根据不同的分类下面有细分了不同的岗位。
女生学Java好就业吗?女生适合学Java编程吗?目前有不少女生学习Java开发,但要结合自身的情况,先了解自己适不适合去学习Java,不要盲目的选择不适合自己的Java培训班进行学习。只要肯下功夫钻研,多看、多想、多练
Can’t connect to local MySQL server through socket \'/var/lib/mysql/mysql.sock问题 1.进入mysql路径
oracle基本命令 一、登录操作 1.管理员登录 # 管理员登录 sqlplus / as sysdba 2.普通用户登录
一、背景 因为项目中需要通北京网络,所以需要连vpn,但是服务器有时候会断掉,所以写个shell脚本每五分钟去判断是否连接,于是就有下面的shell脚本。
BETWEEN 操作符选取介于两个值之间的数据范围内的值。这些值可以是数值、文本或者日期。
假如你已经使用过苹果开发者中心上架app,你肯定知道在苹果开发者中心的web界面,无法直接提交ipa文件,而是需要使用第三方工具,将ipa文件上传到构建版本,开...
下面的 SQL 语句指定了两个别名,一个是 name 列的别名,一个是 country 列的别名。**提示:**如果列名称包含空格,要求使用双引号或方括号:
在使用H5混合开发的app打包后,需要将ipa文件上传到appstore进行发布,就需要去苹果开发者中心进行发布。​
+----+--------------+---------------------------+-------+---------+
数组的声明并不是声明一个个单独的变量,比如 number0、number1、...、number99,而是声明一个数组变量,比如 numbers,然后使用 nu...
第一步:到appuploader官网下载辅助工具和iCloud驱动,使用前面创建的AppID登录。
如需删除表中的列,请使用下面的语法(请注意,某些数据库系统不允许这种在数据库表中删除列的方式):
前不久在制作win11pe,制作了一版,1.26GB,太大了,不满意,想再裁剪下,发现这次dism mount正常,commit或discard巨慢,以前都很快...
赛门铁克各个版本概览:https://knowledge.broadcom.com/external/article?legacyId=tech163829
实测Python 3.6.6用pip 21.3.1,再高就报错了,Python 3.10.7用pip 22.3.1是可以的
Broadcom Corporation (博通公司,股票代号AVGO)是全球领先的有线和无线通信半导体公司。其产品实现向家庭、 办公室和移动环境以及在这些环境...
发现个问题,server2016上安装了c4d这些版本,低版本的正常显示窗格,但红色圈出的高版本c4d打开后不显示窗格,
TAT:https://cloud.tencent.com/document/product/1340