この記事は何?
Develop in Swiftに追加された新しいチュートリアル「Custom types and Swift Testing」をやってみた、前回記事の続きです。
今回は、Add functionality with Swift Testingで引き続き、スコアキーパーのモデルを改良します。
ゲームの状態を追跡し、ゲーム開始時にプレイヤーのスコアをリセットするモデルを作成します。
アプリを構築するときに Swift Testing を使用して単体テストを作成し、意図したとおりに機能するようにします。
Swiftを基礎から学ぶには
自著、工学社より発売中の「まるごと分かるSwiftプログラミング」をお勧めします。変数、関数、フロー制御構文、データ構造はもちろん、構造体からクロージャ、エクステンション、プロトコル、クロージャまでを基礎からわかりやすく解説しています。
また、Swiftプログラミングを基礎から動画で学びたい方には、Udemyコース「今日からはじめるプログラミング」をお勧めします。
Add functionality with Swift Testing
これまでは開発の要所に達すると、プレビューまたはシミュレーターでアプリが期待どおりに機能するかを確認しました。
この種のテストは、シンプルなアプリに最適です。
変更のたびにアプリの全機能を手動でテストすると、多くの時間と労力がかかるので、非常に面倒です。
また、確認すべきアプリの機能の一部を忘れてしまう場合があったり、以前は正常だった機能を誤って壊してしまう可能性もあります。
実際のアプリでこのスケーリングの問題に対処するためには、自動テストを作成できます。
アプリ全体の自動テストを作成するために学べき規範は幅広いですが、最小かつ最も一般的な種類のテストである単体テストに焦点を当てます。
これらは、アプリの内部機能の小さな部分のみをテストします。
単体テストが失敗した場合は、バグをすばやく見つけるのに役立ちます。
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
マクロを使用すると、プレビューで動的プロパティをインラインにできます。
#Preview
のbody
内でのみ使用します。
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を参照してください。