クリスマスといえば、ドイツ産のボードゲーム おばけキャッチ である。1
ということで、大切なクリスマスのために、 TypeScript + Karma + Jasmine を使用した受け入れテスト駆動開発 (以下、ATDD)で、おばけキャッチをプログラミングした。
TL;DR
- ATDDサイクルの導入により、仕様を理解したうえで、不具合がないことを担保した開発ができる。成果物としてのソースコードはテスト結果に代わり、関係者は製品品質を確認できる。
- おばけキャッチ を題材にATDDの練習をし、フロントエンドのテスト駆動開発(以下、TDD)の啓蒙の材料とすることを試みる。
- シンプルなゲームだが、意外と大変だった。でも卑近なゲームのロジックを分解するのは面白い。
- 最終成果物は Github にホストしてある。
環境構築
環境構築はこの記事の主旨ではないので、簡便化するために @angular/cli で Angular の開発環境を整えた。これにより、Karma + Jasmine によるSpecテスト の記述がいきなり始められる。
Given-When-Then のPolyfillの実装
標準的な Karma + Jasmine のSpecテストの実装は Describe-It 形式だが、Martin Fowler の提唱する Given-When-Then 形式で書きたいので、自分でPolyfillを用意した。2
export function Given(context: string, fn: () => void) { describe(`Given: ${context}`, fn); }
export function When(context: string, fn: () => void) { describe(`When: ${context}`, fn); }
export function Then(context: string, fn: () => void) { it(`Then: ${context}`, fn); }
仕様の記述
ATDDでは、 実装コードを書く前に仕様をテストコードに起こす。つまり、開発したコードを、先に要求されている受け入れテストで検証するアプローチだ。これにより、あとから「どういう仕様なのかわからない」と言い訳したり、「なぜか動いているけど大丈夫」のような根拠のない自信に不安を覚えることもなくなる。
引用: https://www.planetgeek.ch/2012/06/12/acceptance-test-driven-development/ テスト駆動開発に関しては、Kent Beck「テスト駆動開発」が教典であるとされている。
おばけキャッチ の前提仕様の実装
仕様は、誰が読んでも(エンジニア・デザイナー・プロダクトオーナー・セールス担当者・問い合わせ担当、そして顧客が読んでも)明瞭で意味のわかる言葉で書かれていなければならない。
Given('ゲーム開始時', () => {
  const ghostBlitz = new GhostBlitz();
  Then('5つのアイテムが提示されていること', () => {
    expect(ghostBlitz.items.length).toBe(5);
  });
  Then('すべてのアイテムが摑まれていないこと', () => {
    expect(ghostBlitz.getGrabbed()).toBeUndefined();
  });
  Then('カードがめくられていないこと', () => {
    expect(ghostBlitz.currentCard).toBeUndefined();
  });
});
テストコードのみが存在する状態で最低限の型エラーやUnexpectedエラー等を解消しテストを実行すると、以下のようにFailする。

そこで、まずは 5つのアイテムが提示されていること を以下のように実装するなどし、 最速でテストをPass させる。
items: Array<Item> = [
  { name: 'オバケ', color: '白' },
  { name: 'ネズミ', color: '灰' },
  { name: 'いす', color: '赤' },
  { name: 'ボトル', color: '緑' },
  { name: '本', color: '青' },
];
おばけキャッチ のゲーム開始時の状態の実装
前提条件を満たすことができたら、次はゲームが開始された状態のテストコードを仕様として記述する。
Given('カードがめくられていない状態で', () => {
  const ghostBlitz = new GhostBlitz();
  ghostBlitz.generateNewCard();
  When('新しいカードがめくられたとき', () => {
    Then('カードに2つのアイテムが記載されていること', () => {
      expect(ghostBlitz.currentCard.length).toBe(2);
    });
    Then('カードに記載されているアイテムが互いに異なること', () => {
      const itemNames = ghostBlitz.currentCard.map(item => item.name);
      const uniqueItemNames = itemNames.filter((x, i, self) => self.indexOf(x) === i);
      expect(uniqueItemNames.length).toBe(2);
    });
    Then('カードに記載されているアイテムの色が互いに異なること', () => {
      const itemColors = ghostBlitz.currentCard.map(item => item.color);
      const uniqueItemColors = itemColors.filter((x, i, self) => self.indexOf(x) === i);
      expect(uniqueItemColors.length).toBe(2);
    });
  });
});
まだ実装をしていないので、当然テストはFailする。

そこで、このテストを満たすための実装コードを記述する。ただし、ここでは カードに2つのアイテムが記載されていること のみを、まずは満たす。
generateNewCard(): void {
  this.currentCard = [
    { name: undefined, color: undefined },
    { name: undefined, color: undefined },
  ];
}
これにより、 カードに2つのアイテムが記載されていること は満たしたが、 カードに記載されているアイテムが互いに異なること が満たされていないことを確認できる。

そこで、さらに実装を修正する。ここでは テストが通りさえすればよい ので、Itemの情報をハードコーディングしてしまって構わない。
generateNewCard(): void {
  this.currentCard = [
    { name: 'オバケ', color: '白' },
    { name: 'ネズミ', color: '灰' },
  ];
}
- 仕様をテストとして記述する
- まだ実装していないので、テストがFailすることを確認する4
- テストをPassするための実装を行う
- テストがPassすることを確認する
- (Code Smellがあれば)リファクタリングを行う
というTDDのサイクルを、受け入れテスト仕様ごとに行うのが、ATDDのおおまかな流れである。このようにして、すべてのテストをPassさせる。

おばけキャッチ のゲーム開始後の正解判定の実装 (1)
その時点でのすべてのテストが通ったことを確認し、その時点ではCode Smellが存在しないので、リファクタリングをせずにさらに次の実装に進む。
When('新しいカードがめくられ、', () => {
  When('白いオバケと赤のネズミが記載されているとき', () => {
    ghostBlitz.currentCard = [
      { name: 'オバケ', color: '白' },
      { name: 'ネズミ', color: '赤' },
    ];
    Then('白いオバケが正解であること', () => {
      expect(ghostBlitz.getCorrectItem()).toEqual(
        { name: 'オバケ', color: '白' },
      );
    });
  });
});
getCorrectItem(): Item {
  return { name: undefined, color: undefined };
}

同様に、テストが通りさえすればよいので、テストが通るための実装として、正解のItem情報をハードコーディングする。
getCorrectItem(): Item {
  return { name: 'オバケ', color: '白' };
}
「白いオバケ」の場合のみにPassする実装に意味があるのか? と考えたくなるが、雰囲気で修正するのではなく、テストを追加することでその妥当性を確認する。
When('赤いいすと灰色のボトルが記載されているとき', () => {
  ghostBlitz.currentCard = [
    { name: 'いす', color: '赤' },
    { name: 'ボトル', color: '灰' },
  ];
  Then('赤いいすが正解であること', () => {
    expect(ghostBlitz.getCorrectItem()).toEqual(
      { name: 'いす', color: '赤' },
    );
  });
});
期待どおりテストがFailしている。

そこで、 getCorrectItem() に引数として currentCard の情報を渡し、ちゃんとカードに合った正解が得られるように実装を修正しよう。
Then('白いオバケが正解であること', () => {
  expect(ghostBlitz.getCorrectItem(ghostBlitz.currentCard)).toEqual(
    { name: 'オバケ', color: '白' },
  );
});
Then('赤いいすが正解であること', () => {
  expect(ghostBlitz.getCorrectItem(ghostBlitz.currentCard)).toEqual(
    { name: 'いす', color: '赤' },
  );
});
getCorrectItem(currentCard: Array<Item>): Item {
  if (!Array.isArray(currentCard)) return;
  const matched = this.items.filter(item => {
    return (item.name === currentCard[0].name && item.color === currentCard[0].color)
        || (item.name === currentCard[1].name && item.color === currentCard[1].color);
  });
  if (matched.length === 1) return matched[0];
}
おばけキャッチ のゲーム開始後の正解判定の実装 (2)
おばけキャッチの仕様では、 一致する絵柄が存在しない場合、オブジェ・色情報がいずれもカードに含まれないアイテムが正解となる という発展ルールがある。そこで、そのパターンのTDDも行う。
When('赤いオバケと灰色のボトルが記載されているとき', () => {
  ghostBlitz.currentCard = [
    { name: 'オバケ', color: '赤' },
    { name: 'ボトル', color: '灰' },
  ];
  Then('青い本が正解であること', () => {
    expect(ghostBlitz.getCorrectItem(ghostBlitz.currentCard)).toEqual(
      { name: '本', color: '青' },
    );
  });
});
テストがFailしたことを確認できる。

これにより、 先ほどの実装では、この仕様を満たしていない ということを確認できた。さらに実装コードの変更が必要だ。
const matched2 = this.items.filter(item => {
  return item.name !== currentCard[0].name
      && item.name !== currentCard[1].name
      && item.color !== currentCard[0].color
      && item.color !== currentCard[1].color;
});
if (matched2.length === 1) return matched2[0];
matched2 という適当極まりない定数名でよいので実装を追加し、テストが通ることを確認する。

リファクタリング
getCorrectItem() が Long Method になってしまったので、リファクタリングを行う。
getCorrectItem(currentCard: Array<Item>): Item {
  if (!Array.isArray(currentCard)) return;
  const perfectlyMatched = this.getPerfectlyMatched(currentCard);
  const perfectlyUnmatched = this.getPerfectlyUnmatched(currentCard);
  return perfectlyMatched || perfectlyUnmatched;
}
private getPerfectlyMatched(currentCard: Array<Item>): Item {
  const matched = this.items.filter(item => {
    return (item.name === currentCard[0].name && item.color === currentCard[0].color)
        || (item.name === currentCard[1].name && item.color === currentCard[1].color);
  });
  if (matched.length === 1) return matched[0];
}
private getPerfectlyUnmatched(currentCard: Array<Item>): Item {
  const matched = this.items.filter(item => {
    return item.name !== currentCard[0].name
        && item.name !== currentCard[1].name
        && item.color !== currentCard[0].color
        && item.color !== currentCard[1].color;
  });
  if (matched.length === 1) return matched[0];}
}
もう一度テストを行い、挙動が変わっていないこと(テストをPassすること)を確認したら、次へ進もう。
「キャッチする」行為(回答)のテスト
ちゃんと正解が何であるか判定できるようになったので、実際に「キャッチ」した場合に、それが正解だったか不正解だったかを判定しよう。同様に、TDDにしたがってテストコードと実装コードを記述していく。
Given('カードがめくられていない状態で', () => {
  When('オバケを摑んだとき', () => {
    const ghostBlitz = new GhostBlitz();
    Then('不正解になること', () => {
      ghostBlitz.grab('オバケ');
      expect(ghostBlitz.isCorrect).toBeFalsy();
// 中略
When('新しいカードがめくられ、', () => {
  When('白いオバケと赤のネズミが記載されているとき', () => {
    // 中略
    When('白いオバケを摑んだとき', () => {
      Then('正解になること', () => {
        ghostBlitz.grab(ghostBlitz.currentCard, 'オバケ');
        expect(ghostBlitz.isCorrect).toBeTruthy();
  // 中略
  When('赤いいすと灰色のボトルが記載されているとき', () => {
    // 中略
    When('ネズミを摑んだとき', () => {
      Then('不正解になること', () => {
        ghostBlitz.grab(ghostBlitz.currentCard, 'ネズミ');
        expect(ghostBlitz.isCorrect).toBeFalsy();
  // 中略
  When('赤いオバケと灰色のボトルが記載されているとき', () => {
    // 中略
    When('本を摑んだとき', () => {
      Then('正解になること', () => {
        ghostBlitz.grab(ghostBlitz.currentCard, '本');
        expect(ghostBlitz.isCorrect).toBeTruthy();
  // 後略
grab(currentCard: Array<Item>, itemName: string): void {
  if (!Array.isArray(currentCard)) return;
  if (this.getCorrectItem(currentCard).name === itemName) {
    this.isCorrect = true;
  }
}
これで、カードがめくられて、キャッチすることで、それが正解か不正解か判定する、という流れのTDDができた。5
さらなる仕様の追加:正解の一元性の担保
ところで、現状の実装コードでのcurrentCardの中身は以下のようになっている。
this.currentCard = [
  { name: 'オバケ', color: '白' },
  { name: 'ネズミ', color: '灰' },
];
このカードは、実は実際には存在しえない。なぜなら 「白いオバケ」と「灰色のネズミ」の両方が正解になってしまうカードは、仕様上存在しない からだ。そこで、 両方が正解ではないこと(正解がひとつのみ存在すること) をテストで担保しよう。6

currentCard 情報として上記の配列を渡すとテストが落ちることが確認できる。 currentCard のジェネレータを作成しよう。
generateNewCard(): void {
  const firstItemName = this.decideFirstItemName();
  const firstItemColor = this.decideFirstItemColor();
  const secondItemName = this.decideSecondItemName({
    name: firstItemName,
    color: firstItemColor
  });
  this.currentCard = [
    {
      name: firstItemName,
      color: firstItemColor
    }, {
      name: secondItemName,
      color: this.decideSecondItemColor(
        {
          name: firstItemName,
          color: firstItemColor
        },
        secondItemName
      )
    },
  ];
}
decideFirstItemName(): Names {
  return this.items[Math.floor(Math.random() * this.items.length)].name;
}
decideFirstItemColor(): Colors {
  return this.items[Math.floor(Math.random() * this.items.length)].color;
}
decideSecondItemName(firstItem: Item): Names {
  const targets = this.items.filter(item => {
    return item.name !== firstItem.name && item.color !== firstItem.color;
  });
  return targets[Math.floor(Math.random() * targets.length)].name;
}
decideSecondItemColor(firstItem: Item, secondItemName: Names): Colors {
  let isFirstItemPerfectlyMatched = false;
  let targets: Array<Item>;
  for (let i = 0; i < this.items.length; i++) {
    if (this.items[i].name === firstItem.name && this.items[i].color === firstItem.color) {
      isFirstItemPerfectlyMatched = true;
      break;
    }
  }
  if (isFirstItemPerfectlyMatched) {
    const targets = this.items.filter(item => {
      return item.name !== firstItem.name && item.name !== secondItemName;
    });
    return targets[Math.floor(Math.random() * targets.length)].color;
  } else {
    const targets = this.items.filter(item => {
      return item.color !== firstItem.color && item.name !== firstItem.name;
    });
    return targets[Math.floor(Math.random() * targets.length)].color;
  }
}
こうしてテストをPassする実装ができた。しかし、あまりにも冗長であるので、リファクタリングを行う。
generateNewCard(): void {
  const firstItem = { name: this.decideFirstItemName(), color: this.decideFirstItemColor() };
  const secondItemName = this.decideSecondItemName(firstItem);
  const secondItemColor = this.decideSecondItemColor(firstItem, secondItemName);
  const secondItem = { name: secondItemName, color: secondItemColor };
  this.currentCard = [ firstItem, secondItem ];
}
private getRandom(array: Array<any>): any {
  return array[Math.floor(Math.random() * array.length)];
}
private decideFirstItemName(): Names {
  return this.getRandom(this.items).name;
}
private decideFirstItemColor(): Colors {
  return this.getRandom(this.items).color;
}
private decideSecondItemName(firstItem: Item): Names {
  const targets = this.items.filter(item => {
    return item.name !== firstItem.name && item.color !== firstItem.color;
  });
  return this.getRandom(targets).name;
}
private decideSecondItemColor(firstItem: Item, secondItemName: Names): Colors {
  let isFirstItemPerfectlyMatched = false;
  for (let i = 0; i < this.items.length; i++) {
    if (this.items[i].name === firstItem.name && this.items[i].color === firstItem.color) {
      isFirstItemPerfectlyMatched = true;
      break;
    }
  }
  const targets = this.items.filter(item => {
    if (isFirstItemPerfectlyMatched) {
      return item.name !== firstItem.name && item.name !== secondItemName;
    } else {
      return item.color !== firstItem.color && item.name !== firstItem.name;
    }
  });
  return this.getRandom(targets).color;
}
まだリファクタリングの余地はあるが、ここでひとつ不安を持つ。 ランダムに生成した currentCard が、たまたまテストをPassするパターンだっただけなのではないか?
ランダムな currentCard がいつでも仕様を満たすことの確認
テストコードがすでにあるので、上記のような不安もテストで客観的に証明ができる。以下のように100回試行するテストを追加することで、テストの妥当性を約束できる。
When('カードが100枚めくられたとき', () => {
  for (let i = 0; i < 100; i++) {
    const ghostBlitz = new GhostBlitz();
    ghostBlitz.generateNewCard();
    Then(`${i}枚目のカードに記載されているアイテムに正解が存在すること`, () => {
      expect(ghostBlitz.getCorrectItem(ghostBlitz.currentCard)).toBeTruthy();
    });
  }
});

これで、合計117件のテストをPassすることがわかった。誰が読んでもわかるように、多少の文言の変更を行い、あらためてテストを実行した結果の全文が以下である。
おばけキャッチ
  Given: ゲーム開始時
    Then: 5つのアイテムが提示されていること
    Then: すべてのアイテムが摑まれていないこと
    Then: カードがめくられていないこと
  Given: カードがめくられていない状態で
    When: オバケを摑んだとき
      Then: 不正解になること
    When: 新しいカードがめくられたとき
      Then: カードに2つのアイテムが記載されていること
      Then: カードに記載されているアイテムが互いに異なること
      Then: カードに記載されているアイテムの色が互いに異なること
      Then: カードに記載されているアイテムに正解が存在すること
    When: カードが100枚めくられたとき
      Then: 0枚目のカードに記載されているアイテムに正解が存在すること
      // 中略
      Then: 99枚目のカードに記載されているアイテムに正解が存在すること
    When: 新しいカードがめくられ
      When: 白いオバケと赤のネズミが記載されているとき
        Then: 白いオバケが正解であること
        When: オバケを摑んだとき
          Then: 正解になること
    When: 新しいカードがめくられ
      When: 赤いいすと灰色のボトルが記載されているとき
        Then: 赤いいすが正解であること
      When: ネズミを摑んだとき
        Then: 不正解になること
    When: 新しいカードがめくられ
      When: 赤いオバケと灰色のボトルが記載されているとき
        Then: 青い本が正解であること
        When: 本を摑んだとき
          Then: 正解になること
    When: 新しいカードがめくられ
      When: 赤いオバケと緑の本が記載されているとき
        Then: 灰色のネズミが正解であること
        When: ネズミを摑んだとき
          Then: 不正解になること
        When: ネズミと叫んだとき
          Then: 正解になること
おわりに
最終成果物は Github にホストしたが、思っていたより時間がかかった。シンプルなゲームだが、実際に内部ロジックを分解して再発明してみると──時計を分解して螺子や歯車の構造を見るのに似て──その条件分岐の複雑さに驚く。
私の所属する会社組織ではアジャイル・スクラムの導入に伴いATDD/TDDが当たり前になってきたが、これをはじめとしてその他種々の方法で不具合の無い/少ないプロダクトを実現したいものである。
ところで、今回はロジックの実装のみを行い、プレイできる画面は作らなかった。なぜか?
だって もうすでに公式のスマホアプリがある し、なんだかんだで本物をやったほうが楽しいもの......
- 
クリスマス => サンタクロース => 赤と白 => いすとオバケ => おばけキャッチ という単純明快なロジックである。 ↩ 
- 
jasmine-given というnpmパッケージを見つけたが、使い方がわからなかったのでやめた。 ↩ 
- 
いまになって見直すと、 ゲーム開始時はGivenではなくWhenが適切だったかもしれない。 ↩
- 
Failすることを確認しなければいけない理由は、「テストが通ってしまうと、意図せぬ実装をしてしまっていた」と判断でき、過去の実装に問題があった可能性に気づけるからだ。 ↩ 
- 
実際には「キャッチする」以外にも「叫ぶ」というアクションも存在する。その実装はスペースの兼ね合いで割愛するので、 Github を参照されたい。 ↩ 
- 
これは先に実装したほうがスムーズだったかもしれない。 ↩ 
- 
余談だが、Amazonのレビューで 「ネズミの耳の位置がずれていた」というレビュー を見つけた。おばけキャッチそのものにもATDDが必要か、あるいは「ネズミの耳の位置」については仕様が定められていなかったか、どちらだろうか。 ↩ 





