如何写一个国际象棋的游戏(第五部分)【前端如何写单元测试,让你更加自信】

877 阅读5分钟

“我正在参加掘金社区游戏创意投稿大赛个人赛,详情请看:游戏创意投稿大赛

代码仓库,走过路过点一个 Star ✨

在移动之前

我们的目的是移动这个棋子,实际上我们是对于这个棋盘的状态进行一个处理,让 React 去重新渲染出来就行,如果说要考虑性能的问题,我是不相信一个吃我大几百兆内存的现代浏览器连几百个 Dom 节点渲染都会卡的,回到上一句话,我们对于棋盘的处理实际上就是变成了对这个二维数组进行处理。

移动这些棋子

在不考虑是否正确移动的情况下,第一次点击会选中一个棋子,第二次我们会去选中一个目标格子,然后我们对数组中这两个值进行处理,把原来的格子更新为空,把目标格子上的棋子更新为我们之前选中的棋子。

// Before
// -> '   +------------------------+
//      8 | r  n  b  q  k  b  n  r |
//      7 | p  p  p  p  .  p  p  p |
//      6 | .  .  .  .  .  .  .  . |
//      5 | .  .  .  .  p  .  .  . |
//      4 | .  .  .  .  P  P  .  . |
//      3 | .  .  .  .  .  .  .  . |
//      2 | P  P  P  P  .  .  P  P |
//      1 | R  N  B  Q  K  B  N  R |
//        +------------------------+
//          a  b  c  d  e  f  g  h'
// After
// -> '   +------------------------+
//      8 | r  n  b  q  k  b  n  r |
//      7 | p  p  .  p  .  p  p  p |
//      6 | .  .  .  .  .  .  .  . |
//      5 | .  .  p  .  p  .  .  . |
//      4 | .  .  .  .  P  P  .  . |
//      3 | .  .  .  .  .  .  .  . |
//      2 | P  P  P  P  .  .  P  P |
//      1 | R  N  B  Q  K  B  N  R |
//        +------------------------+
//          a  b  c  d  e  f  g  h'

最终这个棋子的移动问题就变成了数组处理的问题。

export const initMap = [
    [BLACK.rock, BLACK.knight, BLACK.bishop, BLACK.queen, BLACK.king, BLACK.bishop, BLACK.knight, BLACK.rock],
    [BLACK.pawn, BLACK.pawn, BLACK.pawn, BLACK.pawn, BLACK.pawn, BLACK.pawn, BLACK.pawn, BLACK.pawn],
    [FUNC.blank, FUNC.blank, FUNC.blank, FUNC.blank, FUNC.blank, FUNC.blank, FUNC.blank, FUNC.blank,],
    [FUNC.blank, FUNC.blank, FUNC.blank, FUNC.blank, FUNC.blank, FUNC.blank, FUNC.blank, FUNC.blank,],
    [FUNC.blank, FUNC.blank, FUNC.blank, FUNC.blank, FUNC.blank, FUNC.blank, FUNC.blank, FUNC.blank,],
    [FUNC.blank, FUNC.blank, FUNC.blank, FUNC.blank, FUNC.blank, FUNC.blank, FUNC.blank, FUNC.blank,],
    [WHITE.pawn, WHITE.pawn, WHITE.pawn, WHITE.pawn, WHITE.pawn, WHITE.pawn, WHITE.pawn, WHITE.pawn],
    [WHITE.rock, WHITE.knight, WHITE.bishop, WHITE.queen, WHITE.king, WHITE.bishop, WHITE.knight, WHITE.rock],
];
​
interface CellProps {
    col: number;
    row: number;
}
​
export const move = (board: string[][], startPos: CellProps, endPos: CellProps) => {
    const startPiece = board[startPos.row][startPos.col]
    if (isEqual(startPos, endPos)) {
        return board;
    }
    return board.map((row, rowIndex) => {
        return row.map((col, colIndex) => {
            if (rowIndex === endPos.row && colIndex === endPos.col) {
                return startPiece;
            } if (rowIndex === startPos.row && colIndex === startPos.col) {
                return FUNC.blank;
            } else {
                return col;
            }
        })
    })
}

写上一些测试是一个好的习惯

你应该听说过 TDD(Test Driven Development),BDD(Behavior Driven Development)这些单词,你可能并没有真正意义上写过几个测试,也许你会把测试这个动作后置,你先写出了实现的代码,然后才编写测试,这样其实你陷入了一个陷阱,你会为了通过测试,而使得你故意去忽略掉了一些 edge cases,这样其实是违反了 TDD 的原则的,而 TDD 是需要你先定义好这个函数的边界,写出这个测试,把可能出现的情况想透彻,然后再去编写你的函数。So called test driven development. 因此你需要前置这个动作,虽然在一开始你会很难受,会觉得这样我功能都来不及写,还写测试。在你最后修改整个项目代码的时候,你就知道当时这些测试是多么的明智,多么痛的领悟,如果你的项目有详尽的测试,那么你在修改你功能函数的时候,你会更加自信。而不会担心下面这种极端的情况出现。

image-20220330213251696.png

这里写上一个针对这个 move 函数的测试,其实测试并不难写,测试的本质就是我期望这输入在各种情况下得到什么样的输出?只要牢记这个问题,你就能写出测试。

这里我们使用 create-react-app 自带的 jest 框架进行一个简单的测试,你只需要 npm run test 就可以运行测试了,create-react-app 还很贴心的加上了 watch 模式,所以我可以边写边保存边看结果,而不需要去命令行中重复的去运行这个命令。这里对于如何配置不会多描述,官方文档上面写的非常详细,各个环境,各个语言都有很详细的描述,足够 cover 运行起一个简单的测试。

这个 move函数在src/operations/index.ts 中, 首先我们在相同的目录下面新建一个文件叫做index.test.ts在按照文档中配置完成之后,实际上用 create-react-app 的话你不需要做很多额外的配置,如果你是使用 TypeScript 的话则需要加上 "@types/jest": "^27.4.0",这个 deps就可以了。

那么接下来我们就可以写测试了,回到刚才提到的,我期望这输入在各种情况下得到什么样的输出?所以 Jest 对应的 DSL 也是非常直接

test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3);
});

expect(sum(1, 2)).toBe(3);其实这里就非常直观了,我期望 1 + 2 = 3 。

回到我们的 move函数,我们期望什么?

test('board: from [0][0] to [0][0] did not move', () => {});
​
test('board: from [0][0] to [0][3] did move', () => {});

那么上面的期望就是从 [0 , 0] 移动到 [0, 0],这是不是没有移动?是的这个没有移动。那从 [0, 0] 移动到 [0, 3] 这个是不是移动了?没错,就是这么简单。那接下来我们需要实现这个测试

import { move } from './index';
test('board: from [0][0] to [0][0] did not move', () => {
    const startPos = { col: 0, row: 0 }
    const endPos = { col: 0, row: 0 }
    const newBoard = move(initMap, startPos, endPos);
    expect(newBoard).toEqual(initMap);
});
​
test('board: from [0][0] to [0][3] did move', () => {
    const startPos = { col: 0, row: 0 }
    const endPos = { col: 0, row: 3 }
    const newBoard = move(initMap, startPos, endPos);
    expect(newBoard[startPos.row][startPos.col]).toBe(FUNC.blank);
    expect(newBoard[endPos.row][endPos.col]).toBe(initMap[startPos.row][startPos.col]);
});

那这样两个测试就写好啦。

使用 Scenario

仔细看上面两个测试,我都写了board: from ...那这种情况下,可以看出这两个测试是同属一个测试场景的,那么我们可以把以上代码改造成如下的形式

describe('moves on board', () => {
  const startPos = { col: 0, row: 0 }
  const endPosA = { col: 0, row: 0 }
  const endPosB = { col: 0, row: 3 }
  
  test('from [0][0] to [0][0] did not move', () => {
      const newBoard = move(initMap, startPos, endPosA);
      expect(newBoard).toEqual(initMap);
  });
​
  test('from [0][0] to [0][3] did move', () => {
      const newBoard = move(initMap, startPos, endPosB);
      expect(newBoard[startPos.row][startPos.col]).toBe(FUNC.blank);
      expect(newBoard[endPosB.row][endPosB.col]).toBe(initMap[startPos.row][startPos.col]);
  });
})

最终你可能得到以下的输出,因为我还写了其他的测试,所以最终结果会变成这样。

 PASS  src/operations/index.test.ts
  get pieces
    ✓ white pieces (2 ms)
    ✓ black pieces (1 ms)
    ✓ empty grid
  get grid axis
    ✓ grid axis (1 ms)
​
Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        2.113 s
Ran all test suites.
​
Watch Usage: Press w to show more.