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 举报,一经查实,本站将立刻删除。