6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

ZeneloAdvent Calendar 2018

Day 6

『おばけキャッチ』の受け入れテスト駆動開発による実装(或いは、TypeScript + Karma + Jasmine によるフロントエンドTDDのサンプル)

Last updated at Posted at 2018-12-05

クリスマスといえば、ドイツ産のボードゲーム おばけキャッチ である。1

ということで、大切なクリスマスのために、 TypeScript + Karma + Jasmine を使用した受け入れテスト駆動開発 (以下、ATDD)で、おばけキャッチをプログラミングした。

image.png
引用: http://www.mobius-games.co.jp/Zoch/GeistersBlitz.html

TL;DR

  • ATDDサイクルの導入により、仕様を理解したうえで、不具合がないことを担保した開発ができる。成果物としてのソースコードはテスト結果に代わり、関係者は製品品質を確認できる。
  • おばけキャッチ を題材にATDDの練習をし、フロントエンドのテスト駆動開発(以下、TDD)の啓蒙の材料とすることを試みる。
  • シンプルなゲームだが、意外と大変だった。でも卑近なゲームのロジックを分解するのは面白い。
  • 最終成果物は Github にホストしてある。

環境構築

環境構築はこの記事の主旨ではないので、簡便化するために @angular/cliAngular の開発環境を整えた。これにより、Karma + Jasmine によるSpecテスト の記述がいきなり始められる。

Given-When-Then のPolyfillの実装

標準的な Karma + Jasmine のSpecテストの実装は Describe-It 形式だが、Martin Fowler の提唱する Given-When-Then 形式で書きたいので、自分でPolyfillを用意した。2

src/assets/spec/util/given-when-then.ts
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では、 実装コードを書く前に仕様をテストコードに起こす。つまり、開発したコードを、先に要求されている受け入れテストで検証するアプローチだ。これにより、あとから「どういう仕様なのかわからない」と言い訳したり、「なぜか動いているけど大丈夫」のような根拠のない自信に不安を覚えることもなくなる。

image.png
引用: https://www.planetgeek.ch/2012/06/12/acceptance-test-driven-development/ テスト駆動開発に関しては、Kent Beck「テスト駆動開発」が教典であるとされている。

おばけキャッチ の前提仕様の実装

仕様は、誰が読んでも(エンジニア・デザイナー・プロダクトオーナー・セールス担当者・問い合わせ担当、そして顧客が読んでも)明瞭で意味のわかる言葉で書かれていなければならない。

image.png
引用: http://www.mobius-games.co.jp/Zoch/GeistersBlitz.html

おばけキャッチの前提仕様
Given('ゲーム開始時', () => {
  const ghostBlitz = new GhostBlitz();

  Then('5つのアイテムが提示されていること', () => {
    expect(ghostBlitz.items.length).toBe(5);
  });
  Then('すべてのアイテムが摑まれていないこと', () => {
    expect(ghostBlitz.getGrabbed()).toBeUndefined();
  });
  Then('カードがめくられていないこと', () => {
    expect(ghostBlitz.currentCard).toBeUndefined();
  });
});

3

テストコードのみが存在する状態で最低限の型エラーやUnexpectedエラー等を解消しテストを実行すると、以下のようにFailする。
Screen Shot 2018-12-01 at 10.33.05.png
そこで、まずは 5つのアイテムが提示されていること を以下のように実装するなどし、 最速でテストをPass させる。

おばけキャッチの前提仕様を満たす実装コードの一部
items: Array<Item> = [
  { name: 'オバケ', color: '' },
  { name: 'ネズミ', color: '' },
  { name: 'いす', color: '' },
  { name: 'ボトル', color: '' },
  { name: '', color: '' },
];

Screen Shot 2018-12-01 at 10.34.25.png

おばけキャッチ のゲーム開始時の状態の実装

前提条件を満たすことができたら、次はゲームが開始された状態のテストコードを仕様として記述する。

おばけキャッチのゲーム開始時の状態を担保するテスト
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する。
Screen Shot 2018-12-01 at 10.45.14.png
そこで、このテストを満たすための実装コードを記述する。ただし、ここでは カードに2つのアイテムが記載されていること のみを、まずは満たす。

「カードに2つのアイテムが記載されていること」のみを満たす実装
generateNewCard(): void {
  this.currentCard = [
    { name: undefined, color: undefined },
    { name: undefined, color: undefined },
  ];
}

これにより、 カードに2つのアイテムが記載されていること は満たしたが、 カードに記載されているアイテムが互いに異なること が満たされていないことを確認できる。
Screen Shot 2018-12-01 at 10.50.47.png
そこで、さらに実装を修正する。ここでは テストが通りさえすればよい ので、Itemの情報をハードコーディングしてしまって構わない。

「カードに記載されているアイテムが互いに異なること」を満たす実装
generateNewCard(): void {
  this.currentCard = [
    { name: 'オバケ', color: '' },
    { name: 'ネズミ', color: '' },
  ];
}

Screen Shot 2018-12-01 at 10.51.40.png
このように、

  1. 仕様をテストとして記述する
  2. まだ実装していないので、テストがFailすることを確認する4
  3. テストをPassするための実装を行う
  4. テストがPassすることを確認する
  5. (Code Smellがあれば)リファクタリングを行う

というTDDのサイクルを、受け入れテスト仕様ごとに行うのが、ATDDのおおまかな流れである。このようにして、すべてのテストをPassさせる。
Screen Shot 2018-12-01 at 10.52.31.png

おばけキャッチ のゲーム開始後の正解判定の実装 (1)

その時点でのすべてのテストが通ったことを確認し、その時点ではCode Smellが存在しないので、リファクタリングをせずにさらに次の実装に進む。

正解がオバケのカードである場合、オバケが正解と判定されるテスト
When('新しいカードがめくられ、', () => {
  When('白いオバケと赤のネズミが記載されているとき', () => {
    ghostBlitz.currentCard = [
      { name: 'オバケ', color: '' },
      { name: 'ネズミ', color: '' },
    ];

    Then('白いオバケが正解であること', () => {
      expect(ghostBlitz.getCorrectItem()).toEqual(
        { name: 'オバケ', color: '' },
      );
    });
  });
});
正解がオバケのカードである場合、オバケが正解と判定されるテストについて、型エラーやUnexpectedエラーのみを通す実装
getCorrectItem(): Item {
  return { name: undefined, color: undefined };
}

Screen Shot 2018-12-01 at 11.02.29.png
同様に、テストが通りさえすればよいので、テストが通るための実装として、正解のItem情報をハードコーディングする。

正解がオバケのカードである場合、オバケが正解と判定されるテストについて、そのテストを通すことだけを担保したテストの修正
getCorrectItem(): Item {
  return { name: 'オバケ', color: '' };
}

「白いオバケ」の場合のみにPassする実装に意味があるのか? と考えたくなるが、雰囲気で修正するのではなく、テストを追加することでその妥当性を確認する。

正解がいすのカードである場合、いすが正解と判定されるテスト
When('赤いいすと灰色のボトルが記載されているとき', () => {
  ghostBlitz.currentCard = [
    { name: 'いす', color: '' },
    { name: 'ボトル', color: '' },
  ];

  Then('赤いいすが正解であること', () => {
    expect(ghostBlitz.getCorrectItem()).toEqual(
      { name: 'いす', color: '' },
    );
  });
});

期待どおりテストがFailしている。
Screen Shot 2018-12-01 at 11.05.16.png
そこで、 getCorrectItem() に引数として currentCard の情報を渡し、ちゃんとカードに合った正解が得られるように実装を修正しよう。

currentCard情報をもとにした正解判定のためのテストコードの修正
Then('白いオバケが正解であること', () => {
  expect(ghostBlitz.getCorrectItem(ghostBlitz.currentCard)).toEqual(
    { name: 'オバケ', color: '' },
  );
});

Then('赤いいすが正解であること', () => {
  expect(ghostBlitz.getCorrectItem(ghostBlitz.currentCard)).toEqual(
    { name: 'いす', color: '' },
  );
});
currentCard情報をもとにした正解判定のための実装コードの修正
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];
}

Screen Shot 2018-12-01 at 11.32.51.png
テストがPassした!

おばけキャッチ のゲーム開始後の正解判定の実装 (2)

おばけキャッチの仕様では、 一致する絵柄が存在しない場合、オブジェ・色情報がいずれもカードに含まれないアイテムが正解となる という発展ルールがある。そこで、そのパターンのTDDも行う。

一致する絵柄が存在しない場合の正解判定のテスト
When('赤いオバケと灰色のボトルが記載されているとき', () => {
  ghostBlitz.currentCard = [
    { name: 'オバケ', color: '' },
    { name: 'ボトル', color: '' },
  ];

  Then('青い本が正解であること', () => {
    expect(ghostBlitz.getCorrectItem(ghostBlitz.currentCard)).toEqual(
      { name: '', color: '' },
    );
  });
});

テストがFailしたことを確認できる。
Screen Shot 2018-12-01 at 11.34.38.png
これにより、 先ほどの実装では、この仕様を満たしていない ということを確認できた。さらに実装コードの変更が必要だ。

一致する絵柄が存在しない場合の正解判定のテストを満たすための実装コード
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 という適当極まりない定数名でよいので実装を追加し、テストが通ることを確認する。
Screen Shot 2018-12-01 at 11.59.39.png

リファクタリング

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()メソッド
grab(currentCard: Array<Item>, itemName: string): void {
  if (!Array.isArray(currentCard)) return;

  if (this.getCorrectItem(currentCard).name === itemName) {
    this.isCorrect = true;
  }
}

これで、カードがめくられて、キャッチすることで、それが正解か不正解か判定する、という流れのTDDができた。5

さらなる仕様の追加:正解の一元性の担保

ところで、現状の実装コードでのcurrentCardの中身は以下のようになっている。

実装コードでのcurrentCardの代入箇所
this.currentCard = [
  { name: 'オバケ', color: '' },
  { name: 'ネズミ', color: '' },
];

このカードは、実は実際には存在しえない。なぜなら 「白いオバケ」と「灰色のネズミ」の両方が正解になってしまうカードは、仕様上存在しない からだ。そこで、 両方が正解ではないこと(正解がひとつのみ存在すること) をテストで担保しよう。6

Screen Shot 2018-12-01 at 12.59.49.png
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回試行するテストを追加することで、テストの妥当性を約束できる。

100枚のcurrentCardが、いつでも仕様を満たすことを確認するテスト
When('カードが100枚めくられたとき', () => {
  for (let i = 0; i < 100; i++) {
    const ghostBlitz = new GhostBlitz();
    ghostBlitz.generateNewCard();

    Then(`${i}枚目のカードに記載されているアイテムに正解が存在すること`, () => {
      expect(ghostBlitz.getCorrectItem(ghostBlitz.currentCard)).toBeTruthy();
    });
  }
});

Screen Shot 2018-12-01 at 14.36.11.png
これで、合計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が当たり前になってきたが、これをはじめとしてその他種々の方法で不具合の無い/少ないプロダクトを実現したいものである。

ところで、今回はロジックの実装のみを行い、プレイできる画面は作らなかった。なぜか?
だって もうすでに公式のスマホアプリがある し、なんだかんだで本物をやったほうが楽しいもの......

7

  1. クリスマス => サンタクロース => 赤と白 => いすとオバケ => おばけキャッチ という単純明快なロジックである。

  2. jasmine-given というnpmパッケージを見つけたが、使い方がわからなかったのでやめた。

  3. いまになって見直すと、 ゲーム開始時Given ではなく When が適切だったかもしれない。

  4. Failすることを確認しなければいけない理由は、「テストが通ってしまうと、意図せぬ実装をしてしまっていた」と判断でき、過去の実装に問題があった可能性に気づけるからだ。

  5. 実際には「キャッチする」以外にも「叫ぶ」というアクションも存在する。その実装はスペースの兼ね合いで割愛するので、 Github を参照されたい。

  6. これは先に実装したほうがスムーズだったかもしれない。

  7. 余談だが、Amazonのレビューで 「ネズミの耳の位置がずれていた」というレビュー を見つけた。おばけキャッチそのものにもATDDが必要か、あるいは「ネズミの耳の位置」については仕様が定められていなかったか、どちらだろうか。

6
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?