Sponsor Link
環境&前提
- 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.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
//
// 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
}
- BowlingGame は、Frame を 10 保持します
- Frame は、Bowl を3つ保持します。3つの初期値は、それぞれ.NotYet, .NotYet, .NoNeed を指定しています
- 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としました。
当初、bowlingGame.setBowl(frameIndex, inFrameIndex, value) でセットできることを考えたのですが、いま何フレーム目の何投目かを誰かが記録しなければいけないので、やめました。
とりあえず(?)、ストライクやスペアでない 1投分 を追加して、記録できているかを確認できることをテストします。
//
// 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 の追加実装
どのフレームに記録するかの判断は、それなりにロジックを書かないといけないので、後回しにして、
とりあえず、最初のフレームの最初の投球に記録することにしました。これでテストは通るはずです。
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フレーム分保存できるテストに変更しましょう
テスト内容に合わせて、テストのメソッド名も変更しました。
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 スペア の時のみ可能なので、もう少し後にしましょう。
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フレームまで、スペア・ストライク共にないときのテストケースを追加しましょう。
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!
Sponsor Link