[TDD][SwiftUI] SwiftUI と TDD で作る ボーリングスコアアプリ(その1 モデル作成 Part1)
[TDD][SwiftUI] SwiftUI と TDD で作る ボーリングスコアアプリ(その2 View と ViewModelの作成 Part1)
どんなモデルとビューを作ったかは、上記記事を参照ください。
Sponsor Link
アプリ設計:振り返り
ここまでに、MVVM の基本的なところを実装し、以下ができるようになっています。
- ボーリングスコア風の表示
- 1〜10フレームのスコア記録
初期の設計で困るところは発生していませんので、このまま実装していきます。
今回は、「スコア計算」です。
アプリ設計:追加設計
スペアとストライクの扱いはまだ着手せずに進めていきます。
つまり、スコア計算は、それまでの各投球でのピン数を合計すれば良いことになります。
ただし、以下を決定しておかないといけません。
- 投球が完了していないフレームでの合計スコア表示
1投しただけの状態で、その1投目までの合計を表示すべきか、2投目が終わるまで、”-” 表示で良いかを決める必要があります。
ここでは、2投目が終わるまでは(スペア、ストライクを考慮した時には、必要な情報が集められるようになるまでは)、スコア表示しないこととして、代わりに”-” を表示することとしました。
少し調べたのですが、どのように表示するのが正式なのかわかりませんでした。
もしかするとボーリングのルールとしては、途中での計算は決めていないのかもしれません。
スコア計算もチェックするテスト
前回のテストを改良して、第1フレームの2投を入力後にスコア表示をチェックするように修正しましょう。
まずは、テストでチェックできるように Accessibility ID を付与する必要があります。
ここでは、FrameScoreView0 〜 FrameScoreView9 という ID を付与するようにしています。
struct FrameScoreView: View {
@ObservedObject var viewModel: BowlingGameViewModel
let frameIndex: Int
var body: some View {
Text(viewModel.scoreAsText(frame: frameIndex))
.accessibility(identifier: "FrameScoreView\(frameIndex)")
.frame(width: 50, height: 20)
.border(Color.gray.opacity(0.5))
}
}
以下は、2投した状態で、チェックするテストです。
1投目は1ピン、2投目は5ピン倒した結果を入力として、表示内容をチェックしています。
func test_RecordOneFrame_TwoBowl_ShouldBeRecordedAndAtFirstFrame() throws {
// UI tests must launch the application that they test.
let app = XCUIApplication()
app.launch()
let button1 = app.buttons["Button1"]
XCTAssertTrue(button1.exists)
let button5 = app.buttons["Button5"]
XCTAssertTrue(button5.exists)
let frame0bowl0Label = app.staticTexts["FrameBowlView0-0"]
XCTAssertTrue(frame0bowl0Label.exists)
let frame0bowl1Label = app.staticTexts["FrameBowlView0-1"]
XCTAssertTrue(frame0bowl1Label.exists)
let frame0ScoreLabel = app.staticTexts["FrameScoreView0"]
XCTAssertTrue(frame0ScoreLabel.exists)
XCTAssertEqual(frame0bowl0Label.label, "-")
XCTAssertEqual(frame0bowl1Label.label, "-")
XCTAssertEqual(frame0ScoreLabel.label, "-")
button1.tap()
XCTAssertEqual(frame0bowl0Label.label, "1")
XCTAssertEqual(frame0bowl1Label.label, "-")
XCTAssertEqual(frame0ScoreLabel.label, "-")
button5.tap()
XCTAssertEqual(frame0bowl0Label.label, "1")
XCTAssertEqual(frame0bowl1Label.label, "5")
XCTAssertEqual(frame0ScoreLabel.label, "6")
}
setupWithError 内の、continueAfterFailure を = true とすると、Assert が Failure しても最後までテストを実行するようになります。
そうすることで、各投球の表示は期待通りになっていて、スコア表示のテストで Failure していることがわかります。
ここまでのスコア計算の実装状況
Model ではなにも実装していません。以下のように、ViewModel が、”0″ を返しているという実装です。ですので、初期状態でも、テストが Failure しています。
func scoreAsText(frame:Int) -> String {
return "0"
}
ここでは、ViewModel が計算するのではなく、Model に問い合わせて結果を View に渡すという実装にします。
スコア計算の実装
BowlingGame へ実装
モデル (BowlingGame) へスコア計算を実装していきます。
スペア・ストライクを考慮しないのであれば、フレーム分の2投後でのスコアは、
「前のフレームまでのスコア+2投で倒したピン数」となります。
また、関数返り値を Int? として、まだ計算するための情報が揃っていない時には、nil を返す仕様とします。
func frameResult(frame: Int) -> Int? {
if let lastResult = frame == 0 ? 0 : frameResult(frame: frame - 1) {
if let bowl0 = bowlResult(frame: frame, bowl: 0) {
if let bowl1 = bowlResult(frame: frame, bowl: 1) {
return lastResult + bowl0 + bowl1
}
}
}
return nil
}
ViewModel は、Model の計算結果を使って、View に返すように実装を修正します。
func scoreAsText(frame:Int) -> String {
if let result = game.frameResult(frame: frame) {
return String(result)
}
return "-"
}
先ほどの1フレームのテストは、うまく Pass します。
シミュレータでの実行は、以下のようになります。
スペア・ストライクをチェックしていないので、入力された数字の合計がスコアになっています。
いまは、1フレームしかテストしていませんので、(スペア・ストライクでない)10フレームまでの入力で、スコアが正しいことを確認するテストを追加しましょう。
Refactoring
テストを追加する前に、将来的なテストに対しての準備を兼ねて、すこし Refactoring しておきましょう。
ボタンとテキストは、常に参照すると思いますので、Setup で取得して、配列で保持しておくことにします。
テキストが存在するかを確認するテストも別途作成しました。(test_LabelExists)
ボタンをテキストを事前に取得しておくことで、これまでのテスト test_RecordOneFrame_TwoBowl_ShouldBeRecordedAndAtFirstFrame は、シンプルになりました。
//
// TDDBowlingUITests.swift
//
// Created by : Tomoaki Yagishita on 2020/11/08
// © 2020 SmallDeskSoftware
//
import XCTest
class TDDBowlingUITests: XCTestCase {
var bowlButtons: [XCUIElement] = []
var frameBowlLabels:[[XCUIElement]] = [[XCUIElement]]()
var frameScoreLabels: [XCUIElement] = []
var totalScoreLabel: XCUIElement!
override func setUpWithError() throws {
// In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = true
let app = XCUIApplication()
app.launch()
for index in 0..10 {
let button = app.buttons["Button\(index)"]
bowlButtons.append(button)
let bowl0 = app.staticTexts["FrameBowlView\(index)-0"]
let bowl1 = app.staticTexts["FrameBowlView\(index)-1"]
frameBowlLabels.append([bowl0, bowl1])
let score = app.staticTexts["FrameScoreView\(index)"]
frameScoreLabels.append(score)
}
totalScoreLabel = app.staticTexts["TotalScoreView"]
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
func test_LabelExists() throws {
for index in 0..10 {
XCTAssertTrue(frameBowlLabels[index][0].exists)
XCTAssertTrue(frameBowlLabels[index][1].exists)
XCTAssertTrue(frameScoreLabels[index].exists)
}
XCTAssertTrue(totalScoreLabel.exists)
}
func test_RecordOneFrame_TwoBowl_ShouldBeRecordedAndAtFirstFrame() throws {
XCTAssertEqual(frameBowlLabels[0][0].label, "-")
XCTAssertEqual(frameBowlLabels[0][1].label, "-")
XCTAssertEqual(frameScoreLabels[0].label, "-")
bowlButtons[1].tap()
XCTAssertEqual(frameBowlLabels[0][0].label, "1")
XCTAssertEqual(frameBowlLabels[0][1].label, "-")
XCTAssertEqual(frameScoreLabels[0].label, "-")
bowlButtons[5].tap()
XCTAssertEqual(frameBowlLabels[0][0].label, "1")
XCTAssertEqual(frameBowlLabels[0][1].label, "5")
XCTAssertEqual(frameScoreLabels[0].label, "6")
}
}
テストの追加
Refactoring して、コードが見易くなり、追加もしやすくなりましたので、10フレームまでの新しいテストを追加して、確認することにします。
テスト用のユーティリティ関数も追加しました。前回のテストは、1行になっています。
//
// TDDBowlingUITests.swift
//
// Created by : Tomoaki Yagishita on 2020/11/08
// © 2020 SmallDeskSoftware
//
import XCTest
class TDDBowlingUITests: XCTestCase {
var bowlButtons: [XCUIElement] = []
var frameBowlLabels:[[XCUIElement]] = [[XCUIElement]]()
var frameScoreLabels: [XCUIElement] = []
var totalScoreLabel: XCUIElement!
override func setUpWithError() throws {
// In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = true
let app = XCUIApplication()
app.launch()
for index in 0..10 {
let button = app.buttons["Button\(index)"]
bowlButtons.append(button)
let bowl0 = app.staticTexts["FrameBowlView\(index)-0"]
let bowl1 = app.staticTexts["FrameBowlView\(index)-1"]
frameBowlLabels.append([bowl0, bowl1])
let score = app.staticTexts["FrameScoreView\(index)"]
frameScoreLabels.append(score)
}
totalScoreLabel = app.staticTexts["TotalScoreView"]
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
func test_LabelExists() throws {
for index in 0..10 {
XCTAssertTrue(frameBowlLabels[index][0].exists)
XCTAssertTrue(frameBowlLabels[index][1].exists)
XCTAssertTrue(frameScoreLabels[index].exists)
}
XCTAssertTrue(totalScoreLabel.exists)
}
func test_RecordOneFrame_TwoBowl_ShouldBeRecordedAndAtFirstFrame() throws {
checkAndThrowFrame(prevResult: 0, frameIndex: 0, num1: 1, num2: 5)
// XCTAssertEqual(frameBowlLabels[0][0].label, "-")
// XCTAssertEqual(frameBowlLabels[0][1].label, "-")
// XCTAssertEqual(frameScoreLabels[0].label, "-")
//
// bowlButtons[1].tap()
// XCTAssertEqual(frameBowlLabels[0][0].label, "1")
// XCTAssertEqual(frameBowlLabels[0][1].label, "-")
// XCTAssertEqual(frameScoreLabels[0].label, "-")
//
// bowlButtons[5].tap()
// XCTAssertEqual(frameBowlLabels[0][0].label, "1")
// XCTAssertEqual(frameBowlLabels[0][1].label, "5")
// XCTAssertEqual(frameScoreLabels[0].label, "6")
}
func test_ScoreAtEachFrame_From0To9FrameNoSpareNoStrike_CorrectlyDisplayed() {
var localScore:Int = 0
for index in 0..10 {
let (bowl0, bowl1) = randomBowlForFrame()
checkAndThrowFrame(prevResult: localScore, frameIndex: index, num1: bowl0, num2: bowl1)
localScore = localScore + bowl0 + bowl1
}
XCTAssertEqual(totalScoreLabel.label, String(localScore))
}
// utility
func checkAndThrowFrame(prevResult: Int, frameIndex:Int, num1:Int, num2:Int) {
XCTAssertEqual(frameBowlLabels[frameIndex][0].label, "-")
XCTAssertEqual(frameBowlLabels[frameIndex][1].label, "-")
XCTAssertEqual(frameScoreLabels[frameIndex].label, "-")
self.bowlButtons[num1].tap()
XCTAssertEqual(frameBowlLabels[frameIndex][0].label, String(num1))
XCTAssertEqual(frameBowlLabels[frameIndex][1].label, "-")
XCTAssertEqual(frameScoreLabels[frameIndex].label, "-")
self.bowlButtons[num2].tap()
XCTAssertEqual(frameBowlLabels[frameIndex][0].label, String(num1))
XCTAssertEqual(frameBowlLabels[frameIndex][1].label, String(num2))
XCTAssertEqual(frameScoreLabels[frameIndex].label, String(prevResult + num1 + num2))
}
func randomBowlForFrame() -> (Int, Int) {
let bowl0 = Int.random(in: 0..10)
let bowl1 = Int.random(in: 0..(10-bowl0))
return (bowl0, bowl1)
}
}
シミュレータで動作させると、どんどんスコアが追加されていくのが見えて、面白いです。
まとめ その3
10フレームまで記録できるようにはなりました。
次に、スペア・ストライクの表示対応とスコア計算対応と進めていく予定です。
説明は以上です。次回に続きます。Happy Coding!
Sponsor Link