A Tour of isowords: Part4 を読む
これを読む
A router and API client in one
このエピソードでは client と sever 側で routing の仕組みを共有する方法について見ていく
まず、アクティブな target を ServerRouter に切り替える
このモジュールには、server サイドで受信したリクエストを解析し、iOS アプリに送信するリクエストを生成するために必要なコードの全てが含まれている
またモジュール内に Playground がある
最近の SPM の改良により、Package 内で Playground を実行できるようになった
まず、Router.playground を開いてみよう
ここには Router を操作するために必要なライブラリをimportするためのコードが既に少し入っており、Router がロジックを実装するために必要な依存関係を注入することで、Router を構築している
import Foundation
import ServerRouter
import SharedModels
let router = ServerRouter.router(
date: Date.init,
decoder: JSONDecoder(),
encoder: JSONEncoder(),
secrets: ["deadbeef"],
sha256: { $0 }
)
これらの依存関係の中には、JSONDecoder・JSONEndoer のようにそれほど驚くべきではないものがある
Router には POST リクエストの body をデシリアライズおよびシリアライズする必要がある部分があり、これらのオブジェクトを注入することで、その作業を行う方法に一貫性を持たせている
他の三つのオブジェクトはあまり目立たないが、特定の API リクエストが改竄されないように署名するために必要である
この一つの値は、server 側から入ってくるリクエストの routing と client 側から出ていくリクエストの構築を同時に行うことができる
実際に使ってみて、そのことを実証してみる
受信リクエストの routing とは、生の URL リクエストが入ってきて、それを解析して first party type に変換し、実際の server ロジックを実行するのに使用することを意味する
これは基本的に、視聴者の多くが自分のアプリケーションで deep linking をサポートするために採用しているのと同じ原理である
ServerRoute.swift では、first class のデータを変換することができる
public enum ServerRoute: Equatable {
case api(Api)
case appSiteAssociation
case appStore
case authenticate(AuthenticateRequest)
case demo(Demo)
case download
case home
case pressKit
case privacyPolicy
case sharedGame(SharedGame)
...
}
これは基本的にはサイトや API の全ての部分と対話できる大きな enum である
Api、Demo、SharedGame のようにさらにネストした enum に繋がるものもある
Router 経由でこれらの case の一つに対応するシンプルなリクエストを作成してみよう
ホームページはおそらく isowords のウェブサイトの route へのリクエストであると考えられるので、 match
メソッドを使ってそのようなリクエストを解析してみよう
match
メソッドには複数の overload があり、URL、リクエストメソッド、ヘッダー、ボディを含む完全な URL Request を受け取るものや、単に URL や URL String を受け取るヘルパーなどがある
ここでは簡単に説明する
router
.match(string: "https://www.isowords.xyz/")
// .home
そして確かに route の URL は home route と一致している
press kit も多分、 /press-kit
くらいのシンプルなものだと思う
router
.match(string: "https://www.isowords.xyz/press-kit")
// .pressKit
これも同じく実際に pressKit の route と一致する
ではもう少し複雑なことをやってみよう
デイリーチャレンジのこれまでの結果を取得する API リクエストを構築してみよう
これはホーム画面で読み込まれ、上部にある「X people have already played」というモジュールを表示するためのものである
この route のほとんどがどのようなものか覚えていると思うので、それを試してみる
全ての API リクエストが /api
パスの component name space にネストされていることは知っているので、そこから始めてみる
router
.match(string: "https://www.isowords.xyz/api/")
// nil
現在、 /api
パスの route を表す route の case がないため、これは nil を返す
しかし、さらにデイリーチャレンジの route が /daily-challenges
path component にあることを知っている
router
.match(string: "https://www.isowords.xyz/api/daily-challenges")
// nil
これはまだ nil を返す
なぜなら route の一つを正確に表すリクエストがまだ構築されていないからである
今日のデイリーチャレンジの結果を得るためには、さらに path component /today
を追加する必要がある
router
.match(string: "https://www.isowords.xyz/api/daily-challenges/today")
// nil
これでもまだ nil だが、これは必要なクエリパラメータがいくつかあるからである
最も重要なのは、全ての API リクエストに access token を添付することである
access token はゲーム内での認証時に全ての Player に割り当てられる
これをクエリパラメータで提供することができる
router
.match(string: "https://www.isowords.xyz/api/daily-challenges/today?accessToken=deadbeef-dead-beef-dead-beefdeadbeef")
// nil
そして最後に、どの言語でデイリーチャレンジの結果を取得しているかを知るために、言語パラメータを提供する必要がある
現在 isowords は英語でしか利用できないが、近いうちに他の言語でも利用できるようにしたいと考えており、そのためにはスコアや leaderboards を言語別にセグメント化する必要がある
access token と同じようにクエリパラメータでこれを提供できる
router
.match(string: "https://www.isowords.xyz/api/daily-challenges/today?accessToken=deadbeef-dead-beef-dead-beefdeadbeef&language=en")
// .api(
// Api(
// accessToken: DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF,
// isDebug: false,
// route: .dailyChallenge(.today(language: .en))
// )
// )
これで最終的に nil
ではない値が得られる
これは router がリクエストを正常に認識したことを意味する
これは .api
route を指し、その中で .dailyChallenage
route を指し、その中で言語が .en
の .today
route を指す深くネストされた enum である
これが isowords の server での routing の仕組みである
リクエストが来るたびに、router の .match
メソッドを使って、リクエストがどの route を表しているかを把握し、次に複雑な enum のスイッチを入れて、server ロジックのどの部分を実行するのかを把握する
しかし、これは router の責任の半分に過ぎない
残りの半分は、API へのリクエストを行うためのリクエストを生成することである
これは非常に重要なことである。というのも、先ほどやったことからわかるように、isowords の route は非常に複雑で、正しく理解しなければならないことが数多くあるからである
生の URL Request を一から作るよりも、コンパイラによって静的に型チェックされる ServerRoute enum の値を作って、router にそれをリクエストにすることを任せた方が良いだろう
幸運なことに、それはまさに isowords の router が行うことである
router の .request(for:)
メソッドは、ServerRoute の値を受け取り、リクエストを返す
router
.request(for: <#T##ServerRoute#>)
ここで、 .
を入力してオートコンプリートでリクエスト可能なものを全て表示させることもできる
そうすると先ほどの ServerRoute ファイルで見た全ての選択肢が表示される
api
appSiteAssociation
appStore
authenticate
demo
download
home
pressKit
privacyPolicy
sharedGame
今は .api
を選択しよう
router
.request(for: .api(<#T##ServerRoute.Api#>))
ここで、特定の .api
route を選択しなければならないが、これは .
と入力してオートコンプリートでオプションを表示させることで、再度検討することができる
.init(accessToken: <#T##AccessToken#>, isDebug: <#T##Bool#>, route: <#T##ServerRoute.Api.Route#>)
.api
リクエストを作成するには、API の全ての route から選択する前にいくつかの必須フィールドを入力する必要がある
これらのフィールドにデータを入力してみよう
router
.request(
for: .api(
.init(
accessToken: .init(rawValue: UUID()),
isDebug: false,
route: <#T##ServerRoute.Api.Route#>
)
)
)
これで、この .api
route でオートコンプリートを行うといくつかの選択肢が出てくる
changelog
config
currentPlayer
dailyChallenge
games
leaderboard
push
sharedGame
verifyReceipt
ここでは先ほど実験した .dailyChallenge
の route を使ってみよう
router
.request(
for: .api(
.init(
accessToken: .init(rawValue: UUID()),
isDebug: false,
route: .dailyChallenge(<#T##ServerRoute.Api.Route.DailyChallenge#>)
)
)
)
そして、オートコンプリートで選択肢を見てみよう
results
start
today
.today
route は、今日の結果を読み込むのに適した候補のようである
router
.request(
for: .api(
.init(
accessToken: .init(rawValue: UUID()),
isDebug: false,
route: .dailyChallenge(.today(language: <#T##Language#>))
)
)
)
そして後は Language を埋めていくだけだが、これは .en
で埋められる
router
.request(
for: .api(
.init(
accessToken: .init(rawValue: UUID()),
isDebug: false,
route: .dailyChallenge(.today(language: .en))
)
)
)
現在、この route の穴を全て埋め、Playground のコンパイルが完了している
驚くべきことに、全ての穴が埋まるまで、コンパイル用のコードさえなかったのである
これで route が正しく構築されていることが、コンパイル時に静的に保証されることになる
さらに良いことに、router は完全に構築された URLRequest を返してきている
これを使って、server からデータを読み込むためのネットワークリクエストを出すことができる
// api/daily-challenges/today?accessToken=3EE1B177-CCCD-4E75-838B-B5F6AF5068F5&language=en
もっと複雑な route を試してみよう
スコアを leaderboards に登録するための API リクエストを構築したいとしたらどうだろう
このたった一つの route がさまざまなユースケースをサポートすることがわかった
まず .games
route を構築し、次に .submit
route を構築する
router.request(
for: .api(
.init(
accessToken: .init(rawValue: UUID()),
isDebug: false,
route: .games(
.submit(<#T##ServerRoute.Api.Route.Games.SubmitRequest#>)
)
)
)
)
SubmitRequest を作成するには、gameContext と moves の配列を指定する必要がある
.init(
gameContext: <#T##ServerRoute.Api.Route.Games.SubmitRequest.GameContext#>,
moves: <#T##Moves#>
)
gameContext を構築するには、サポートしているゲームの種類を一つ選択する必要がある
dailyChallenge
shared
solo
turnBased
これらのタイプのゲームでは、leaderboards に提出するデータのセットがそれぞれ異なる
ここでは .solo
を例に説明する
gameContext: .solo(<#T##ServerRoute.Api.Route.Games.SubmitRequest.GameContext.Solo#>),
これらの Solo を作成するには、gameMode(.timed
または .unlimited
)、language(今ところ英語のみ)、プレイされた isowords cube の完全な説明が書かれている puzzele など、ソロゲームの重要な部分を全て指定する必要がある
.init(
gameMode: <#T##GameMode#>,
language: <#T##Language#>,
puzzle: <#T##ArchivablePuzzle#>
)
これらの引数のデータを埋めていこう
puzzle には SharedModels モジュールで用意した .mock
を使う
gameContext: .solo(
.init(
gameMode: .timed,
language: .en,
puzzle: .mock
)
)
そして最後に moves の配列を用意する必要がある
Move 型の mock も用意されているので、それを使ってみよう
moves: [
.highScoringMove,
.removeCube
]
ここまで見てきたように、これは API リクエストを構築するためのかなり強力な方法なのである
router.request(
for: .api(
.init(
accessToken: .init(rawValue: UUID()),
isDebug: false,
route: .games(
.submit(
.init(
gameContext: .solo(
.init(
gameMode: .timed,
language: .en,
puzzle: .mock
)
),
moves: [
.highScoringMove,
.removeCube
]
)
)
)
)
)
)
全ては完全に静的で、server によって型検査される
これらのパラメータのうち、どれがクエリ文字列に含まれるか、どれが request body に含まれるか、あるいはもしかしたらこれらのうちのいくつかは header に含まれるかもしれない、といったことを推測する必要はない
これらは全て、私たちからは隠されており、全く重要ではない
router がその面倒を見てくれる
私たちがすべきことは、これら全ての引数を提供するだけで、全てが background で動作する
これは server と cllient を同期させることができ、同時に server でのリクエストの routing や API クライアントでのリクエストの構築を簡単に行うことができている
新しい route を追加したり、既存の route に追加データを加えたりするだけで、server と client の両方で更新が必要な箇所がすぐに分かり、全てがうまくいくのは正直言ってかなり驚きである
これは、私たちが通常、iOS アプリケーションで API クライアントを作成する方法とは全く対照的である
運が良ければ、バックエンドチームの誰かが仕様書や何らかのドキュメントを持っていて、どのエンドポイントにアクセスできるかを知ることができるが、運が悪ければ同僚にメッセージを送って詳細を聞いたり、自分で server コードを探索しなければならない
client でこの machinery を利用している全ての場所を見るには、プロジェクトで environment.apiClient
を検索する必要がある
検索すると API リクエストを行っている数多くのスポットが表示される
例えば DailyChallengeView.swift ファイルでは、今日の結果を読み込むために API リクエストを行っている
case .onAppear:
return .merge(
environment.apiClient.apiRequest(
route: .dailyChallenge(.today(language: .en)),
as: [FetchTodaysDailyChallengeResponse].self
)
.receive(on: environment.mainRunLoop.animation())
.catchToEffect()
.map(DailyChallengeAction.fetchTodaysDailyChallengeResponse),
...
)
これは先ほど見ていたのと同じ route である
また AppDelegate を見てみると Push 通知の登録を行っている
environment.apiClient.apiRequest(
route: .push(
.register(
.init(
authorizationStatus: .init(rawValue: settings.authorizationStatus.rawValue,
build: environment.build.number(),
token: token
)
)
)
)
私たちがこれらの API リクエストを行うたびに必要なのは、静的に型付けされた値を構築することだけである
直近の例では、内部に大量のデータを持つ ServerRoute.push(.register) である
router は舞台裏で物事を処理する
データが header 値なのか、JSON body なのか、クエリパラメータなのかを知る必要はない
全てが自動的に処理されるのである
food の下では、live API クライアントは、URLRequest を生成して、URLSession で送信するために、ここに表示されている router 値の一つを利用している
live の ApiClient があるファイルにジャンプするとこれを見ることができる
router の .request(for:)
メソッドを呼び出す小さなプライベートヘルパーメソッドがある
guard let request = router.request(for: route, base: baseUrl)?.setHeaders()
これが router の仕組みであるが、どのようにこれを実装しているのか少しだけ紹介する
Router.swift にアクセスしてみると、とてもワイルドなものを見ることができる
大きな値のリストがあるが、どれも訳のわからないものばかりである
例えば↓
.case(ServerRoute.Api.Route.changelog(build:))
<¢> get %> "changelog"
%> queryParam("build", .tagged(.int))
<% end
信じられないかもしれないが、これは parser 記述である
これは parsing に関する 21 のエピソードで取り上げた parser とは似ても似つかないものだが、Point-Free は非常に長い間、parsing について考え、繰り返してきた
Point-Free を立ち上げる前の初期の頃は次のような parsing ライブラリで実験をしていた
そのシンボルは、今では別の名前で親しまれている operator の binary operator バージョンで、特に .map
, .skip
, .take
である
例えば、今の parsing vernacular では、この parser を次のように定式化する
Get()
.skip(Path("changelog"))
.take(QueryParam("build", .tagged(.int))
.skip(End())
.map(ServerRoute.Api.Route.changelog)
これにより受信した URLRequest をどのように分解・処理しているかが明確になる
まず GET リクエストであることを解析し、パス成分から "changelog" を解析し、.skip
operator により結果を破棄する
次にクエリからビルドパラメータを解析し、Tagged ライブラリを使用してタグ付き整数として処理している
そして、そのビルド番号を .map
して API route の .changelog
ケースにバンドルしている
しかし先ほど述べたように、この式は単なる parser ではない
反転可能な parser なのである
つまり iOS アプリの API クライアントに必要な route を URLRequests に変えることもできる
つまり、このような構文を router で動作させるためには、少し余分な作業をしなければならないが、それは完全に可能であり、それを見るのはとても素晴らしいことである
しかし、それは Point-Free の別のエピソードに取っておくことにする
これがどれほどクールなことなのか、繰り返し説明したいと思う
server で routing を実現しているコードは、iOS アプリの API クライアントも動かしている
文字通り、ここに表示されている小さな router の値は、server と iOS アプリの両方で使用されている
これは本当に素晴らしいことで、これまで考える必要のなかった様々な問題やバグを取り除くことができる
server 用の URL を手動で作成する必要がないので、タイプミスのリスクがなくなった
例えば server が URL の path component に kebab case を使用していて、誤って camel case を使用してしまうことがある( /api/leaderboard-scores
, /api/leaderboardScores
)
router を使用する際には、実際に path を構築することはないので、このような心配をする必要は全くない
なぜなら router を使用する際に実際に path を構築することはないからである
これらは全て router を支える parser-printer 層で自動的に処理される
私たちはクエリパラメータ、header、request body のどのパラメータを渡されたかを覚える必要はない
そのようなことは全て隠蔽されている
私たちは必要な全ての引数を提供して、route enum の値を構築するだけで、router がクエリパラム、header、body の入力を自動的に行う
また、先ほど示した大きな enum に新しい route が追加されると、client と server の両方ですぐに利用できるようになる
コードに目を通したり、バックエンドの同僚に新しいエンドポイントの詳細を聞いたりする必要はない
ただ列挙された値を構築するだけで、すぐに利用できるようになる
Integration testing super powers: the client
ここまで紹介してきたことは、server を Swift で書くことができるだけではなく、client と server の間で多くのコードを共有することができることを示している
しかし、二つのプラットフォーム間でコードを共有することは、ほんの始まりに過ぎない
テストにも大きなメリットがある
Point-Free は機能のあらゆる部分をテストできるように、必要なデータを返す API クライアントの依存関係をモックアウトして、 client コードのユニットテストを書いている
そして同時に、server のユニットテストを書き、その依存関係をモックアウトしてロジックのあらゆる部分を実行している
これらをどうにかして組み合わせることはできないだろうか?
結局、server も client も Swift で書かれている
もし、iOS コードに「モック」の API クライアントを提供できたらどうだろうか
この client は food の下で密かに実際の server コードを実行している
そうすれば、iOS のロジックと server のロジックの両方を同時に実行する一つのテストを書くことができる
わざわざローカルの server を起動して、それを叩く必要もない
文字通り iOS アプリの中で server のコードを実行することができる
これがどのように実現されているのかを見ていく
今回はゲームオーバー画面の integration test を追加する
この画面は既に他の簡単なテストで十分カバーされている
例えば、GameOverFeatureTests.swift にアクセスすると、7つのテストがあり、通常のソロゲームとデイリーチャレンジを終えた時の動作や、ゲームオーバー画面を閉じた時の App Store レビューのリクエスト、ゲーム本編をまだ購入していない場合の upgrade interstitial の表示など、あらゆる種類のシナリオをカバーしている
スクリーンショットのテストもある
しかし、これらのテストは全て failing API クライアントを使用し、そのエンドポイントの一部をオーバーライドして必要なデータを取得することで機能している
例えば、ソロゲームのスコアを送信するテストを行うには、次のようにしている
environment.apiClient.override(
route: .games(
.submit(
.init(
gameContext: .solo(.init(gameMode: .timed, language: .en, puzzle: .mock)),
moves: [.mock]
)
)
),
withResponse: .ok([
"solo": [
"ranks": [
"lastDay": LeaderboardScoreResult.Rank(outOf: 100, rank: 1),
"lastWeek": .init(outOf: 1000, rank: 10),
"allTime": .init(outOf: 10000, rank: 100),
]
]
])
)
これはテストを素早く書いたり、コードベースのあらゆるエッジケースをテストしたりするのに適して入るが、強力ではない
このコードはリクエストを解読し、この特定のロジックを処理する関数に routing し、提出されたパズルデータを検証し、そのデータを保存するためにデータベースにリクエストし、ランクを返す
もし、これら全てを一つのテストに取り込むことができれば、フロントエンドやバックエンドに変更を加えても誤ってアプリを壊してしまうことはないという確信を持つことができると思う
では早速テストしてみよう
まずこの integration test のために新しいテストターゲットを追加する
既存の GameOverFeatureTests ターゲットにこのテストを追加するよりも、新しいターゲットを作成した方が良いと思う
なぜなら、integration test では server コードをビルドする必要があり、それは実験的な Web ライブラリや Swift NIO など負荷の高いものをビルドすることを意味するからである
そのため、新しいターゲットを追加することで、これらのテストを軽量化しつつ、統合のためのより豊かなテスト体験を提供することができる
今回の intergation test では client のコードを実行するので、このテストターゲットを Package.swift の client セクションに追加する
.testTarget(
name: "GameOverFeatureIntegrationTests",
dependencies: [
"GameOverFeature",
"IntegrationTestHelpers",
"SiteMiddleware",
]
),
このテストターゲットは、コアとなる GameOverFeature(iOS コード)、IntegrationTestHelpers(server コードから API クライアントを自動的に導出するためのコード)、そして SiteMiddleware(Site を実行する server コード)に依存している
Pakcage.swift が更新されたので、Tests ディレクトリの中に GameOverFeatureIntegrationTests ディレクトリを作成し、テストのスタブを含む新しいテストファイルを作成する
import XCTest
class GameOverFeatureIntegrationTests: XCTestCase {
func testSubmitSoloScore() {
}
}
このテストを実行しようとすると、Xcode がこのテストターゲット用の Scheme を作成していないため、実行できないことがわかる
手動で既存の Scheme にテストを追加することもできるが、このスタイルのテストのために依存関係を増やすことになるので、既存のテストがかなり遅くなってしまう
その代わりに、このテストターゲットのために全く新しい専用の Scheme を作成することができる
さて、ビルドが完了し、テストが実行できるようになったところで、integration test を書くには何から始めれば良いだろうか
驚くべきことに、過去の「better test dependencies」のエピソードで行ったように、failing test dependencies が私たちを導いてくれる
これらのエピソードでは、テストで使用されている依存関係を即座に確認できるように、failing environment of dependencies を接続することで、ゼロからテストを書く方法を実演した
そしてテストが合格するまで、依存関係を徐々に埋めていき、暗い部屋に懐中電灯を置くように機能を発見することができた
これをここでやってみよう
このテストは他の TCA のテストと同様に、まずゲームオーバー画面のドメインを持つ TestStore を構築することから始める
import ComposableArchitecture
import GameOverFeature
...
let store = TestStore(
initialState: GameOverState(
completedGame: .mock,
isDemo: false
),
reducer: gameOverReducer,
environment: .failing
)
GameOverState はいくつかの引数を取る
completedGame
は再生されたゲームの最終データを保持するデータタイプで、isDemo
はこの画面が App Clip の一部として表示されているかどうかを判断するために使用され、その場合は UX/UI が少し変わる
この機能の依存関係が実行されると、即座にテストスイートが失敗することになる
では、TestStore をセットアップした後、具体的に何をテストしたいのだろうか?
ゲームオーバー画面の機能の大部分は、画面が最初に表示された時に起動される
Player のパズルやスコアを含む API リクエストを server に送信するなど、様々なことが起こる
では、.onAppear
Action を送信して何が起こるかを見てみよう
store.send(.onAppear)
Executed 1 test, with 15 failures (14 unexpected) in 0.068 (0.070) seconds
この1回のテストだけで12回の失敗があったので、.onAppear
が発生した時に、この機能がかなりの処理を行っていることがわかる
失敗例を見ると、私たちがまだ実装を提供していない多くの依存関係が使用されていることがわかる(以下は失敗例)
- RunLoop に言及しているので、何か非同期の作業を行っていて、それをメインスレッドに戻すようにスケジューリングする必要があるのだろう
- ApiClient についての言及があるが、これはリクエストを行うことを想定しているので当然のことだろう
- LocalDatabaseClient と ServerConfig についての言及がある。upgrade interstitial を表示すべきかどうかを判断するためにこの二つを使用している
- UserNotificationsClient についても触れられているが、これはアプリのプッシュ通知を有効にするようにお願いするかどうかを判断するために使われる
- また AudioClient についても言及されているが、これはゲームオーバー時に特別に音楽を再生するために使用される
- このようにテストに合格するためには、かなりの数の依存関係を提供する必要がある
- また、依存関係を提供することで、別の依存関係を取得する新しいロジックにアクセスすることができるため、さらに多くの依存関係が出てくる可能性がある
おそらく最も簡単に提供できるのは RunLoop だろう
この依存関係には二つの選択肢がある
テストの時間の流れを明示的に制御できる TestScheduler を使うか、スレッドの切り替えをせずに直ちに Action を実行する ImmediateScheduler を使うかである
ここではシンプルにするために、ImmediateScheduler を使ってみよう
var environment = GameOverEnvironment.failing
environment.mainRunLoop = .immediate
let store = TestStore(
initialState: GameOverState(
completedGame: .mock,
isDemo: false
),
reducer: gameOverReducer,
environment: environment
)
store.send(.onAppear)
Executed 1 test, with 8 failures (8 unexpected) in 0.052 (0.054) seconds
これだけで既に四つ以上の失敗を修正することができた
もうひとつ簡単に直せる失敗がある
時間制限のあるゲームの終了間近になると、できるだけ多くの単語を作ろうと必死になる傾向があり、時間切れでゲームオーバーの画面が表示されると、その画面の中の何かを誤ってタップしてしまう危険性がある
そのため、最初に画面全体を無効にして、一秒後に再び有効にするようにしている
これを実現するために、一秒遅れで .delayedOnAppear
Action を送信し、その Action の中で状態を enable に変異させる
このテストでは、store の .receive
メソッドを使って、Action をシステムにフィードバックする effect を期待していることを明示している
store.receive(.delayedOnAppear) {
$0.isViewEnabled = true
}
Executed 1 test, with 7 failures (7 unexpected) in 0.055 (0.057) seconds
これでさらに一件の不具合を修正できた
もう一つの簡単な不具合 AudioPlayer である
この依存関係については、intergation test ではない unit test で既に test coverage を行っているので、ここでその作業を蒸し返す必要はないだろう
その代わり integration test では、client と server 間の直接的なやり取りに焦点を当てるべきである
つまり audio client に .noop
の依存関係を使用することができる
environment.audioPlayer = .noop
Executed 1 test, with 5 failures (5 unexpected) in 0.448 (0.450) seconds
これでさらに二つの問題が修正され、少しずつ改善されている
他にもいくつかの依存関係があるが、これらは server にはあまり関係のない client 側の機能に使用されているという点で、AudioPlayer と似ている
LocalDatabaseClient、ServerConfigClient、UserNotificationsClient は、upgrade interstitial をいつ表示するか、プッシュ通知の許可をいつ求めるかを決定するために使用されている
このようなゲームオーバーの側面については、unit test で既に完全な test coverage があるので、今はこれらの機能をテストせずに、実際の client と server の通信経路に注目しよう
つまり、これらのエンドポイントのために、スタブ化された effect を入れて、それらについて考えなくて済むようにすることができる
environment.database.playedGamesCount = { _ in .init(value: 0) }
environment.serverConfig.config = { .init() }
environment.userNotifications.getNotificationSettings = .none
Executed 2 tests, with 2 failures (2 unexpected) in 0.447 (0.450) seconds
これで失敗は二つだけになった
まだ依存関係の必要最低限の部分だけをスタブ化していることに注意して欲しい
DatabaseClient、ServerConfigClient、UserNotificationsClient の全てをスタブ化する必要はない
なぜなら、これら三つの特定のエンドポイントだけがアクセスされることがわかっているからである
これにより、機能の特定の部分をテストする際に、依存関係のどの部分が使用されているかを正確に説明することができ、テストがより強固なものになる
さて、失敗は二つになったが、どちらも API に関連しているようである
🛑 ApiClient.currentPlayer is unimplemented
...
🛑 ApiClient.apiRequest(.games(.submit(...))
最初の失敗は、API の currentPlayer にアクセスしようとしたことによるものである
これは upgrade interstitial を表示させないために、Player が既に製品版を購入しているかどうかを確認する必要があるからである
二つ目の失敗は、ゲームを leaderboards に登録するための API リクエストが行われたことである
これまでは API クライアントでこの特定の route をオーバーライドすることで対処してきた
environment.apiClient.override(
route: .games(.submit(<#T##ServerRoute.Api.Route.Games.SubmitRequest#>)),
withResponse: <#T##Effect<(data: Data, response: URLResponse), URLError>#>
)
これにより、この一つの route だけにレスポンスを提供することができ、他の全ての route は失敗し続けることになる
しかし、この route をオーバーライドしても、server のコードを実行することはできない
この route に特定のレスポンスを提供するために、server が行う全てのことを回避しているからである
Integration testing super powers: the server
API クライアントを server コードから派生させることができれば、API リクエストの際に実際の server コードを実行することができ、より良い結果が得られるだろう
これは実際に可能だし、やってみるととても素晴らしいことである
ApiClient にはイニシャライザがあり、Middleware や Router と呼ばれるものを指定することができる
import IntegrationTestHelpers
...
environment.apiClient = .init(
middleware: <#T##Middleware<StatusLineOpen, ResponseEnded, Unit, Data>##Middleware<StatusLineOpen, ResponseEnded, Unit, Data>##(Conn<StatusLineOpen, Unit>) -> IO<Conn<ResponseEnded, Data>>#>,
router: <#T##Router<ServerRoute>#>
)
ServerEnvironment は TCA の Environment と全く同じ目的を持っている
server が作業をするのに必要な全ての依存関係を保持する
現在は10個の依存関係を保持している
public struct ServerEnvironment {
public var changelog: () -> Changelog
public var database: DatabaseClient
public var date: () -> Date
public var dictionary: DictionaryClient
public var itunes: ItunesClient
public var envVars: EnvVars
public var mailgun: MailgunClient
public var randomCubes: () -> ArchivablePuzzle
public var router: Router<ServerRoute>
public var snsClient: SnsClient
...
}
これには主に以下のようなものが含まれている
- Posgres データベースと対話するための client
- 有効な単語を検索するための DictionaryClient。これは iOS アプリで使用している client と全く同じもの
- ItunesClient は認証のために Apple に受信データを送信する
- Router は受信したリクエストを解析して、そのリクエストのロジックをどのように実行するかを判断するために使用する値である
- SnsClient は Amazon の SNS サービスとのやりとりをするためのもので、Push 通知の送信に使われる
このファイルの一番下には、この Environment の .failing
実装もあり、TCA のように、依存関係を徹底的に調べることができる
さて、TCA での機能構築方法と server 構築方法にはかなりの共通点があるように感じられるかもしれない
確かにその通りではあるが、残念ながら server 再度アプリケーションをゼロから構築する方法については、まだ深く掘り下げることができていない
Point-Free でこれらのトピックについて議論を始める前に、Swift での concurrency についてもう少し状況が改善されるのを待っている
しかし、問題ない
server を構築する方法の複雑さを理解していなくても、このテストを通過することができる
.failing
の server environment を使う Middleware を注入して、Router にも .failing
のものを使えば良いのである
environment.apiClient = .init(
middleware: siteMiddleware(environment: .failing),
router: .failing
)
そして今、TCA に引き渡す API クライアントは、完全に私たちの server コードで動いている
これを確認するためにテストを実行すると、まだ実装されていない server の依存関係を使用しているために、突然数多くの失敗が発生してしまう
TCA の時と同じように、今度は server にも同じことをしてみよう
障害が発生したときに、テスト用の依存関係をひとつひとつ確認していく
例えば、現在未実装の router からのエンドポイントを使用しているという障害がある
実は mock server router が既に定義されていて、それを使うことができる
このモックは、date のイニシャライザ、SHA256 の実装、JSON の encoder や decoder など、router が必要とする依存関係のモックアウトを行う
var serverEnvironment = ServerEnvironment.failing
serverEnvironment.router = .test
var environment = GameOverEnvironment.failing
environment.audioPlayer = .noop
environment.apiClient = .init(
middleware: siteMiddleware(environment: serverEnvironment),
router: .mock
)
そして今、テストを実行すると失敗は二つだけになった
Executed 1 test, with 2 failures (1 unexpected) in 0.464 (0.467) seconds
最初の失敗は、現在未実装の DatabaseClient のエンドポイント使用していることに言及している
🛑 DatabaseClient.fetchPlayerByAccessToken is unimplemented
このエンドポイントは非常にわかりやすく、access token から Player をフェッチするだけで、その access token は client から与えられるものである
このエンドポイントを実装する最も簡単な方法は、単純にモックのデータでオーバーライドすることである
そのためにはクロージャを用意する
serverEnvironment.database.fetchPlayerByAccessToken = { _ in
}
そして、ここでは EitherIO と呼ばれるものを返す必要がある
この型は、TCA における Effect と同様の役割を果たす
外の世界と対話して、副作用を実行するものである
この型はすぐに値を返すものを作ることができるが、この場合は Player である
serverEnvironment.database.fetchPlayerByAccessToken = { _ in
.init(value: .blob)
}
もう一つの方法は、実際に稼働している DatabaseClient を使用することである
つまり、ローカルコンピュータ上で稼働している実際の Postgres データベースと対話するものである
そうすれば、アプリのより多くの部分を動かすことができるので、integration test をさらに強化することができるが、セットアップに少し時間がかかるので、今のところはこの方法で行くことにしよう
テストを再度実行すると、fetchPlayerByAccessToken
エンドポイントの失敗は無くなったが、新しい失敗が現れた
🛑 DictionaryClient.contains is unimplemented
DictionaryClient の失敗は、スコアが提出された時にプレイされたゲームが実際に意味のあるものかどうかを検証していることが原因である
私たちは、統計情報を得るために不正なデータを leaderboards に送信してほしくない
検証の過程で、提出された単語をチェックするために DictionaryClient を使用している
うまくいけば、より良い DictionaryClient の依存関係を提供することで、この二つの失敗を修正することができる
contains
エンドポイントをオーバーライドして、渡された単語が辞書に含まれていることを示すようにしてみよう
serverEnvironment.dictionary.contains = { _, _ in true }
今テストを実行してみるといくつかの失敗はなくなったが、新しい失敗が発生した
🛑 DatabaseClient.submitLeaderboardScore is unimplemented
これは新しい Database エンドポイントへのアクセスで、パズルとスコアをデーターベース内の leaderboards テーブルに実際に送信するものである
これは SubmitLeaderboardScore を引数として受け取る関数で、テーブルに行を挿入するのに必要な全てのデータを保持し、LeaderboardScore を返す
これは、データベースに挿入されたばかりのデータ行を表すデータ型である
submitLeaderboardScore: (SubmitLeaderboardScore) -> EitherIO<Error, LeaderboardScore>
先ほどと同じようにオーバーライドして、LeaderboardScore の値をすぐに返す EitherIO の値を用意する
この値は TestStore の initialState にシードされたゲームを表すように構成する
serverEnvironment.database.submitLeaderboardScore = { _ in
.init(
value: .init(
createdAt: .mock,
dailyChallengeId: nil,
gameContext: .solo,
gameMode: .timed,
id: .init(rawValue: UUID()),
language: .en,
moves: CompletedGame.mock.moves,
playerId: Player.blob.id,
puzzle: .mock,
score: score("CAB")
)
)
}
繰り返しになるが、live の DatabaseClient を使用してこれら全てを処理することもできた
そうすれば、テストでさらに強力な保証を得ることができるが、今のところはこれで十分である
徐々に近づいてきたが、テストを実行すると新たな障害が発生してしまう
🛑 DatabaseClient.fetchLeaderboardSummary is unimplemented
どうやら新しいデータベースのエンドポイントにアクセスしているようである
これは leaderboards のスコアの送信に成功すると、すぐに leaderboards のサマリーを取得するために起こる
このサマリーでは、Player のスコアを過去1日、1週間、および全ての期間の Rank に分類する
fetchLeaderboardSummary
は FetchLeaderboardSummaryRequest
を受け取り、どのようなサマリーが欲しいか(どのゲームモード、どの時間範囲、どの言語など)を記述し、Rank
を返す関数である
var fetchLeaderboardSummary: (FetchLeaderboardSummaryRequest) -> EitherIO<Error, LeaderboardScoreResult.Rank>
これは server から3回呼び出され、過去1日、過去1週間、全ての時間に対応するタイムスコープごとに一回ずつ呼び出される
このエンドポイントを実装するために、タイムスコープをモックの Rank にマッピングする小さな Dictionary を定義することができる
let ranks: [TimeScope: LeaderboardScoreResult.Rank] = [
.allTime: .init(outOf: 10_000, rank: 1_000),
.lastWeek: .init(outOf: 1_000, rank: 100),
.lastDay: .init(outOf: 100, rank: 10),
]
そして fetchLeaderboardSummary
の実装は Dictionary から読み取るだけでよい
serverEnvironment.database.fetchLeaderboardSummary = {
.init(value: ranks[$0.timeScope]!)
}
テストをしてみると失敗は一つだけになっている
Executed 1 test, with 1 failure (0 unexpected) in 0.464 (0.466) seconds
🛑 The store received 1 unexpected action after this one: …
Unhandled actions: [ GameOverAction.submitGameResponse( Result<SubmitGameResponse, ApiError>.success( SubmitGameResponse.solo( LeaderboardScoreResult( ranks: [ “allTime”: Rank( outOf: 100, rank: 10000 ), “lastDay”: Rank( outOf: 1, rank: 100 ), “lastWeek”: Rank( outOf: 10, rank: 1000 ), ] ) ) ) ), ]
この失敗は、システムが明示的に assert していない Effect から Action を受け取ったことを示している
これは server の依存関係が全て解決したことで、ようやく API からデータが戻ってきて、それがシステムにフィードバックされるようになったために起こっている
また、TCA ではテストで Effect がどのように実行されるかを徹底的に明示する必要がある
失敗例から、.submitGameResponse
Action を受け取ったことがはっきりとわかる
これは API からようやくレスポンスが戻ってきたということで良いことではある
store.receive(.submitGameResponse(<#T##Result<SubmitGameResponse, ApiError>#>))
さらに私たちは成功した Response を得ることを期待している
store.receive(.submitGameResponse(.success(<#T##SubmitGameResponse#>)))
SubmitGameResponse
を作成するには、どのようなゲームを提出したかを決定しなければならないが、今回はソロゲームである
store.receive(.submitGameResponse(.success(.solo(<#T##LeaderboardScoreResult#>))))
LeaderboardScoreResult
の値を作成するには、time scopes を key にした Rank の Dictionary を提供する必要がある
store.receive(.submitGameResponse(.success(.solo(.init(ranks: <#T##[TimeScope : LeaderboardScoreResult.Rank]#>)))))
そして、それはまさに上記で定義したデータベースのエンドポイントに役立つものを使うことができる
store.receive(.submitGameResponse(.success(.solo(.init(ranks: ranks)))))
そして、この Action を受け取った時、UI がこれらの Rank を表示する必要があるため、いくつかの状態の変異があることを期待する
ここでは、この Action を受け取った後に発生すると思われる mutation を実行する、期待値のクロージャを作成することができる
store.receive(.submitGameResponse(.success(.solo(.init(ranks: ranks))))) {
$0
}
また $0
のオートコンプリートを使って GameOverState にどんな状態が保持されているかを探り、何を変更するべきかを考えることもできる
summary
というフィールドには RankSummary
と呼ばれるものが格納されているので、それを利用するのが良さそうである
これらを構築するには、.dailyChallenge
case と leaderboard
case のどちらかを選択する
これはゲームオーバーの画面が、それぞれの時間帯のゲームで微妙に異なるためである
今書いているテストでは、デイリーチャレンジを扱っていないので、.leaderboard
case を採用しよう
$0.summary = .leaderboard(<#T##[TimeScope : LeaderboardScoreResult.Rank]#>)
.leaderboard
の case を構築するためには、Rank
の Dictionary を提供する必要があるが、これも先ほど定義した通りである
store.receive(.submitGameResponse(.success(.solo(.init(ranks: ranks))))) {
$0.summary = .leaderboard(ranks)
}
これでテストを実行すると全てのテストにパスするようになった!
これで初めての integration test に合格した
一連の User Action を入力し、状態がどのように変化し、Effect がどのように実行されるかを aseert することで、標準的でありふれた TCA のテストを書いている
しかし、その裏では、ゲームオーバー機能がロジックを実行するために使用している API クライアントが、実際には server コードを呼び出しているのである
server コードは、受信したリクエストの routing、複数のデータベースクエリの実行、client に送信できるようにデータを成形するなど、膨大な作業を行っている
そして、client はそのデータを decode して、UI に表示する
また、integration test はどちらか一方の概念ではなく、むしろスペクトラムのようなものであることも言及しておこう
今書いたテストは、client と server という二つの全く異なるコンポーネントがどのように相互作用するかをテストしているので、integration test であると考えている
過去には、複数の TCA 機能のテストを一度に書くことを integration test という言葉で表現したこともある
しかし、これらのケースでは、それぞれ別のレベルの integration を目指すことができる
例えば integration test ではデータベースをスタブアウトすることにしたが、live のデータベースを使用することもできた
そうすれば iOS client、server、Postgres データベースの三つの独立したコンポーネントをテストすることができる
また、TCA の integration test では、全ての機能がお互いにうまく機能していることを確実に証明するために、root app の Reducer のテストのみを書くことを許可するという極端な方法もある
しかしこれには長所と短所がある
integration test は深ければ深いほど強力になるが、同時に設定や維持が難しくなる
その逆に integration test が浅ければ浅いほど、書いたり維持したりするのが簡単になるが、できる限りのテストができなくなる
そのため integration test に取り組む際には、これらの原則を念頭に置いて、苦痛と報酬の閾値を見極めることが重要である
例えば、ある機能の一部を重点的にテストする場合には、依存関係をスタブ化した Unit test だけで十分だし、アプリケーションのいくつかのコアフローには、アプリケーションのより異質な部分を取り込むための深いテストも必要かもしれない
しかし知っておくべき最も重要なことは、server と client の両方が Swift で構築されている場合、integration test を書くことは完全に可能であるということである
また TCA のテストに似た server テストを書くことがいかにクールかということも強調しておきたい
failing 依存関係は、テストを通過させるためのプロセスを段階的に導いてくれた
もし、server コードのこの部分で新しい依存関係を使い始めたら、テストで即座に通知して、修正できるようにしなければならない
Point-Free では、今後も server サイドの Swift について多くのことを伝えていくが、TCA で学んだ多くの原則を適用していく