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

# この記事は何？
[Develop in Swift](https://developer.apple.com/tutorials/develop-in-swift)に追加された新しいチュートリアル「Custom types and Swift Testing」をやってみた、[前回記事](https://qiita.com/imchino/items/5d306720a207d22c5817)の続きです。

今回は、[Add functionality with Swift Testing](https://developer.apple.com/tutorials/develop-in-swift/add-functionality-with-swift-testing)で引き続き、スコアキーパーのモデルを改良します。
ゲームの状態を追跡し、ゲーム開始時にプレイヤーのスコアをリセットするモデルを作成します。
アプリを構築するときに **Swift Testing** を使用して単体テストを作成し、意図したとおりに機能するようにします。

> Swiftを基礎から学ぶには
自著、工学社より発売中の「[まるごと分かるSwiftプログラミング](https://amzn.asia/d/7YPWJwl)」をお勧めします。変数、関数、フロー制御構文、データ構造はもちろん、構造体からクロージャ、エクステンション、プロトコル、クロージャまでを基礎からわかりやすく解説しています。
> また、Swiftプログラミングを基礎から動画で学びたい方には、Udemyコース「[今日からはじめるプログラミング](https://www.google.com/url?sa=t&rct=j&q=&esrc=s&source=web&cd=&cad=rja&uact=8&ved=2ahUKEwii1qTuj_2BAxV4hlYBHbj5BqEQFnoECBYQAQ&url=https%3A%2F%2Fwww.udemy.com%2Fcourse%2Fstart-programming-today%2F&usg=AOvVaw2vs4Sccq-bpfA4ZD2rCyNm&opi=89978449)」をお勧めします。

## Add functionality with Swift Testing 

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

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

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

![スクリーンショット 2024-12-04 12.35.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70476/a809b230-af84-60d8-a3af-4ae5547b1d1b.png)

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

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

```swift
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`プロパティが不要になりました。
削除してください。
プレビューでプレイヤーを追加し、スコアを変更してみます。
アプリは、スコアボードを導入する前と同じように機能するはずです。

```swift
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`型に追加します。

```swift
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:)`メソッドの実装が期待どおりに機能するようにします。

```swift
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でのアプリのテスト](https://developer.apple.com/documentation/xcode/testing-your-apps-in-xcode)を参照してください。

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

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

`@Test`アノテーションは、関数をテストメソッドとしてマークします。
`async`キーワードはメソッドの並行性を、`throws`キーワードはエラーを処理できることを示しています。
これらのトピックついて、詳細は[非同期関数の定義と呼び出し](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/#Defining-and-Calling-Asynchronous-Functions)と[スロー関数を使用したエラーの伝播](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/errorhandling/#Propagating-Errors-Using-Throwing-Functions)を確認してください。

空のテストメソッド名を`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`のテストだけが、唯一の合格であることに注目してください。

```swift
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`で更新します。
テストを再実行し、合格することを確認してください。

```swift
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`マクロ](https://developer.apple.com/documentation/swiftui/previewable())を使用すると、プレビューで動的プロパティをインラインにできます。
`#Preview`の`body`内でのみ使用します。

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

```swift
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が更新されます。

```swift
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`プロパティを追加します。
現時点では、すべてのプレイヤーを返しておきます。
以前に作成したテストと同様に、テストを作成しやすくするために、最初の実装はコード量を最小限に留めます。

```swift
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`型はプロトコルに準拠していないので、エラーが発生します。
之は、次の手順で修正します。

```swift
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`型の[エクステンション](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/extensions)を追加します。
型に`Equatable`の準拠を宣言すると、Swiftは「型の全プロパティが比較した値と等しければ`true`を返す実装」を自動的に提供します。
ただし、それは今回のプレイヤーに望ましい実装ではありません。
名前とスコアに関して等しければ、IDが異なっていても同等であると見なされるべきです。
プレイヤーをどのように扱いたいか、意図的なデータモデリングを熟慮します。
`id`プロパティの値はインスタンスごとに異なるので、他のプロパティをチェックして等価性を決定すべきです。
2人のプレイヤーが同じインスタンスであるかを確認したい場合は、`id`プロパティを直接比較できます。

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

```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`を修正していきます。

```swift
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）して、すべてのテストに合格することを確認します。


```swift
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のプレビューを固定して、以降のステップを実行しやすくしておきます。
勝利したプレイヤーの横に王冠が表示されるか、試してみましょう！
プレビュー上でゲームを開始したら、プレイヤーにいくらかポイントを与えてゲームを終了します。

```swift
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* で発生するコンパイラエラーは、次のステップで修正します。

```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`バインディングを追加すると、コンパイラエラーが解消されます。

```swift
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を追加します。

```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("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を変更して、ボタンに一貫したスタイルを適用します。
大きなカプセル型の枠線付きボタンで、青色にします。

```swift
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](https://developer.apple.com/tutorials/develop-in-swift/custom-types-and-swift-testing-conclusion)

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

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

Xcodeが作成を補助できるさまざまな自動テストの詳細については、[Test Coverage](https://developer.apple.com/documentation/xcode/test-coverage)を参照してください。

- [Meet Swift Testing](https://developer.apple.com/wwdc24/10179)を視聴
- [Go Further with Swift Testing](https://developer.apple.com/wwdc24/10195)を視聴