[TDD][SwiftUI] SwiftUI と TDD で作る ボーリングスコアアプリ(その1 モデル作成 Part1)

     
⌛️ 3 min.
ボーリングアプリ(スコアを記録するアプリ)を、SwiftUI と TDD で作ってみます。

環境&前提

環境&前提
  • macOS Catalina 10.15.7
  • Xcode 12.1
  • MVVM (Model-View-ViewModel) で作ります
  • TDD で作るので、テスト作ってから、実装します。(基本的には、YAGINI(You ain’t gonna need it)で、必要になってから実装します)
  • 最適化しない(メモリ効率や実行スピードは考慮しない、いわゆる富豪的プログラミングでいきます)

アプリ概要

以下の方針で作りますが、途中で必要に応じてアップデートしていきます。

アプリ概要
  • 1投ごとに記録できる
  • GUI は、最低限。例えば、スペアは、”/” で表示され、ストライクは、✖️ で表示される。アニメーションとかしない
  • フレームごとに、そこまでの合計点数が計算されて、表示される
  • 1人分の1ゲーム分を記録表示する

アプリ設計

TDD で作るとは言っても、簡単な設計は行います。(1項目で5分以上悩んだら、設計過多ということで、設計段階での作り込みをしないようにシンプルに作りました)

アプリ設計
  • MVVM の M (Model)
    • struct Frame は、1フレーム分を記録する
    • Frame が 10 集まって、 struct BowlingGame になる
    • Frame には、2投分のデータが保存されるが、10Frame 目は、3投するかも
    • 1投には、{未投,既投,不要}の種類があり、既投 であれば、倒した本数が記録されている。1〜9Frameの3投目は常に 不要 のハズ
    • 1Frame には、{スコア計算不可(まだ投げてない or 必要な情報がそろってない) と スコア計算可能} がある
    • 1Frame の 1投目が 10 であれば、2投目は記録できない
    • スコア計算は、都度計算され、保存されない
    • スコア計算できない時は、スコアとしては、-1 が返される
  • MVVM の VM (ViewModel)
    • Frame に記録されている1投目が 10 であれば、ストライク表示を行う
    • Frame に記録されている2投の合計が10であれば、スペア表示を行う
    • スコアが、-1 (計算不可)の時には、”-” がスコアとして表示される
  • MVVM の View (View)
    • Frame 表示には、FrameView を作る
    • FrameView は、BowlView x 2 + ScoreView で構成される
    • GameView は、FrameView を10と、TotalScoreViewを2つ持つ
    • GameView は、ViewModel を持つ

プロジェクトを作る

Xcode を使って、プロジェクトを作ります。

  • Template: “iOS” – “App” を選択
  • Project 設定: 以下を選択・設定
    • Product Name: TDDBowling
    • Interface: SwiftUI
    • Life Cycle: SwiftUI App
    • Language: Swift
    • Include Tests にチェックマーク
  • 適当な箇所に保存 (Create Git repository on my Mac にチェックしておくと git でバージョン管理できて幸せです)

Hello World を表示してくれるテンプレートが、動くことを確認します。

Model を作る

モデルを作ろうとするまえに、まずは、テストを作りましょう。

Includes Tests をチェックしたことで、テンプレートとして、2種類のテストが用意されています。”TDDBowlingTests” と “TDDBowlingUITests” です。

前者は、いわゆる UnitTest、後者は、UITest です。

モデルは、UITest ではテストできないので、UniteTest (TDDBowlingTests) にテストを追加します。

Model 用テストクラスの作成

モデルの名称は、BowlingGame にする予定なので、
新しく “BowlingGameTests” というファイル名のSwift ファイルを TDDBowlingTests に追加します。
Model は、”BowlingGame” という struct です。引数なしで、作成できるハズなので、以下のようなテストコードになります。

引数なしで struct が生成できるかどうかを確認するテストになってます。

BowlingGameTests code


//
//  BowlingGameTests.swift
//
//  Created by : Tomoaki Yagishita on 2020/11/08
//  © 2020  SmallDeskSoftware
//

import XCTest
@testable import TDDBowling

class BowlingGameTests: XCTestCase {

    override func setUpWithError() throws {
        // Put setup code here. This method is called before the invocation of each test method in the class.
    }

    override func tearDownWithError() throws {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }

    func test_initializeBowlingGame_withoutAnyArguments() {
        let bowlingGame = BowlingGame()
        XCTAssertNotNil(bowlingGame)
    }
}

当然テストを描いただけなので、コンパイルエラーになってしまい、実行できません。エラー:”Cannot find ‘BowlingGame’ in scope”

Model の作成

今度は、アプリケーション本体のコードを書いていきます。BowlingGame.swift を追加します。

BowlingGame.swift code


//
//  BowlingGame.swift
//
//  Created by : Tomoaki Yagishita on 2020/11/08
//  © 2020  SmallDeskSoftware
//

import Foundation

struct BowlingGame {
    // (1)
    var frames:[Frame] = [Frame](repeating: .init(), count: 10)
}

// (2)
struct Frame {
    var bowls:[Bowl] = [.NotYet, .NotYet, .NoNeed]
}

//(3)
enum Bowl {
    case NotYet
    case Done(Int)
    case NoNeed
}
コード解説
  1. BowlingGame は、Frame を 10 保持します
  2. Frame は、Bowl を3つ保持します。3つの初期値は、それぞれ.NotYet, .NotYet, .NoNeed を指定しています
  3. Bowl は、enum で定義しています。NotYet は、未投球 を Done は、投球済を NoNeed は、10フレーム目の3投目のための設定です。Done の時には、倒れた本数を記録するため、assosiated type に Int を指定しました

# 1投は、”Throw” は、予約語とかぶるので、Bowl にしてみました。

懸念点は、以下の点ですが、テストで明らかになるまでは放置で。

  • 初期化する時に、10フレームの3投目まで NoNeed になってしまう

BowlingGame テストの追加 1投目を記録できるかテスト

BowlingGame に含まれる Frame をテストすることを考えると、当然、BowlingGame の保持する Frame に対するアクセサを考えることになります。

Frame に値をセットして、きちんと記録されていることをテストすることにしましょう。

シンプルに、bowlingGame.addBowlResult(Int) で、投球した結果を追加していくこととしました。どこに追加するかは、BowlingGame 側が考えるということで。

取得する時は、何フレーム/何投目を意識して取得するはずなので、bowlResult(frameIndex, inFrameIndex) -> Bowlとしました。

MEMO

当初、bowlingGame.setBowl(frameIndex, inFrameIndex, value) でセットできることを考えたのですが、いま何フレーム目の何投目かを誰かが記録しなければいけないので、やめました。

とりあえず(?)、ストライクやスペアでない 1投分 を追加して、記録できているかを確認できることをテストします。

BowlingGameTests code


//
//  BowlingGameTests.swift
//
//  Created by : Tomoaki Yagishita on 2020/11/08
//  © 2020  SmallDeskSoftware
//

import XCTest
@testable import TDDBowling

class BowlingGameTests: XCTestCase {
    // 既存コードは省略
    
    func test_putScore_atFrame0Bowl0_shouldBeRecorded() {
        var bowlingGame = BowlingGame()

        let resultBowl0 = bowlingGame.addBowlResult(3)
        XCTAssertTrue(resultBowl0)

        let bowl00 = bowlingGame.bowlResult(frame: 0, bowl: 0)
        XCTAssertEqual(bowl00, 3)
    }
}

当然、addBowl や bowlResult なんて メソッドは作ってないのでエラーです。

BowlingGame の追加実装

どのフレームに記録するかの判断は、それなりにロジックを書かないといけないので、後回しにして、
とりあえず、最初のフレームの最初の投球に記録することにしました。これでテストは通るはずです。

BowlingGame 追加 code


    mutating func addBowlResult(_ num: Int) -> Bool {
        self.frames[0].bowls[0] = .Done(num)
        return true
    }

    func bowlResult(frame: Int, bowl: Int)  -> Int? {
        let bowl = frames[frame].bowls[bowl]
        
        switch bowl {
            case .Done(let num):
                return num
            default:
                return nil
        }
    }

先ほど書いたテストは通ることが確認できます。

最初のフレームを記録できるかテスト(1投目を記録できるかテストの改良)

2投目も保存して、1フレーム分保存できるテストに変更しましょう

テスト内容に合わせて、テストのメソッド名も変更しました。

test_putScore_atFrame0Bowl0And1_shouldBeRecorded code


class BowlingGameTests: XCTestCase {
    func test_putScore_atFrame0Bowl0And1_shouldBeRecorded() {
        var bowlingGame = BowlingGame()

        let resultBowl0 = bowlingGame.addBowlResult(3)
        XCTAssertTrue(resultBowl0)

        let bowl00 = bowlingGame.bowlResult(frame: 0, bowl: 0)
        XCTAssertEqual(bowl00, 3)

        let resultBowl1 = bowlingGame.addBowlResult(5)
        XCTAssertTrue(resultBowl1)

        let bowl01 = bowlingGame.bowlResult(frame: 0, bowl: 1) //(1)
        XCTAssertEqual(bowl01, 5)
    }
}

特に、2投目を想定して実装していないので、
2投目のテストで、”XCTAssertEqual failed: (“nil”) is not equal to (“Optional(5)”)”というエラーになります。

BowlingGame.addBowlResult の改良

手抜きして、常に 0-Frame の 0 投目に記録していましたが、きちんと記録するようにしましょう。

ここにきて、どこに記録すべきかをきちんと考えましょう。

最初の Frame の Bowl から辿って行って、最初に見つかった Bowl.NotYet の箇所に記録します。ですが、10フレーム目の 3投目の処理を考えなければいけません。

が、3投目は、10フレーム目が、ストライク or スペア の時のみ可能なので、もう少し後にしましょう。

BowlingGame.addBowlResult code


struct BowlingGame {
    var frames:[Frame] = [Frame](repeating: .init(), count: 10)
    
    func findRecordableFrameBowl() -> (frame:Int, bowl:Int)? {
        for frameIndex in 0..10 {
            let frame = frames[frameIndex]
            for bowlIndex in 0..3 {
                let bowl = frame.bowls[bowlIndex]
                switch bowl {
                    case .Done(_):
                        continue
                    case .NoNeed:
                        continue
                    case .NotYet:
                        return (frameIndex, bowlIndex)
                }
            }
        }
        return nil
    }
    
    mutating func addBowlResult(_ num: Int) -> Bool {
        if let addIndex = self.findRecordableFrameBowl() {
            self.frames[addIndex.frame].bowls[addIndex.bowl] = .Done(num)
            return true
        }
        return false
    }

記録できる Frame/Bowl を探すための findRecordableFrameBowl メソッドを追加しました。

スペア・ストライクなしのテストをもう少し追加

力技ですが、10フレームまで、スペア・ストライク共にないときのテストケースを追加しましょう。

BowlingGameTests#test_recordScore_wholeGameWithOutSpareStrike_shouldBeRecorded code


    func test_recordScore_wholeGameWithOutSpareStrike_shouldBeRecorded() {
        var bowlingGame = BowlingGame()
        var gameData:[[Int]] = []
        
        for _ in 0..10 {
            let bowl1 = Int.random(in: 0..10)
            let bowlResult1 = bowlingGame.addBowlResult(bowl1)
            XCTAssertNotNil(bowlResult1)
            let bowl2 = Int.random(in: 0..(9-bowl1))
            let bowlResult2 = bowlingGame.addBowlResult(bowl2)
            XCTAssertNotNil(bowlResult2)
            let frameData = [bowl1, bowl2]
            gameData.append(frameData)
        }
        
        for index in 0..10 {
            XCTAssertEqual(bowlingGame.bowlResult(frame: index, bowl: 0), gameData[index][0])
            XCTAssertEqual(bowlingGame.bowlResult(frame: index, bowl: 1), gameData[index][1])
        }
    }

スペアやストライクについてのロジック追加も残っていますが、ViewModel や View を作っていくことで、ここまでに作ってきたモデルを表示できるようにします。

[TDD][SwiftUI] SwiftUI と TDD で作る ボーリングスコアアプリ(その2 View と ViewModelの作成 Part1)

説明は以上です。次回に続きます。Happy Coding!

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です