2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Udemyおすすめ講座をシェアしよう! by UdemyAdvent Calendar 2024

Day 11

【Swift Testing】アプリ開発に単体テストを導入する

Last updated at Posted at 2024-12-04

この記事は何?

Develop in Swiftに追加された新しいチュートリアル「Custom types and Swift Testing」をやってみた、前回記事の続きです。

今回は、Add functionality with Swift Testingで引き続き、スコアキーパーのモデルを改良します。
ゲームの状態を追跡し、ゲーム開始時にプレイヤーのスコアをリセットするモデルを作成します。
アプリを構築するときに Swift Testing を使用して単体テストを作成し、意図したとおりに機能するようにします。

Swiftを基礎から学ぶには
自著、工学社より発売中の「まるごと分かるSwiftプログラミング」をお勧めします。変数、関数、フロー制御構文、データ構造はもちろん、構造体からクロージャ、エクステンション、プロトコル、クロージャまでを基礎からわかりやすく解説しています。
また、Swiftプログラミングを基礎から動画で学びたい方には、Udemyコース「今日からはじめるプログラミング」をお勧めします。

Add functionality with Swift Testing

これまでは開発の要所に達すると、プレビューまたはシミュレーターでアプリが期待どおりに機能するかを確認しました。
この種のテストは、シンプルなアプリに最適です。

変更のたびにアプリの全機能を手動でテストすると、多くの時間と労力がかかるので、非常に面倒です。
また、確認すべきアプリの機能の一部を忘れてしまう場合があったり、以前は正常だった機能を誤って壊してしまう可能性もあります。

実際のアプリでこのスケーリングの問題に対処するためには、自動テストを作成できます。
アプリ全体の自動テストを作成するために学べき規範は幅広いですが、最小かつ最も一般的な種類のテストである単体テストに焦点を当てます。
これらは、アプリの内部機能の小さな部分のみをテストします。
単体テストが失敗した場合は、バグをすばやく見つけるのに役立ちます。

スクリーンショット 2024-12-04 12.35.05.png

1. Create the scoreboard

ビューから独立したデータ型を設計すると、コードの単体テストが容易になります。
新しいScoreboard型でアプリのロジックをモデル化して、プレイヤーとそのスコアを追跡します。
ContentViewで、アプリのロジックをこのデータ型に延長します。

Scoreboard構造体のSwiftファイルを作成します。
スコアボードはアプリのロジックをモデル化し、プレイヤー、スコア、およびゲームの現在の状態を追跡します。
UIなしのロジックのみに焦点を当てるデータ型を作成すると、単体テストでアプリのロジックをテストできます。

import Foundation

struct Scoreboard {
    var players = [
        Player(name: "Elisha", score: 0),
        Player(name: "Andre", score: 0),
        Player(name: "Jasmine", score: 0),
    ]
}

ContentView.swift に戻って、リファクタリングします。
scoreboardプロパティを追加して、スコアボードのインスタンスを割り当てます。
そして、独自のplayers配列の代わりにスコアボードのデータを使用します。
これにより、スコアボードはアプリの状態の唯一の 真実のソース(source of truth) になります。
したがって、スコアボードが意図したとおりに機能するかをテストすると、アプリの機能も意図したとおりであることを確信できます。
Player型を導入するリファクタリングでは、真っ先にplayersプロパティを置き換えて、発生したコンパイラエラーを修正しました。
今回は、コンパイラのエラーを避けるための別アプローチとして、既存のプロパティと一緒に代替品を構築します。

ForEachビューのデータを更新して、スコアボードからプレイヤーをループします。
スコアボードのplayers配列にプレイヤーを追加するために、追加ボタンのaction:クロージャを更新します。

スコアボードがプレイヤーを追跡するようになったので、ContentViewではplayerプロパティが不要になりました。
削除してください。
プレビューでプレイヤーを追加し、スコアを変更してみます。
アプリは、スコアボードを導入する前と同じように機能するはずです。

import SwiftUI

struct ContentView: View {
    @State private var scoreboard = Scoreboard()
    
    var body: some View {
        VStack(alignment: .leading) {
            Text("Score Kepper")
                .font(.title)
                .bold()
                .padding(.bottom)
            
            Grid {
                GridRow {
                    Text("Player").gridColumnAlignment(.leading)
                    Text("Score")
                }
                .font(.headline)
                
                ForEach($scoreboard.players) { $p in
                    GridRow {
                        TextField("Name", text: $p.name)
                        Text("\(p.score)")
                        Stepper("\(p.score)", value: $p.score).labelsHidden()
                    }
                }
            }
            .padding(.vertical)
            
            Button("Add Player", systemImage: "plus") {
                let newOne = Player(name: "", score: 0)
                scoreboard.players.append(newOne)
            }
            
            Spacer()
        }
        .padding()
    }
}

2. Advance and reset the game state

プレイヤーのスコアをリセットする最初のテストコードをスコアボードに導入してください。
独自のGameState列挙型を作成して、ゲームの状態をモデル化します。
そして、アプリの状態をセットアップ、プレイ、ゲームオーバーに切り替えるためのボタンを追加します。
最後に、セットアップが完了したらスコアをリセットします。

GameState.swift ファイルを作成して、GameState列挙型を宣言します。
列挙型は、関連しているが相互に排他的な値グループをまとめた型の定義です。
多くのインスタンスを持つことができる構造体とは異なり、列挙型の唯一の値は、それが定義する値のひとつだけです。
それらはゲームの状態など、いくつかに限定された異なる値をモデル化する最適な方法です。
ゲームの設定、プレイ、およびゲームオーバーの状態に達することに対応する3つの値をGameState型に追加します。

import Foundation

enum GameState {
    case setup
    case playing
    case gameOver
}

スコアボードに戻って、ゲームの状態を追跡するstateプロパティを追加します。
既定値として、GameState.setupを割り当てます。
ここで行ったように、列挙型の値に型を明示できますが、推測できる場合はそれを省略できます。
たとえば、GameState.setupの代わりに.setupのみを記述します。

ContentView.swift に戻ります。
そして、VStackの下部に、スコアボードの状態に基づいてSwitchステートメントを追加します。
EmptyViewを表示するデフォルトのケースを追加します。
Switchステートメントを使用すると、一連のIf-Elseステートメントのように、変数の値に基づいて動作を制御できます。
ただし、If-Elseステートメントとは異なり、Switchステートメントでは変数が取りうる値を完全に網羅する必要があります。
defaultケースは、どの値にも一致しなかった場合に実行するコードを提供します。

Switchステートメントに.setupケースを追加します。
このケースに一致した場合、ゲームの状態を.playingに進めるボタンを表示します。
Switchステートメントに.playingケースを追加します。
このケースに一致した場合、ゲーム状態を.gameOverに進めるボタンを表示します。
プレビューのゲーム開始ボタンを1回だけクリックするとどうなりますか?2回なら?
試してみて、予想が正しかったかどうかを確認してください。

Switchステートメントに.gameOverケースを追加します。
このケースに一致した場合、ゲームの状態を.setupにリセットするボタンを表示します。
すべてのケースが網羅されたので、コンパイラはdefaultケースが実行されないことを警告します。

defaultケースを削除して、警告を解消します。
プレビューでゲーム開始ボタンをクリックして、ケースを循環します。
ボタンのテキストが「ゲームを終了、ゲームをリセット、ゲームを開始」の順に変更されることを確認してください。

スコアボードに戻ります。
すべてのプレイヤーのスコアをリセットするためのメソッドを追加します。
実装は空白のままにして、funcの後に関数名とパラメーターリストを宣言します。
構造体では、その構造体のプロパティを変更する可能性のあるメソッドにはmutatingキーワードでマークします。
間違った実装から始めると、テストの記述が楽になります。
このアプローチでは、コンパイルされるコードの記述を最小限にしておくことが常套手段です。
空の実装なら問題なくコンパイルされるので、現時点ではこれで十分です。

コンテンツビューに戻ります。
ゲーム開始時のポイントを追跡するプロパティを追加します。
スコアボードはresetScores(to:)メソッドを使用して、プレイヤーのスコアを任意の数値にリセットできます。
ゲーム開始時にそのメソッドを呼び出しますが、それまでのポイント数を追跡する必要があります。
そのため、ビューではプロパティとしてモデル化することは合理的です。

Switchステートメントの.setupケースで、ゲームを.playingにした後でスコアをリセットします。
まだ、resetScores(to:)メソッドに何も実装していませんでしたね。
アプリのロジックを制御するのはスコアボードですが、ユーザーインターフェイスと接続しただけです。
以降、テストを書いて、resetScores(to:)メソッドの実装が期待どおりに機能するようにします。

import SwiftUI

struct ContentView: View {
    @State private var scoreboard = Scoreboard()
    private var startingPoint = 0
    
    var body: some View {
        VStack(alignment: .leading) {
            Text("Score Kepper")
                .font(.title)
                .bold()
                .padding(.bottom)
            
            Grid {
                GridRow {
                    Text("Player").gridColumnAlignment(.leading)
                    Text("Score")
                }
                .font(.headline)
                
                ForEach($scoreboard.players) { $p in
                    GridRow {
                        TextField("Name", text: $p.name)
                        Text("\(p.score)")
                        Stepper("\(p.score)", value: $p.score).labelsHidden()
                    }
                }
            }
            .padding(.vertical)
            
            Button("Add Player", systemImage: "plus") {
                let newOne = Player(name: "", score: 0)
                scoreboard.players.append(newOne)
            }
            
            Spacer()
            
            switch scoreboard.state {
            case .setup:
                Button("Start Game", systemImage: "play.fill") {
                    scoreboard.state = .playing
                    scoreboard.resetScores(to: startingPoint)
                }
            case .playing:
                Button("End Game", systemImage: "stop.fill") {
                    scoreboard.state = .gameOver
                }
            case .gameOver:
                Button("Reset Game", systemImage: "arrow.counterclockwise") {
                    scoreboard.state = .setup
                }
            }
        }
        .padding()
    }
}

3. Create a unit test target

アプリのテストするにあたっては、それを作成して実行する場所が必要です。
設定を編集して、単体テストのバンドルターゲットをプロジェクトに追加します。

プロジェクトナビゲーターで、ScoreKeeperを選択します。
プロジェクトとターゲットの下部で、「+」ボタンをクリックします。

ターゲットは、Xcodeのプロジェクトで構築できる成果物全般です。
すでに、プロジェクトのターゲットにはアプリ自体があることに注目してください。
単体テストのターゲットを追加して、それらをアプリから分離します。
つまり、アプリを起動しなくてもテストを構築して実行できます。
また、テストなしでアプリを配布できることも理解してください。

テンプレート選択ダイアログで、iOS タブを選択します。
Test セクションで、Unit Testing Bundleを選択します。
Next をクリックします。
ターゲットオプションのダイアログで、Product Name を「ScoreKeeperTests」のままにします。
Finish をクリックします。

単体テストは、アプリの内部モデルとメソッドのテストに焦点を当てています。
このチュートリアルでは、簡潔で書きやすい単体テストの作成にフォーカスします。
UIテストバンドルはデバイス上で直接テストするのかのように、アプリ全体のテストを自動化するためのものです。
作成できるテストの種類について、詳細はXcodeでのアプリのテストを参照してください。

4. Test and implement your method

resetScores(to:)メソッドに実装する機能に基づいて、いくつかのテストを作成します。
そして、実装後に期待通りに機能するかを確認します。
テストファーストなこの方法は、一般的に テスト駆動型開発 と呼ばれます。

プロジェクトナビゲータで、ScoreKeeperTests フォルダから ScoreKeeperTests.swift ファイルを開きます。
そこにあるtestExampleメソッドに注目します。
あとでアプリをテストするために、このメソッドを変更します。

@Testアノテーションは、関数をテストメソッドとしてマークします。
asyncキーワードはメソッドの並行性を、throwsキーワードはエラーを処理できることを示しています。
これらのトピックついて、詳細は非同期関数の定義と呼び出しスロー関数を使用したエラーの伝播を確認してください。

空のテストメソッド名をresetScoresに変更します。
@Testアノテーションに「テストを簡潔に説明するタイトル」を追加します。
テストナビゲータは@Testアノテーションに渡された文字列を使って、テストを参照します。

ファイル上部でScoreKeeperをインポートして、@testableアノテーションをマークします。
テストのプロダクトは、アプリとはターゲットが別のプロダクトです。
そのため、テストするためには、ScoreKeeperアプリをインポートする必要があるということです。
通常、アプリ内のデータ型とメソッドは外部からアクセスできません。
ただし、ScoreKeeperTestsはテスト専用のビルドなので、@testableアノテーションでインポートすれば、アプリ内のメソッドにアクセスできます。

一貫性のあるやり方で、テストを作成します。
まず、スコアボードの状態を指定します。
次に、テストしたいメソッドまたは機能を呼び出します。
最後に、そのメソッドの結果としてスコアボードが変更されたことをアサートします。

resetScoresで、プレイヤーのリストを含むスコアボードを作成します。
このとき、「スコアが0ではないプレイヤー」を、少なくとも1人は作成することを確認してください。
そして、スコアを0に設定するためのresetScores(to:)メソッドを、スコアボードから呼び出します。

スコアボードで各プレイヤーをループして、#expectで期待は「そのプレイヤーのスコアが0であること」と明記してください。
テストを実行すると、Testingフレームワークは#expectステートメントを評価します。
falseと評価された場合、そのテストは失敗します。

Xcodeの「Product > Test」コマンドを選択して、テストを実行します。
テストが終了すると、エディターのテスト名の左側に赤いアイコンが表示され、失敗したことがわかります。
エラーフラグに説明が表示され、テストナビゲーターでも確認できます。
最初にテストを失敗させることは、テスト工程において重要です。
現時点でresetScores(to:)メソッドには実装がないので、どのプレイヤーもスコアはゼロにリセットされません。
それにも関わらずテストが合格した場合、テスト側に不正があり、それをデバッグする必要があるとわかります。
このようにしてテストをテストすると、信頼性の高い結果を得られます。

スコアボードを開きます。
resetScores(to:)メソッドの実装で、全プレイヤーのスコアを0にします。
もう一度、「Product > Test」を選択して、テストを実行します。
今回のテストは合格します。

テストをいくつも書いて、resetScores(to:)メソッドに他の入力もテストできます。
ただそれよりも、テスト値をパラメータにして、Testingフレームワークにそれをさせた方が簡単です。

ScoreKeeperTests.swift に戻ります。
resetScoresテストを更新して、引数を宣言します。
これは、呼び出されたときにプレイヤーのスコアをリセットする値です。
コンパイルエラーが発生するので、@Testアノテーションのarguments:引数にテストメソッドに送信したい値を追加します。

テストを再び実行して、複数の失敗があることを確認してください。
テストナビゲーターに、各引数でテストを実行した結果が表示されます。
値が0のテストだけが、唯一の合格であることに注目してください。

import Testing
@testable import ScoreKeeper

struct ScoreKeeperTests {

    @Test("Reset player scores", arguments: [0, 10, 20])
    func resetScores(to newValue: Int) async throws {
        var scoreboard = Scoreboard(players: [
            Player(name: "Elisha", score: 0),
            Player(name: "Andre", score: 5),
        ])
        scoreboard.resetScores(to: newValue)
        
        for player in scoreboard.players {
            #expect(player.score == newValue)
        }
    }

}

スコアボードに戻ります。
resetScores(to:)メソッドの実装で、スコアを固定の0ではなく、引数のnewValueで更新します。
テストを再実行し、合格することを確認してください。

import Foundation

struct Scoreboard {
    var players = [
        Player(name: "Elisha", score: 0),
        Player(name: "Andre", score: 0),
        Player(name: "Jasmine", score: 0),
    ]
    
    var state = GameState.setup
    
    mutating func resetScores(to newValue: Int) {
        for i in 0..<players.count {
            players[i].score = newValue
        }
    }
}

5. Create a game settings view

スコアキーパーアプリは「ゴールした得点」や「残りの体力ポイント」といった、あらゆる種類のゲームポイントを表現できます。
プレイヤーが一定値やその他のポイントでゲームを開始できるように、全プレイヤーの開始ポイント数を変更するためのビューを追加します。
後で、このビューに別の設定オプションを追加します。

SwiftUIビューの SettingsView.swift を作成します。
テキストビューをVStackでラップします。
VStackに.leadingアライメントと余白を提供します。
テキストビューに「"Game Rules"」と表示します。
.headlineフォントにして、その下にDividerビューを追加します。

startingPointsプロパティを追加して、各プレイヤーの開始ポイントを追跡します。
このプロパティに@Bindingをマークします。
@Bindingはデータを直接保存するのではなく、プロパティを他の場所に保存された「真実のソース」と紐付けます。
プレビューコードでエラーが発生するので、次のステップで修正します。

プレビューコードのSettingsView型にstartingPoints引数を追加します。
@Previewable@Stateのアノテーションを使用して、#Preview内でstartingPointsプロパティを宣言します。
@Previewableマクロを使用すると、プレビューで動的プロパティをインラインにできます。
#Previewbody内でのみ使用します。

startingPointsプロパティの更新値を選択するための、Pickerビューを追加します。
アイテムから選択肢を選ばせたい場合、Pickerビューを使用します。
ピッカーの選択肢として、3つのテキストビューを追加します。
開始ポイントごとに、.tagを設定します。
Pickerはバインディング値を、「選択されたタグの値」で更新します。
開始ポイントを有効にする前に、このビューに背景が.thinMaterialな「角丸の長方形」を付け足します。

import SwiftUI

struct SettingsView: View {
    @Binding var startingPoints: Int
    
    var body: some View {
        VStack(alignment: .leading) {
            Text("Game Rules")
                .font(.headline)
            Divider()
            Picker("Starting points", selection: $startingPoints) {
                Text("0 staring points")
                    .tag(0)
                Text("10 staring points")
                    .tag(10)
                Text("20 staring points")
                    .tag(20)
            }
        }
        .padding()
        .background(.thinMaterial, in: .rect(cornerRadius: 10.0))
    }
}

#Preview {
    @Previewable @State var staringPoints = 10
    SettingsView(startingPoints: $staringPoints )
}

コンテンツビューを開きます。
@StateプロパティラッパーをstartingPointsプロパティに追加します。

VStack内で、タイトルテキストの下にSettingsViewを追加します。
バインディングをstartingPoints引数に渡します。
開始ポイントの設定を変更して、ゲームを始めてみましょう。
@Stateバインディングによって、選択済みの開始ポイントでUIが更新されます。

import SwiftUI

struct ContentView: View {
    @State private var scoreboard = Scoreboard()
    @State private var startingPoint = 0
    
    var body: some View {
        VStack(alignment: .leading) {
            Text("Score Kepper")
                .font(.title)
                .bold()
                .padding(.bottom)
            
            SettingsView(startingPoints: $startingPoint)
            
            Grid {
                GridRow {
                    Text("Player").gridColumnAlignment(.leading)
                    Text("Score")
                }
                .font(.headline)
                
                ForEach($scoreboard.players) { $p in
                    GridRow {
                        TextField("Name", text: $p.name)
                        Text("\(p.score)")
                        Stepper("\(p.score)", value: $p.score).labelsHidden()
                    }
                }
            }
            .padding(.vertical)
            
            Button("Add Player", systemImage: "plus") {
                let newOne = Player(name: "", score: 0)
                scoreboard.players.append(newOne)
            }
            
            Spacer()
            
            switch scoreboard.state {
            case .setup:
                Button("Start Game", systemImage: "play.fill") {
                    scoreboard.state = .playing
                    scoreboard.resetScores(to: startingPoint)
                }
            case .playing:
                Button("End Game", systemImage: "stop.fill") {
                    scoreboard.state = .gameOver
                }
            case .gameOver:
                Button("Reset Game", systemImage: "arrow.counterclockwise") {
                    scoreboard.state = .setup
                }
            }
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

6. Declare winners

Scoreboardモデルを反復して、ゲーム終了時の勝者を決定する計算プロパティを追加します。
勝つための複数の方法を提供し、プロパティがゲーム設定に基づいて勝者を正しく選択することをテストします。

Scoreboard.swift を開きます。
doesHighestScoreWinプロパティを追加します。
このプロパティを使って、「最高スコアのプレイヤーを勝者とするか、最低スコアのプレイヤーを勝者とするか」を判断します。
さらに、計算プロパティのwinnersプロパティを追加します。
現時点では、すべてのプレイヤーを返しておきます。
以前に作成したテストと同様に、テストを作成しやすくするために、最初の実装はコード量を最小限に留めます。

import Foundation

struct Scoreboard {
    var players = [
        Player(name: "Elisha",  score: 0),
        Player(name: "Andre",   score: 0),
        Player(name: "Jasmine", score: 0),
    ]
    
    var state = GameState.setup
    var doesHighestScoreWin = true

    var winners: [Player] {
        players
    }
    mutating func resetScores(to newValue: Int) {
        for i in 0..<players.count {
            players[i].score = newValue
        }
    }
}

ScoreKeeperTests.swift に戻ります。
最高スコアのプレイヤーが勝つことを確認する、空のテストを作成します。
テストに打ってつけな、「プレイヤーは2人だけで、どちらかのスコアが高く、ゲームオーバー状態で最高スコアが勝つ」というScoreboard型インスタンスを作成します。
スコアボードのwinnersプロパティが「最高スコアのプレイヤーだけの配列と等しい」という期待を見立てて、出力します。
#expect式が評価できるのはEqableなオブジェクトだけですが、Player型はプロトコルに準拠していないので、エラーが発生します。
之は、次の手順で修正します。

import Testing
@testable import ScoreKeeper

struct ScoreKeeperTests {

    @Test("Reset player scores", arguments: [0, 10, 20])
    func resetScores(to newValue: Int) async throws {
        var scoreboard = Scoreboard(players: [
            Player(name: "Elisha", score: 0),
            Player(name: "Andre", score: 5),
        ])
        scoreboard.resetScores(to: newValue)
        
        for player in scoreboard.players {
            #expect(player.score == newValue)
        }
    }

    @Test("Highest score wins")
    func highestScoreWins() {
        let scoreboard = Scoreboard(
            players: [
                Player(name: "Elisha", score: 0),
                Player(name: "Andre", score: 4)
            ],
            state: .gameOver,
            doesHighestScoreWin: true
        )
        
        let winners = scoreboard.winners
        #expect(winners == [Player(name: "Andre", score: 4)])   // error; Operator function '==' requires that 'Player' conform to 'Equatable'
    }
}

Player.swift を開きます。
Equatableプロトコル適合を追加するために、Player型のエクステンションを追加します。
型にEquatableの準拠を宣言すると、Swiftは「型の全プロパティが比較した値と等しければtrueを返す実装」を自動的に提供します。
ただし、それは今回のプレイヤーに望ましい実装ではありません。
名前とスコアに関して等しければ、IDが異なっていても同等であると見なされるべきです。
プレイヤーをどのように扱いたいか、意図的なデータモデリングを熟慮します。
idプロパティの値はインスタンスごとに異なるので、他のプロパティをチェックして等価性を決定すべきです。
2人のプレイヤーが同じインスタンスであるかを確認したい場合は、idプロパティを直接比較できます。

2つのPlayger型インスタンスが同じ名前とスコアだった場合にtrueを返す、Equatable適合を独自に実装します。
これを行うには、Player型の==メソッドを定義します。
このメソッドは「指定順だけで区別する、意味のない2つのパラメーター」を受け取ります。
慣例上、この種のメソッドのパラメータは大抵、「左側」のlhsと「右側」のrhsと呼ばれます。
自身で!=メソッドを実装することもできますが、Equatable適合を宣言すると、Swiftは「==メソッドの反対を返す実装」を提供します。

import Foundation

struct Player: Identifiable {
    var id = UUID()
    var name: String
    var score: Int
}

extension Player: Equatable {
    static func == (lhs: Player, rhs: Player) -> Bool {
        lhs.name == rhs.name && lhs.score == rhs.score
    }
}

ScoreKeeperTests.swift に戻ります。
そして、エラーが解決されていることに注目してください。
最低スコアが勝つゲームの正しい結果をテストすために、空のテストメソッドを追加します。
試しに、次のステップで実装を確認する前に、この空テストを実装してみましょう。
テストのスコアボードとwinners変数を作成して、#expectで結果が期待どおりであることを確認します。

テストを実行(Product > Test)すると、両メソッドとも失敗します。
何度も言いますが、これは現時点で想定していることです。
Scoreboard.winnersの実装は、全員がゲームに勝つ計算プロパティだからです。
新しいテストが失敗することを確認したので、正しいプレイヤーのみを選択するようにwinnersを修正していきます。

import Testing
@testable import ScoreKeeper

struct ScoreKeeperTests {

    @Test("Reset player scores", arguments: [0, 10, 20])
    func resetScores(to newValue: Int) async throws {
        var scoreboard = Scoreboard(players: [
            Player(name: "Elisha", score: 0),
            Player(name: "Andre", score: 5),
        ])
        scoreboard.resetScores(to: newValue)
        
        for player in scoreboard.players {
            #expect(player.score == newValue)
        }
    }

    @Test("Highest score wins")
    func highestScoreWins() {
        let scoreboard = Scoreboard(
            players: [
                Player(name: "Elisha", score: 0),
                Player(name: "Andre", score: 4)
            ],
            state: .gameOver,
            doesHighestScoreWin: true
        )
        
        let winners = scoreboard.winners
        #expect(winners == [Player(name: "Andre", score: 4)])
    }
    
    @Test("Lowest score wins")
    func lowestScoreWins()  {
        let scoreboard = Scoreboard(
            players: [
                Player(name: "Elisha", score: 0),
                Player(name: "Andre", score: 4)
            ],
            state: .gameOver,
            doesHighestScoreWin: false
        )
        
        let winners = scoreboard.winners
        #expect(winners == [Player(name: "Elisha", score: 0)])
    }
}

Scoreboard.swiftに戻ります。
winnersプロパティでGuardステートメントを使用して、ゲームが.gameOver状態でない場合は「空の配列」を返します。
クロージャは複数行になるので、returnキーワードでplayersを返します。

勝利スコアを持つすべてのプレイヤーは、ゲームに勝ったと見なされます。
winningScore変数を作成して、Ifステートメントで「ゲームのルール」を評価します。

最高スコアが勝つゲームでは、すべてのプレイヤーをループし、プレイヤーのスコアまたは以前の勝利スコアの高い方を保持します。
Int.minの勝利スコアから始めれば、すべてのプレイヤーのスコアがそれ以上になります。
最初のプレイヤーと比較するスコアが必要なので、Int.minを開始スコアとして使用します。
最低スコアが勝つゲームでは、すべてのプレイヤーをループし、プレイヤーのスコアまたは以前の勝利スコアの低い方を保持します。
すべてのプレイヤーがそのスコア以下になるため、Int.maxの勝利スコアから始めます。

winningScoreのプレイヤーだけを含めるようにフィルタリングして、プレイヤーの配列を返します。
filter(_:)メソッドは関数型プログラミングの類です。
末尾クロージャがtrueを返すオブジェクトだけの、新しいコレクションを返します。
For-inループでも同じ手続きをできますが、これが洗練されたやり方です。
再び、テストを実行(Product > Test)して、すべてのテストに合格することを確認します。

import Foundation

struct Scoreboard {
    var players = [
        Player(name: "Elisha",  score: 0),
        Player(name: "Andre",   score: 0),
        Player(name: "Jasmine", score: 0),
    ]
    
    var state = GameState.setup
    var doesHighestScoreWin = true

    var winners: [Player] {
        guard state == .gameOver else { return [] }
        
        var winningScore = 0
        if doesHighestScoreWin {
            winningScore = Int.min
            for p in players {
                winningScore = max(p.score, winningScore)
            }
        } else {
            winningScore = Int.max
            for p in players {
                winningScore = min(p.score, winningScore)
            }
        }
        
        return players.filter { p in
            p.score == winningScore
        }
    }
    
    mutating func resetScores(to newValue: Int) {
        for i in 0..<players.count {
            players[i].score = newValue
        }
    }
}

7. Crown your winners

Scoreboardが勝者を正しく宣言できるようになったので、UI上で勝者を強調表示します。
ゲームが終了したら、すべての勝利プレイヤーの横に王冠を追加します。

ContentView.swift を開きます。
プレイヤー名のTextFieldをHStackでラップします。
そのTextFieldの前に「プレイヤーがスコアボードのwinners配列に含まれているか」を評価するIfステートメントを、追加します。
条件を満たすなら、黄色い王冠を表示します。
ContentViewのプレビューを固定して、以降のステップを実行しやすくしておきます。
勝利したプレイヤーの横に王冠が表示されるか、試してみましょう!
プレビュー上でゲームを開始したら、プレイヤーにいくらかポイントを与えてゲームを終了します。

import SwiftUI

struct ContentView: View {
    @State private var scoreboard = Scoreboard()
    @State private var startingPoint = 0
    
    var body: some View {
        VStack(alignment: .leading) {
            Text("Score Kepper")
                .font(.title)
                .bold()
                .padding(.bottom)
            
            SettingsView(startingPoints: $startingPoint)
            
            Grid {
                GridRow {
                    Text("Player").gridColumnAlignment(.leading)
                    Text("Score")
                }
                .font(.headline)
                
                ForEach($scoreboard.players) { $p in
                    GridRow {
                        HStack {
                            if scoreboard.winners.contains(p) {
                                Image(systemName: "crown.fill")
                                    .foregroundStyle(Color.yellow)
                            }
                            TextField("Name", text: $p.name)
                        }
                        Text("\(p.score)")
                        Stepper("\(p.score)", value: $p.score).labelsHidden()
                    }
                }
            }
            .padding(.vertical)
            
            Button("Add Player", systemImage: "plus") {
                let newOne = Player(name: "", score: 0)
                scoreboard.players.append(newOne)
            }
            
            Spacer()
            
            switch scoreboard.state {
            case .setup:
                Button("Start Game", systemImage: "play.fill") {
                    scoreboard.state = .playing
                    scoreboard.resetScores(to: startingPoint)
                }
            case .playing:
                Button("End Game", systemImage: "stop.fill") {
                    scoreboard.state = .gameOver
                }
            case .gameOver:
                Button("Reset Game", systemImage: "arrow.counterclockwise") {
                    scoreboard.state = .setup
                }
            }
        }
        .padding()
    }
}

SettingsView.swift に切り替えます。
doesHighestScoreWinプロパティを追加して、@Bindingをマークします。
プレビューコードにもdoesHighestScoreWinプロパティを追加して、@Previewable@Stateをマークします。
ContentView.swift で発生するコンパイラエラーは、次のステップで修正します。

import SwiftUI

struct SettingsView: View {
    @Binding var doesHighestScoreWin: Bool
    @Binding var startingPoints: Int
    
    var body: some View {
        VStack(alignment: .leading) {
            Text("Game Rules")
                .font(.headline)
            Divider()
            Picker("Starting points", selection: $startingPoints) {
                Text("0 staring points")
                    .tag(0)
                Text("10 staring points")
                    .tag(10)
                Text("20 staring points")
                    .tag(20)
            }
        }
        .padding()
        .background(.thinMaterial, in: .rect(cornerRadius: 10.0))
    }
}

#Preview {
    @Previewable @State var doesHighestScoreWin = true
    @Previewable @State var staringPoints = 10
    SettingsView(doesHighestScoreWin: $doesHighestScoreWin, startingPoints: $staringPoints)
}

ContentView.swift を開きます。
settingsViewイニシャライザにdoesHighestScoreWinバインディングを追加すると、コンパイラエラーが解消されます。

import SwiftUI

struct ContentView: View {
    @State private var scoreboard = Scoreboard()
    @State private var startingPoint = 0
    
    var body: some View {
        VStack(alignment: .leading) {
            Text("Score Kepper")
                .font(.title)
                .bold()
                .padding(.bottom)
            
            SettingsView(
                doesHighestScoreWin: $scoreboard.doesHighestScoreWin,
                startingPoints: $startingPoint
            )
            
            Grid {
                GridRow {
                    Text("Player").gridColumnAlignment(.leading)
                    Text("Score")
                }
                .font(.headline)
                
                ForEach($scoreboard.players) { $p in
                    GridRow {
                        HStack {
                            if scoreboard.winners.contains(p) {
                                Image(systemName: "crown.fill")
                                    .foregroundStyle(Color.yellow)
                            }
                            TextField("Name", text: $p.name)
                        }
                        Text("\(p.score)")
                        Stepper("\(p.score)", value: $p.score).labelsHidden()
                    }
                }
            }
            .padding(.vertical)
            
            Button("Add Player", systemImage: "plus") {
                let newOne = Player(name: "", score: 0)
                scoreboard.players.append(newOne)
            }
            
            Spacer()
            
            switch scoreboard.state {
            case .setup:
                Button("Start Game", systemImage: "play.fill") {
                    scoreboard.state = .playing
                    scoreboard.resetScores(to: startingPoint)
                }
            case .playing:
                Button("End Game", systemImage: "stop.fill") {
                    scoreboard.state = .gameOver
                }
            case .gameOver:
                Button("Reset Game", systemImage: "arrow.counterclockwise") {
                    scoreboard.state = .setup
                }
            }
        }
        .padding()
    }
}

SettingsView.swift に戻ります。
「最高スコアが勝つのか、最低スコアが勝つのか」を選択できるPickerを追加します。

import SwiftUI

struct SettingsView: View {
    @Binding var doesHighestScoreWin: Bool
    @Binding var startingPoints: Int
    
    var body: some View {
        VStack(alignment: .leading) {
            Text("Game Rules").font(.headline)
            Divider()
            Picker("Win condition", selection: $doesHighestScoreWin) {
                Text("Highest Score Sins").tag(true)
                Text("Lowest Score Sins").tag(false)
            }
            Picker("Starting points", selection: $startingPoints) {
                Text("0 staring points").tag(0)
                Text("10 staring points").tag(10)
                Text("20 staring points").tag(20)
            }
        }
        .padding()
        .background(.thinMaterial, in: .rect(cornerRadius: 10.0))
    }
}

#Preview {
    @Previewable @State var doesHighestScoreWin = true
    @Previewable @State var staringPoints = 10
    SettingsView(doesHighestScoreWin: $doesHighestScoreWin, startingPoints: $staringPoints)
}

プレビューで、アプリを遊んでみます。
ルールの組み合わせをいろいろ試して、ゲームを開始します。
ステッパーで各プレイヤーのスコアを増減してから、ゲームを終了します。
勝利プレイヤーが正しいことを確認してください。

8. Improve your app design

ゲームの状態に基づいてビューを無効にしたり非表示にしたりすることで、アプリに少し磨きをかけます。

ContentView.swift を開きます。
スコアボードが.setup状態でない場合、SettingsViewとプレイヤー名のTextFieldを無効にします。
スコアボードが.setup状態にある場合、スコア列とStepperコントロールの不透明度を0にします。
スコアボードが.setup状態でない場合は、プレイヤー追加ボタンの不透明度を0にします。
不透明度が0のボタンは、誤ってタップしても反応しません。
ボタンを中央に配置するために、SwitchステートメントをHStackでラップして、両側にSpacerを配置します。
HStackを変更して、ボタンに一貫したスタイルを適用します。
大きなカプセル型の枠線付きボタンで、青色にします。

import SwiftUI

struct ContentView: View {
    @State private var scoreboard = Scoreboard()
    @State private var startingPoint = 0
    
    var body: some View {
        VStack(alignment: .leading) {
            Text("Score Kepper")
                .font(.title)
                .bold()
                .padding(.bottom)
            
            SettingsView(
                doesHighestScoreWin: $scoreboard.doesHighestScoreWin,
                startingPoints: $startingPoint
            )
            .disabled(scoreboard.state != .setup)
            
            Grid {
                GridRow {
                    Text("Player").gridColumnAlignment(.leading)
                    Text("Score").opacity(scoreboard.state == .setup ? 0 : 1.0)
                }
                .font(.headline)
                
                ForEach($scoreboard.players) { $p in
                    GridRow {
                        HStack {
                            if scoreboard.winners.contains(p) {
                                Image(systemName: "crown.fill").foregroundStyle(Color.yellow)
                            }
                            TextField("Name", text: $p.name)
                                .disabled(scoreboard.state != .setup)
                        }
                        Text("\(p.score)")
                            .opacity(scoreboard.state == .setup ? 0 : 1.0)
                        Stepper("\(p.score)", value: $p.score)
                            .labelsHidden()
                            .opacity(scoreboard.state == .setup ? 0 : 1.0)
                    }
                }
            }
            .padding(.vertical)
            
            Button("Add Player", systemImage: "plus") {
                let newOne = Player(name: "", score: 0)
                scoreboard.players.append(newOne)
            }
            .opacity(scoreboard.state == .setup ? 1.0 : 0)
            
            Spacer()
            
            HStack {
                Spacer()
                switch scoreboard.state {
                case .setup:
                    Button("Start Game", systemImage: "play.fill") {
                        scoreboard.state = .playing
                        scoreboard.resetScores(to: startingPoint)
                    }
                case .playing:
                    Button("End Game", systemImage: "stop.fill") {
                        scoreboard.state = .gameOver
                    }
                case .gameOver:
                    Button("Reset Game", systemImage: "arrow.counterclockwise") {
                        scoreboard.state = .setup
                    }
                }
                Spacer()
            }
            .buttonStyle(.bordered)
            .buttonBorderShape(.capsule)
            .controlSize(.large)
            .tint(.blue)
        }
        .padding()
    }
}

プレビューで、アプリの全機能を確認します。

Wrap-up: Custom types and Swift Testing

単体テストは、アプリの機能を期待どおりにするための優れた方法です。
小さな焦点に絞った一連のテストがあれば、早期にバグを発見できるので、修正が容易にできます。

このテクニックは、アプリの重要なロジックを処理するカスタム型の設計時には、特に有効です。
アプリのコアロジックの部分でカスタム型を作成すると、期待通りであることを証明するためのテストを作成できます。
その後、これらのデータ型をテストで行ったのと同じように使用すれば、アプリが意図したとおりに機能することを確信できます。

Xcodeが作成を補助できるさまざまな自動テストの詳細については、Test Coverageを参照してください。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?