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

はじめに

おばんです、昨日もいっぱいブログ書いてたのに性懲りもなく1月1日も書いてる田中です!今年の書き初めはTDDから。

今回の記事は前回に引き続き、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リストの振り返り
  • 実装したいことを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リストの振り返り

前回の終了時点でTODOリストは以下のようになっていました。今回は「Cardのインスタンスから文字列表記(notation)を取得する」に取り掛かっていきます。

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

文字列表記(notation)を取得する

まずは黄金律に従ってレッドから始めていきましょう。

レッド

テストを書いていきます。Cardのインスタンスを作成して、そのCardのインスタンスから文字列表記を取得していきます。出題では getNotation() となっていますが、ここはSwiftらしくプロパティとして取得しましょう。こんな感じでしょうか。

context("Suitをハート, Rankをジャックでカードを作成した場合") {
    it("文字列表記'J♥'が取得できること") {
        let jackOfHearts = Card(suit: .heart, rank: .jack)
        
        expect(jackOfHearts.notation).to(equal("J♥"))
    }
}

ここでテストを実行して以下のエラーが出たら予定通りです。ここからテストが通るようにしましょう。

- Value of type 'Card' has no member 'notation'
  - 'Card' 型のメンバーに 'notation' が含まれていません

グリーン

まずは慎重に歩を進めていくことを優先します。必ずテストが通ることを確認するために、仮実装を用いて固定値を返却するようにしてみましょう。当初の予定通りプロパティで取得できるようにします。

struct Card {
    enum Suit { // 省略 }
    
    enum Rank { // 省略 }
    
    let suit: Suit
    let rank: Rank
    
    let notation = "J♥"
}

テストは、通...る!よおっし!良い調子です。さっさとリファクタリングしましょう!!!

リファクタリング

実装を一般化する

「別のsuitとrankを与えたインスタンスを検査したときも期待どおりの結果を返すだろうか?🤔」 前回と同じように、現在は仮実装を行なっているので通ることはありません。ですがこれからリファクタリングするにあたって、一般化が正しく行われているか確認するためにテストケースを追加しましょう。新しく追加するテストケースはこんなところでどうでしょうか。

context("Suitをスペード, Rankをクイーンでカードを作成した場合") {
    it("文字列表記'Q♠'が取得できること") {
        let queenOfSpades = Card(suit: .spade, rank: .queen)
        
        expect(queenOfSpades.notation).to(equal("Q♠"))
    }
}

テストを実行してみると、以下のエラーが出ました。ええ、仮実装していますからこれでオーケーです。

- expected equalt to <Q♠>, got <J♥>
  - 期待値が <Q♠> と等しいことを期待したけど、 <J♥> だった

どうやって一般化しましょうか。そういえばSwiftには計算型プロパティ(Computed Property)がありました。これを使えば、Cardのインスタンスが保持しているsuitとrankから文字列を生成して返却できそうです!

そしてもう一つ、それぞれsuitとrankにはどうやって文字列を紐付けるかですが、これもSwiftであれば、enumにStringのRaw Valueを紐付けて定義することができます。まずはこちらから実装していきましょう。

ちなみに今回は省略していますが、ここでTODOリストを更新するのも良いと思います。「Cardのインスタンスから文字列表記(notation)を取得する」というTODOに取り掛かっていますが、今出てきた「notationを計算型プロパティで宣言する」と「SuitとRankのenumにStringのRaw Valueを紐付けて定義する」という二つを子要素として追加することができます。すぐに取り掛かることだから、という理由であえて明記はしていませんが、もっと複雑な状況の場合は一旦TODOリストに整理した方が良いかもしれません。これも実装の歩幅を調整するのと同じく、自信の度合いによって使い分けていきましょう。

struct Card {
    enum Suit: String {
        case spade = "♠"
        case heart = "♥"
        case club = "♣"
        case diamond = "♦︎"
    }
    
    enum Rank: String {
        case ace = "A"
        case two = "2"
        case three = "3"
        case four = "4"
        case five = "5"
        case six = "6"
        case seven = "7"
        case eight = "8"
        case nine = "9"
        case ten = "10"
        case jack = "J"
        case queen = "Q"
        case king = "K"
    }
    
    let suit: Suit
    let rank: Rank
    
    let notation = "J♥"
}

ここで「enumにRaw Valueを紐付けられたかどうかはテストしなくてよいのだろうか」と思われた方は良い着眼点をお持ちだと思います。もしもSwiftに不慣れで、それが正しく行われたかどうかに自信がなかった場合には、確認のためのテストを書いてみるのもよいかもしれません。今はもうすでにとりかかっているタスクがあるので横道にそれたくないし、その確認のためのテストは、今取り掛かっている「Cardのインスタンスから文字列表記(notation)を取得する」のテストが通った段階で意味を失いそうなので省略しています。

enumの修正ができた段階で念のため、これまでの実装を壊していないかテストを実行しておきましょう。先ほどと状況は変わらず、同じエラーが出ていると思います。 他のテストをクリアしていて、特にプログラムを壊していないようだと確認できたら先に進みましょう。

notationを計算型プロパティに変更しましょう。まずは同じ固定値を返すように、計算型プロパティがさきほどまでと同様のテスト結果を導くように修正します。

struct Card {
    enum Suit { // 省略 }
    
    enum Rank { // 省略 }
    
    let suit: Suit
    let rank: Rank
    
    var notation: String {
        return "J♥"
    }
}

変更したらテストを実行して確認します。さっきと同じエラーが出ていることを確認できたら順調です、あと一歩!プロパティとして保持しているsuitとrankから文字列表記を生成して結果を返却しましょう。

struct Card {
    enum Suit { // 省略 }
    
    enum Rank { // 省略 }
    
    let suit: Suit
    let rank: Rank
    
    var notation: String {
        return "\(rank.rawValue)\(suit.rawValue)"
    }
}

テストを実行したらどうなりましたか?グリーンに戻った!いいね!実装も一般化されて美しくなりました!

その1で言及したように、実装に自信があるのであれば仮実装ではなく明白な実装としてそのまま一般化を行なっても良いです。ただしちゃんと一般化が行われているかを示すためにテストケースをもう一通り追加しておく必要はあると思っています。なぜなら、もしもテストケースが一つの場合、そのケースが通るように実装されていることしか示せていないからです。複数のケースを用意することで、その実装の一般化がなされていることを証明することができ、あとからプログラムを読む人の理解を促すことにつながります。 プロダクトコードと同じように、テストコードやテストケースも、あとから読む人が意図を汲み取りやすいように書いていきましょう。


suitとrankの宣言順を変える

文字列表記を実装していて気づいたことがありました。出題と実装を見比べてください。文字列表記は"3♠"の順で書かれているのに、initの順番を見るとsuit, rankの順番で逆になっています。

出題の変数名の例をみると threeOfSpades ともなっていて、おそらく英語でカードを呼ぶときの順になっています。Googleの画像検索で threeOfSpadesspadesOfThree を検索してみたら、 threeOfSpades の方がヒット件数が多く、確かそうでした。この出題の意図としては文字列表記の通りに、rank, suitの順番が期待されるものだと判断して、Cardの実装を修正していきましょう。

まずは最初のテストケースを切り出して修正していきます。Cardのinitの引数の順序を入れ替えて、expectも上下逆に呼び出します。

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

修正して、テストを実行して、以下のエラーが出たらオーケー。テストに沿って実装を修正しましょう。

- Argument 'suit' must precede argument 'rank'
  - 引数 'suit' は 'rank' の前になければなりません

initは特別書いておらず、プロパティの宣言順に応じて引数の順序が変わるので、suitとrankの宣言順を変えます。

struct Card {
    enum Suit { // 省略 }
    
    enum Rank { // 省略 }
    
    let rank: Rank
    let suit: Suit
    
    var notation: String {
        return "\(rank.rawValue)\(suit.rawValue)"
    }
}

ここでテストを実行して、先ほど修正したテストが通り、他がレッドになったら思惑どおりです。修正したテストと同様にinitの引数の順序を入れ替えましょう。入れ替えてテストが通ったら完璧です!

ついでにenumのRankとSuitの宣言順序も入れ替えておきましょう。こういう細かいリファクタリングがのちのち効いてきたりしますもんね。(※修正したらテストを実行して確認すること!)

struct Card {
    enum Rank: String { // 省略  }
    
    enum Suit: String { // 省略 }
    
    let rank: Rank
    let suit: Suit
    
    var notation: String {
        return "\(rank.rawValue)\(suit.rawValue)"
    }
}

文字列表記に関するリファクタリングはこんなところでしょうか。満足のいくコードが書けました。TODOリストを更新しましょう!

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

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

  • レッド、グリーン、リファクタリングから始める(復習)
  • 言語仕様を活かしたコーディングを心がける
  • 小さいステップを踏み続けられるように、仮実装を活用する(復習)
  • TODOリストも、自身の度合いによって適宜更新する
  • コードを修正したら必ずテストを実行する
  • プロダクトコードと同じように、テストコードやテストケースも、あとから読む人が意図を汲み取りやすいように書いていく
  • 実装上問題がない箇所でも、細かく気づいたところはリファクタリングしていく

ここまでのソースコード

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(rank: .jack, suit: .heart)
                    
                    expect(card.rank).to(equal(Card.Rank.jack))
                    expect(card.suit).to(equal(Card.Suit.heart))
                }
            }
            
            context("Suitをスペード, Rankをクイーンでカードを作成した場合") {
                it("Cardのインスタンスが持つsuitが.spadeで、rankが.queenであること") {
                    let card = Card(rank: .queen, suit: .spade)
                    
                    expect(card.rank).to(equal(Card.Rank.queen))
                    expect(card.suit).to(equal(Card.Suit.spade))
                }
            }
            
            context("Suitをハート, Rankをジャックでカードを作成した場合") {
                it("文字列表記'J♥'が取得できること") {
                    let jackOfHearts = Card(rank: .jack, suit: .heart)
                    
                    expect(jackOfHearts.notation).to(equal("J♥"))
                }
            }
            
            context("Suitをスペード, Rankをクイーンでカードを作成した場合") {
                it("文字列表記'Q♠'が取得できること") {
                    let queenOfSpades = Card(rank: .queen, suit: .spade)
                    
                    expect(queenOfSpades.notation).to(equal("Q♠"))
                }
            }
        }
    }
}
struct Card {
    enum Rank: String {
        case ace = "A"
        case two = "2"
        case three = "3"
        case four = "4"
        case five = "5"
        case six = "6"
        case seven = "7"
        case eight = "8"
        case nine = "9"
        case ten = "10"
        case jack = "J"
        case queen = "Q"
        case king = "K"
    }
    
    enum Suit: String {
        case spade = "♠"
        case heart = "♥"
        case club = "♣"
        case diamond = "♦︎"
    }
    
    let rank: Rank
    let suit: Suit
    
    var notation: String {
        return "\(rank.rawValue)\(suit.rawValue)"
    }
}

まとめ

その2はいかがでしたでしょうか。今回は復習的な内容を多く含んでいます。レッド、グリーン、リファクタリングの流れも二周目を終えて、そろそろ慣れてきた頃合いかと思います。良いですね!

記事の中でも何度か補足していますが、実際にはもっと歩幅を大きくして明白な実装で進めていっても構いません。紹介する中では「小さいステップを踏み続けられるようにする」ために、あえてくどい進め方をしています。次回以降はもう少し速度を早めていくかもしれません。

次回は「課題1-2 カードの比較」に取り組んでいきます!

参考・関連