ちょっと話題の記事

[書いて覚えるTDD] ポーカーのロジックを実装しよう 〜その1〜

2017.12.25

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

おばんです、シリーズとして続けられるかもわからないのに、タイトルに「その1」とかつけてしまいがちな田中です。

今回の記事は、TDDを書きながら学習していくための内容になっています。対象読者は以下の通りです。

  • 「TDDってなんか難しそう...」と思っている人
  • 「TDDはテストを最初に書くことでしょ?」くらいの認識の人
  • 「TDDは良さそうだけどSwiftで書かれたサンプルで学びたい」と思っている人
  • 「書籍や記事を読むだけではなく、書きながらTDDに再入門したい」と思っている人

「TDDってなんか難しそう...」とか、「テスト最初に書くんでしょ?」などなど思ってるそこのあなたに向けて、判断する前にとりあえず書いてみようよ!という想いから書いています。(あとは布教とか、自分がマサカリを受けて成長するために)

このシリーズで紹介するコードはGitHubに公開しているので、参考にしてみてください。

お題は TDD Boot Camp(TDDBC) - TDDBC仙台07/課題 からお借りしています。それでは早速始めていきましょう!

検証環境

  • Xcode 9.2
  • Swift 4.0
  • Quick 1.2.0
  • Nimble 7.0.2

目次

  • 出題
    • トランプ
    • 課題1-1 カードの文字列表記
  • 実装したいことをTODOリストに書き下す
  • レッド、グリーン、リファクタリング
    • レッド
    • グリーン
    • リファクタリング
  • この記事で学んだことの振り返り
  • ここまでのソースコード
  • まとめ
  • 参考・関連

出題

今回取り扱う問題に関わることを記載します。要件と仕様の確認は大事なので。

トランプ

課題の大項目はポーカーですが、まずはトランプの定義から見ていきましょう。(※文章は先述した問題が記載されているサイトを引用しています。)


トランプは、日本ではカードを使用した室内用の玩具を指すために用いられている用語で、もっぱら4種各13枚の計52枚(+α)を1セットとするタイプのものを指して言うことが多い。(https://ja.wikipedia.org/wiki/%E3%83%88%E3%83%A9%E3%83%B3%E3%83%97より抜粋)

  • カード (card)
    • スートとランクを持つ
  • スート (suit) - 以下の4種類を持つ
    • ♠ (スペード/spade)
    • ♥ (ハート/heart)
    • ♣ (クラブ/club)
    • ♦︎ (ダイヤ/diamond)
  • ランク (rank) - 以下の13種類を持つ
    • A (エース/ace), 2, 3, 4, 5, 6, 7, 8, 9, 10, J(ジャック/jack), Q(クイーン/queen), K(キング/king)
  • カードひと組(4スート x 13ランク = 52枚)のことをデッキ(deck)と呼ぶ

課題1-1 カードの文字列表記

任意のカード1枚について、その文字列表記を取得してください

  • スート (suit) と ランク (rank) を与えて カード (card) を生成してください
  • 生成したカードから文字列表記 (notation) を取得してください
【例】
// スートにスペード, ランクに3を与えてカードを生成
Card threeOfSpades = new Card("♠", "3");
// 「スペードの3」の文字列表記は「3♠」
String notation = threeOfSpades.getNotation(); // => "3♠"
// スートにハート, ランクにJを与えてカードを生成
Card jackOfHearts = new Card("♥", "J");
// 「ハートのJ」の文字列表記は「J♥」
String notation = jackOfHearts.getNotation(); // => "J♥"

実装したいことをTODOリストに書き下す

問題はよく読めたでしょうか?それでは次に進みます。今回の問題で重要な部分は以下の三文です。

- 任意のカード1枚について、その文字列表記を取得してください
- スート (suit) と ランク (rank) を与えて カード (card) を生成してください
- 生成したカードから文字列表記 (notation) を取得してください

これをTODOリストに書き起こすと以下のようになります。

- [] Cardを定義して、インスタンスを作成する
- [] Cardのインスタンスから文字列表記(notation)を取得する

TDDを行う際にはこのTODOリストを作ることも大切な手法の一つだと、私は考えています。TODOリストを書くことで、やるべきことを忘れずに目の前のことに集中でき、その仕事が終わったかどうかを確認することができます。

プログラマは往々にして様々な気がかりを抱え込みがちです。今まさにソースコードを書き進めている間でも「ここはこうしたほうが良かったな...」とか、「今取り掛かっている部分と少し違う部分にバグを発見してしまった」なんてことがよくあります。そんなときはTODOリストを活用しましょう。TODOリストに懸念点を書きこんでおくことで、見つけてしまった問題を一旦忘れて、今解決しようとしている問題に集中することができます。

プログラミングにまつわる不安や恐怖をどうコントロールするかということもTDDの関心事の一つです。TODOリストを活用することは、目先のものごとを整理し、不安や恐怖に立ち向かうための、フォースの暗黒面に立ち向かうための一つのツールです。良く使っていきましょう。

「人間は誰でも不安や恐怖を克服して安心を得るために生きる」〜中略〜

 安心を求める事こそ人間の目的だ

(『ジョジョの奇妙な冒険』より、人間をやめたDIOのセリフより引用)

エンジニアのみなさまにおかれましては、GitHub issueを利用している方も多いかと思います。「TODOリストってissueでよいのかな?」と思われる方もいるかもしれません。チームや組織によってはその運用も当てはまるかもしれませんが、私はissueとは別にTODOリストを活用しています。私が普段触れるissueはユーザーストーリーを元に作られており、TODOよりももっと大きな粒度であることが多いからです。なので、私の認識ではTODOリストはissueとは別で、それをより小さく噛み砕いたものだと認識しています。

レッド、グリーン、リファクタリング

次はTDDの最も重要な基本で、奥義でもあるレッド、グリーン、リファクタリングについて解説します。

  • レッド: テストを書く。この段階の終了時点ではテストケースが書かれ、失敗している状態。
  • グリーン: 動作する実装を書く。この段階の終了時点では動作する汚い実装を書いている状態。このときはどんな汚い手を使っても、とにかくテストが通る状態を作ることに専念する。
  • リファクタリング: 綺麗な実装を書く。この段階の終了時点では実装が綺麗になっている状態。先のグリーンではとにかくテストが通る状態を作ることに専念したため、汚い実装になっているはずです。テスト対象が決まった値を返しているものを一般化したり、重複を除去したり、責務を正しく分散・凝縮させたりします。

まずはレッドになるテストを書く。テストを実行し、レッドになることが確認できたらグリーンになるように実装を書き換える、とにかく早く。レッドからグリーンにするステップの踏み方(手順)については後ほど解説します。

自動テストを書くとき、グリーンの状態でストップしがちではないでしょうか?TDDではリファクタリングが重要になります。(これはTDDに限った話ではありませんが、)グリーンであることを担保できているのであれば、それはリファクタリングのチャンスです。テストによって動作が保証されているのであれば、どんなに中の実装をいじろうと問題はないはずです。グリーンな状態のテストは新たな実装や、既存部分の書き換えのための足場/補助輪になってくれます。これは先に述べたように、不安や恐怖のコントロールにつながります。

少し長く喋りすぎました、座学はもういいからさっさとコードを書いていきましょう!QuickとNimbleをインストールした状態で、以下のようにPlayingCardsSpec.swiftをテストグループ内に作りましょう。この状態からスタートしていきます。

import Quick
import Nimble

@testable import TDDPokerExampleBySwift

class PlayingCardsSpec: QuickSpec {
    override func spec() {
        describe("課題: トランプ") {
            
        }
    }
}

これでテストを書いていく用意ができました。

TODOリストを振り返ると、以下のような項目がありました。TODOはどこから手をつけてもよいですが、今回は文字列表記の前にそもそものCardの定義とインスタンスが必要であることが明白なので、「Cardを定義して、インスタンスを作成する」から取り掛かりましょう。

- [] **Cardを定義して、インスタンスを作成する**
- [] Cardのインスタンスから文字列表記(notation)を取得する

と、その前に気づいたことが二つあります。

ひとつは「Cardを定義してインスタンスを作成する」なんて、Swiftを書き慣れた方であれば実装が明白ですし、今回の例でFailable Initializerとして定義したり、例外を投げるような実装にはしないでしょう。わざわざ今回のTODO項目に挙げるべきことでもなかったかもしれません。

ふたつ目には、仕様の見落としがありました。Cardはそのインスタンスを作成できるだけでなく、SuitとRankを持ちます。TODOリストに「Cardを定義してインスタンスを作成する」の子要素として「CardはSuitを持つ」と「CardはRankを持つ」という項目を追加した方が良さそうです。これで一つ目の気づいたことに挙げた、「TODO項目に挙げるべきことでもなかった」という考えを否定して意味を持たせることができました。

- [] **Cardを定義して、インスタンスを作成する**
  - [] **CardはSuitを持つ**
  - [] **CardはRankを持つ**
- [] Cardのインスタンスから文字列表記(notation)を取得する

レッド

作業に取り掛かる前にTODOを見直したところで、まずはレッドの状態のテストを作成します。テストケースの定義は、Cardのインスタンスを作成したときに、SuitとRankが意図したものになっているかどうかという観点で作っていきましょう。こんな感じでどうでしょうか。

import Quick
import Nimble

@testable import TDDPokerExampleBySwift

class PlayingCardsSpec: QuickSpec {
    override func spec() {
        describe("課題: トランプ") {
            context("Suitをハート, Rankをジャックでカードを作成した場合") {
                it("Cardのインスタンスが持つsuitが.heartで、rankが.jであること") {
                    let card = Card(suit: .heart, rank: .j)
                    
                    expect(card.suit).to(equal(Card.Suit.heart))
                    expect(card.rank).to(equal(Card.Rank.j))
                }
            }
        }
    }
}

一つのテストケースに複数のアサーション(ここではエクスペクテーションですが)を書いてしまう、アサーションルーレットアンチパターンに抵触しています。一つのテストケースに複数のアサーションを書いてしまうと、もしアサーションの評価が失敗だったときにどれが失敗したのかがわかりにくいという問題です。詳しくは いまさらながらSwiftでTDDをやってみた - Qiita にコメントでまとめさせていただきました。アンチパターンなので良くはありませんが、ひとまずは置いておきましょう。置いておこうと判断した理由は、このテストの価値がそんなに高いものではなくて、のちのち無くしてしまうだろうなと考えているからです。

追記(2017/12/25): 上で説明したアサーションルーレットアンチパターンについてですが、XCTAssert系で検査を行う場合は全てのアサーションに対して検査が行われるため、これが問題にならないことがわかりました。これはQuickを用いた場合も同様の挙動であることが確認できました。誤記してしまい、申し訳ありませんm(_ _)m

ここでテストを実行してみましょう。以下のエラーが出たら成功です!おめでとうございます!レッドなのにおめでとうも、不思議な感覚かもしれませんね。

- Use of undeclared identifier 'Card'
  - 宣言されていない識別子 'Card' が使用されている

ちなみに動的型付き言語の場合は変数やクラスが未定義の場合はテスト実行時にそれが判明するため、それがレッドだとわかりやすいかもしれません。では今出ているコンパイルエラーはどうでしょうか。そもそもテストが実行できないので、レッドにすらなれない前の段階のような気もしますが、コンパイルエラーもレッドの一種と見て問題ありません。

グリーン

レッド、グリーン、リファクタリングのレッドの段階をふむことができたので、次は早急にグリーンにしましょう!はい急いで急いで!

今出ているエラーはCardが未定義のために出ているエラーでしたので、Cardを作っていきましょう。

struct Card {
    enum Suit {
        case spade
        case heart
        case club
        case diamond
    }
    
    enum Rank {
        case one
        case two
        case three
        case four
        case five
        case six
        case seven
        case eight
        case nine
        case ten
        case j
        case q
        case k
    }
    
    let suit: Suit = .heart
    let rank: Rank = .j
    
    init(suit: Suit, rank: Rank) {}
}

これでテストが通るはずです。もしXcodeで実装している場合、テスト実行のショートカット Command + U を叩きましょう。通りましたか?おめでとうございます!あなたの実装とテストが確かなものであることが世界に認められた瞬間です!!!

この実装に対して「いやそもそもsuitとrankベタ書きじゃん...」などの疑問を持たれる方もいらっしゃるかもしれませんが、グリーンの段階ではとにかく早くテストを通すことを最優先に考えます。このように期待値通りの値をベタ書きするテクニックを「仮実装」と言います。 よくないところは次のリファクタリングの段階で直していきましょう。


「そもそも仮実装なんて意味があるのか?最初からinitで変数に代入するコードを書けばいいじゃないか!TDDはやっぱり無駄に小さくステップを踏む必要があって、時間がかかるやり方だったんだ!」

お待ちくださいお客様、そうではございません。TDDで大切なのは小さいステップを踏み続けられるようになることです。今回は小さなステップを踏むとどれくらい細かになるかということを紹介するためにあえて丁寧に説明しています。実際日々のコーディングではここまで細かく区切る必要が、必ずしもあるわけではありません。すぐに頭の中のコードを実装に落としても構いません。これは、期待通りの値をベタ書きして徐々に変数を書き換えていく仮実装と対比して、「明白な実装」といいます。

TDDでは仮実装と明白な実装を使い分けてコードを書いていきます。使い分ける基準は自分がこれから書くコードに対する自信の度合いです。「自信」の反対語として「不安」がありますが、先述したように不安をコントロールすることもTDDの関心事の一つです。わざわざ仮実装というテクニックがあるのは、不安をコントロールするためです。勇気をもって大きなステップを踏んでみて、もし失敗したら小さなステップに切り替えればいい。適切な使い分けで、不安を乗り越えていきましょう。

リファクタリング

みなさんお待ちかねのリファクタリングタイムです!もーーー、汚いところが目についてしょうがないと感じていた方は多かったんじゃないでしょうか!僕もその一人です!直していきますよ!!


すぐに見てとれる修正点もありますが、その前に一つ気になることがあります。「そういえば Cardを定義して、インスタンスを作成する ためのテストケースって一つだったけど、他の値を代入してインスタンスを作成したらもちろんその値が取得できるよね??」 テストケースを追加して検証してみましょう。

import Quick
import Nimble

@testable import TDDPokerExampleBySwift

class PlayingCardsSpec: QuickSpec {
    override func spec() {
        describe("課題: トランプ") {
            context("Suitをハート, Rankをジャックでカードを作成した場合") {
                it("Cardのインスタンスが持つsuitが.heartで、rankが.jであること") {
                    let card = Card(suit: .heart, rank: .j)
                    
                    expect(Card.Suit.heart).to(equal(card.suit))
                    expect(Card.Rank.j).to(equal(card.rank))
                }
            }
            
            context("Suitをスペード, Rankをクイーンでカードを作成した場合") {
                it("Cardのインスタンスが持つsuitが.spadeで、rankが.qであること") {
                    let card = Card(suit: .spade, rank: .q)
                    
                    expect(Card.Suit.spade).to(equal(card.suit))
                    expect(Card.Rank.q).to(equal(card.rank))
                }
            }
        }
    }
}

テストを実行してみたら二つのエラーが検出できました。

- expected to equal <spade>, got <heart>
  - 期待値が<spade>と等しいことを期待したけど、<heart>だった
- expected to equal <q>, got <j>
  - 期待値が <q> と等しいことを期待したけど、 <j> だった

しまった!そういえばさっきCardのsuitとrankは仮実装として、.heart.jをベタ書きで代入していたことを忘れていた!どこのことを言ってるかというと、ここです。

// Card.swift
let suit: Suit = .heart
let rank: Rank = .j

suitとrankを決めうちではなく一般化する必要が出てきました。先に作っておいたinit内でsuitとrankを代入するようにしましょう。

struct Card {
    enum Suit {
        // 省略
    }
    
    enum Rank {
        // 省略
    }
    
    let suit: Suit
    let rank: Rank
    
    init(suit: Suit, rank: Rank) {
        self.suit = suit
        self.rank = rank
    }
}

これでテストを実行すれば、定義した二つのテストがグリーンになるはずです。お疲れ様でした。

実装が妥当であることを示すために今回はテストケースを追加しました。「Suitをハート, Rankをジャックでカードを作成した場合 Cardのインスタンスが持つsuitが.heartで、rankが.jであること」のテストケースでは、ハート, ジャックのインスタンスを作成できることしか保証できていませんでした。「Cardを定義して、インスタンスを作成する」というTODO項目を達成させるために、「他のSuitとRankでCardのインスタンスを作成しても、インスタンスはそのSuitとRankを持つか」という観点が抜けていたことが問題でした。このように複数のテストケースから実装の一般化を促すテクニックを三角測量といいます。

三角測量はTDDの範疇を超えて、テストケースに対する観察眼が必要なテクニックだと私は考えます。『テスト駆動開発』の言葉を借りるならば、「どのように設計すればよいかアイデアが浮かばないときに、少し異なる角度から問題について考える機会をあたえてくれる」テクニックです。特に注意して言っておきたいのは、三角測量は「仮実装していた部分を確認するためのテクニック」ではないということです。

お疲れ様ついでにもう一つ思い出したことがありました。Swiftのstructには、定義したプロパティが未初期化だった場合、initを省略できる言語仕様がありました。省略できるものは省略しましょう。

struct Card {
    enum Suit {
        // 省略
    }
    
    enum Rank {
        // 省略
    }
    
    let suit: Suit
    let rank: Rank
}

実装の書き換えを行なったら必ずテストを実行するようにしましょう。テストは通りましたか?通った、よかった、私のSwiftの言語仕様の理解に間違いはなかったようです。


次のリファクタリングポイントは私の確認ミスだったので素直に謝ります、ごめんなさい。問題文の一文を見てください。

  • A (エース/ace), 2, 3, 4, 5, 6, 7, 8, 9, 10, J(ジャック/jack), Q(クイーン/queen), K(キング/king)

one, j, q, kは読みの通りのace, jack, queen, kingと命名した方が正しい命名のように思えてきました。さらにoneにおいては誤解があることに気がつきました。one, two, threeというのはカードに書かれた数字のことであり、aceが1を意味するかというのは別の問題で、ポーカー/大富豪/七並べなどのゲームのルールがaceをどう解釈するかによって変わりそうです。テストを書き換えていきましょう。

import Quick
import Nimble

@testable import TDDPokerExampleBySwift

class PlayingCardsSpec: QuickSpec {
    override func spec() {
        describe("課題: トランプ") {
            context("Suitをハート, Rankをジャックでカードを作成した場合") {
                it("Cardのインスタンスが持つsuitが.heartで、rankが.jackであること") {
                    let card = Card(suit: .heart, rank: .jack)
                    
                    expect(card.suit).to(equal(Card.Suit.heart))
                    expect(card.rank).to(equal(Card.Rank.jack))
                }
            }
            
            context("Suitをスペード, Rankをクイーンでカードを作成した場合") {
                it("Cardのインスタンスが持つsuitが.spadeで、rankが.queenであること") {
                    let card = Card(suit: .spade, rank: .queen)
                    
                    expect(card.suit).to(equal(Card.Suit.spade))
                    expect(card.rank).to(equal(Card.Rank.queen))
                }
            }
        }
    }
}

書き換えが終わったらテストを実行しましょう。以下のエラーが出るはずです。

- Type 'Card.Rank' has no member 'jack'
  - 'Card.Rank' 型はメンバーに 'jack' を持たない
- Type 'Card.Rank' has no member 'queen'
  - 'Card.Rank' 型はメンバーに 'queen' を持たない

jackとqueenはCard.Rankのメンバーに含まれていないと、コンパイラが真っ赤になってまで怒ってくれました。甲斐甲斐しいですね。レッドになることを確認したら実装を書き換えていきましょう。

struct Card {
    enum Suit {
        // 省略
    }
    
    enum Rank {
        case ace
        case two
        case three
        case four
        case five
        case six
        case seven
        case eight
        case nine
        case ten
        case jack
        case queen
        case king
    }
    
    let suit: Suit
    let rank: Rank
}

ここまでのリファクタリングはこんなところではないでしょうか。レッド、リーン、リファクタリングのグリーンの段階よりもずっと良いコードになりました!TODOリストを更新しましょう。

- [x] **Cardを定義して、インスタンスを作成する**
  - [x] **CardはSuitを持つ**
  - [x] **CardはRankを持つ**
- [] Cardのインスタンスから文字列表記(notation)を取得する

文字列表記についてはまた次回見ていきます。少し長くなりましたが、この記事で学んだことを振り返りましょう。

この記事で学んだことの振り返り

  • 目の前の実装に集中するためにTODOリストを使うことは効果的
  • TDDの関心事の一つに、プログラミングにまつわる不安や恐怖をどうコントロールするかという話がある
  • レッド/グリーン/リファクタリングでTDDの1サイクル。特にリファクタリングを必ず行なって、動作する綺麗な実装を手に入れる必要がある
  • TODOリストはどこから手をつけても良い
  • TODOリストは都度精査し、足りない項目に気付いたら追加する
  • コンパイルエラーもレッドの一種である
  • レッドを素早くグリーンに変えるために期待値通りの値をベタ書きするテクニックを「仮実装」という
  • 頭の中のコードをすぐ実装に落とし込むことを「明白な実装」という
  • TDDで大切なのは小さなステップを踏み続けられるようになること
  • 仮実装と明白な実装は自分がこれから書くコードに対する自信の度合いで使い分ける
  • 複数のテストケースから実装の一般化を促すテクニックを「三角測量」という
  • Swiftのstructには、定義したプロパティが未初期化だった場合、initを省略できる言語仕様がある
  • 問題文はよく読もう
  • Rankが何を意味するかはゲームのルールによって変わりそうだ。Card(モデル)がどうあるべきか考えよう

ここまでのソースコード

import Quick
import Nimble

@testable import TDDPokerExampleBySwift

class PlayingCardsSpec: QuickSpec {
    override func spec() {
        describe("課題: トランプ") {
            context("Suitをハート, Rankをジャックでカードを作成した場合") {
                it("Cardのインスタンスが持つsuitが.heartで、rankが.jackであること") {
                    let card = Card(suit: .heart, rank: .jack)
                    
                    expect(card.suit).to(equal(Card.Suit.heart))
                    expect(card.rank).to(equal(Card.Rank.jack))
                }
            }
            
            context("Suitをスペード, Rankをクイーンでカードを作成した場合") {
                it("Cardのインスタンスが持つsuitが.spadeで、rankが.queenであること") {
                    let card = Card(suit: .spade, rank: .queen)
                    
                    expect(card.suit).to(equal(Card.Suit.spade))
                    expect(card.rank).to(equal(Card.Rank.queen))
                }
            }
        }
    }
}
struct Card {
    enum Suit {
        case spade
        case heart
        case club
        case diamond
    }
    
    enum Rank {
        case ace
        case two
        case three
        case four
        case five
        case six
        case seven
        case eight
        case nine
        case ten
        case jack
        case queen
        case king
    }
    
    let suit: Suit
    let rank: Rank
}

まとめ

テスト駆動開発について、実際にコードを書きつつ、コードと考えを付き合わせながら紹介してみました。完全に『テスト駆動開発』の第一部に影響されました。

また、世の中にあるTDDについて紹介した記事や文献はSwiftで書かれたものが少ないように思いました。もちろん無いわけではありませんが、Javaなどの歴史ある言語ほどは無いと思ったため、この記事を書いてみました。Swiftをすでに習得している人がTDDを始めるときのきっかけになり、同時にTDDがどういったものか感じていただければ幸いです。

はじめの一歩は難しいものではない。TDDの難しさは、「どういったテストケースが必要か」、「コンポーネントをどう設計すれば良いテストができるか」などのテクニックや戦術を同時に学ばなければいけないために複雑になるところにあると思っています。もちろんTDD自体にも、ステップの歩幅の調節を見極める難しさがあります。一気にうまくできるわけではないので、それこそTDDのコツコツ感と同じで少しずつ学んでいけば良いのだと思っています。

追記

たまたま空いていたiOS Advent Calendar 2017の19日目の記事として今回の記事を追加させていただきました。( ˘ω˘)

参考・関連