クリスマスといえば、ドイツ産のボードゲーム おばけキャッチ である。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が必要か、あるいは「ネズミの耳の位置」については仕様が定められていなかったか、どちらだろうか。 ↩