はじめに
こんにちは、iOSエンジニアの dayossi です。
家族が幸せになれるサービスを提供したいと思って、
HaloHaloという家族日記アプリをリリースしています。
今回は、ユニットテストを通じてアプリ開発をして感じた
3つの悩みについて分析しました。
今回の成果物はコチラです。
https://github.com/Kohei312/TDD_Practice_Poker
これまでよく陥っていた悩み
テストを書かずに開発していた時は、
以下の3つの悩みを切り分けられず、よく悩まされていました。
・UIKitを中心とした、View構築のロジックがおかしいのか...?
・UIを表示するためのデータがおかしいのか...?
・データの受け渡しがおかしいのか...?
ユニットテストを書くことで
このあたりの切り分けがスムーズに行うことができ、
問題を分析しやすくなりました。
問題を分析する際に、以下の3つの視点が
共通して有効だったと感じました。
1. ちゃんと責務を分離できているか?
オブジェクト指向の原則であるSOLID原則の1つ 単一責務の原則
と、
設計方針・意図を意識できているかを確認する問いかけです。
今回は「レイヤー、処理ごとに目的を明確にする」というニュアンスで使用しました。
2. 状態の変化を可視化できているか?
1つ目のポイントと重複する部分がありますが、
どの処理を、どのタイミングで呼び、どんな変化が起こっているのか明確にすることです。
特に引数が多くなって、複数の処理を同時に行おうとすると混乱しやすく
それゆえコードミスも起こりやすいと感じました。
enumで状態を列挙しておいたり、特定のレイヤーでのみプロパティの変更を行う処理を定義しておくと、
状態変化を把握しやすかったです。
3. 適度にレイヤーを分割できているか?
ここも1つ目のポイントと重複しますが、
全体設計どおりにレイヤーが機能しているかを逐次確認しました。
各レイヤーがなんの状態を管理するのか、目的を確認しながら
徐々に責務を分離していくと、見直しも行いやすかったです。
以下、今回のアプリ開発で実際に取り組んだ事例を取り上げていきます。
事例:状態が共有されない
今回作成したポーカーゲームのなかで、
プレイヤーは3回までカードを交換できるルールを設けました。
(プレイヤーは、ユーザーとCPUの2人という構成です)
その中で、各プレイヤーがカードを交換した回数に応じて
ゲームの状態が変化する、という点でつまづきが起きました。
交換回数を changeCount という変数で
Playerというプレーヤーの状態を持つ構造体のプロパティとして保持し、
ルールに沿って制御するようにしていました。
PlayerTypeというenumにて、プレーヤーがユーザーかコンピュータなのか
判別するようにしています。
public enum PlayerType{
case me
case other
}
struct Player{
var playerType:PlayerType
init(playerType:PlayerType){
self.playerType = playerType
}
var playerStatement:PlayerStatement = .thinking
var changeCount = 3
}
最初はゲーム全体のロジックを管理する PokerInteractor という上位レイヤーに
直接Player型のインスタンスを置いて管理していました。
// MARK:- 各プレイヤーのカード交換回数、状態を管理
public struct PokerInteractor{
var player_me:Player
var player_other:Player
mutating func changePlayerCount(_ playerType:PlayerType){
switch playerType{
case .me:
player_me.changeCount -= 1
case .other:
player_other.changeCount -= 1
}
}
ビジネスロジックを取りまとめるレイヤーとして位置付けており、
ここでプレイヤーの状態と、ゲームの進行状態をコントロールしていました。
ですが、ここに落とし穴がありました。
片方のプレイヤーのターンでは、そのプレイヤーのカード交換回数はちゃんとカウントされていましたが
もう一方のプレイヤーのカード交換回数が共有されないのです。
player_meのターンで、player_meのchangeCountは確かに減っているのに
player_otherのターンになると、player_meのchangeCountが初期値に戻っているのです.
public struct PokerInteractor{
#WARNING("いつまでも、状態が共有されない...")
var player_me:Player
var player_other:Player
mutating func changePlayerCount(_ playerType:PlayerType){
switch playerType{
case .me:
player_me.changeCount -= 1
case .other:
player_other.changeCount -= 1
}
}
問題点:ロジックデータの管理に問題がある
テストでは、実際に計算できていることは確認できており
View側での構築エラーもみられなかったので、ロジックデータの管理に問題があると考えました。
分析:メモリポインタの変更を考慮できていなかった
changeCountはイミュータブルな値なので
値を変更する際は、生成したPlayer型のインスタンスから変更を指示する必要があります。
ただ、値型であるPlayerのプロパティを更新すると、Player全体の値が更新され
Playerをプロパティにもつ上位レイヤーのPokerInteractorも更新されます。
そのため、PokerInteractorで管理していた 2つのPlayerは
結果的に新しいインスタンスが再生成されることとなり、
カードの交換カウントが毎回初期値へリセットされてしまって
プレイヤー全員の状態を共有することができなくなっていました。
そこで、Player全員の状態を把握するための参照型PlayerStatusを一つ追加し
Playerの状態を共有できるように変更して対応しました。
対策:PlayerとPokerInteractorの間に、参照型レイヤーを追加した
PlayerStatusを参照するメモリ領域は常に同一であるため
各プレイヤーの値が更新され、メモリポインタが変更されても
常に変更後の値をスコープできることを狙いとしました。
final class PlayerStatus{
var players:[Player] = []
var interactorInputProtocol:InteractorInputProtocol?
subscript(playerType:PlayerType)->Player{
get{
return players.filter({$0.playerType == playerType}).last!
}
set(newValue){
if let player = players.filter({$0.playerType == playerType}).last{
for (index,p) in players.enumerated() {
if p.playerType == player.playerType{
players.remove(at: index)
players.insert(newValue, at: index)
}
}
}
}
}
func decrementChangeCount(_ playerType:PlayerType){
self[playerType].changeCount -= 1
interactorInputProtocol?.checkGameStatement(playerType)
}
}
配列内にPlayerクラスを格納し、subscriptで必要なプロパティを抽出できるようにしましたが
計算コストが高い上にネストが深く読みづらいので、
今回のアプリのように登場人物が限られるケースでは、
それぞれインスタンスを分けて保持したほうが
わかりやすくて良かったかなと思います。
注意点として、どこからでもPlayerStatusのプロパティを変更し
その状態を共有できてしまうため
計算処理も、PlayerStatusから行うように統一しました。
考察:各モジュールの責務分離があいまいだった
以上で、各プレーヤーの状態管理をPlayerStatusが担うことも明確にし
PokerInteractorからは、状態変更を指示するだけにしました。
言い方を変えれば、ビジネスロジックを管理するPokerInteractorでの
責務は分散できる余地があったことを見逃していたといえます。
テストを通してビジネスロジックの一つ一つの処理は
ちゃんと動いていることを確認できていたため、
PokerInteractorレイヤーの責務が複雑になっていることに
気づくことができたと思います。
まとめ
自分が体験した中で、よく陥りやすい3パターンを抽出しましたが
ホントに基本的なことばかりでお恥ずかしい限りです。
原則を外れてしんどかった部分が多くを占めていることを、改めて実感できました。
もっと上手に設計原則を生かせるように精進します。
温かいツッコミを、お待ちしております。
参考図書・記事
[TDD Boot Camp.TDDBC仙台07課題:ポーカー]
(http://devtesting.jp/tddbc/?TDDBC%E4%BB%99%E5%8F%B007%2F%E8%AA%B2%E9%A1%8C)
松尾 和昭,細沼 祐介,田中 賢治 他.iOSテスト全書(2019).PEAKS出版.
関 義隆,史 翔新,田中 賢治 他.iOSアプリ設計パターン入門(2019).PEAKS出版.
[田中 賢治. Swiftで書いておぼえるTDD(2018).株式会社インプレス R&D.]
(https://nextpublishing.jp/book/10137.html)
[キーワード]
TDD駆動設計:
[Dan Chaput, Lee Lambert, Rich Southwell. What is an Enterprise Business Rule Repository?. MODERA analyst.com.]
(http://media.modernanalyst.com/New_Wisdom_Software_Webinar_-_PRINT.pdf)
Value Semantics:
Yuta Koshizawa. Value Semantics とは. Heart of Swift
Yuta Koshizawa. Value Semantics を持たない型の問題と対処法. Heart of Swift
Yuta Koshizawa. Swift が値型中心の言語になれた理由とその使い方. Heart of Swift
Copy-on-Write:
(SwiftにおいてCopy-on-Writeは問題にならないと思う)[https://qiita.com/koher/items/8c22e010ad484d2cd321]
(Swift での Copy on Write の実装方法の解説)
[https://qiita.com/omochimetaru/items/f32d81eaa4e9750293cd]
(swiftで 依存関係逆転の原則 を使ってテストしやすい設計にする)[https://qiita.com/peka2/items/4562456b11163b82feee]
VIPER:
(VIPERアーキテクチャでプロダクトのiOSアプリを1から作ったまとめ)[https://qiita.com/hirothings/items/8ce3ca69efca03bbef88]
SOLID分析:
(SwiftでわかるSOLID原則 iOSDC 2020)[https://speakerdeck.com/k_koheyi/swifttewakarusolidyuan-ze-iosdc-2020]
(iOS開発の事例に寄せたSOLID原則の解説)[https://zenn.dev/k_koheyi/articles/019b6a87bc3ad15895fb]
メモリ:
(Swiftのメモリレイアウトを調べる)
[https://qiita.com/omochimetaru/items/64b073c5d6bcf1bbbf99]
(素晴らしいSwiftのポインタ型の解説)
[https://qiita.com/omochimetaru/items/c95e0d36ae7f1b1a9052]
(Memory Safety)
[https://docs.swift.org/swift-book/LanguageGuide/MemorySafety.html#//apple_ref/doc/uid/TP40014097-CH46-ID571]
値型:
ミュータブルな型とイミュータブルな型の相違を知ろう
[純粋値型Swift]
(https://qiita.com/koher/items/0745415a8b9842563ea7)
subscript:
Swift の Subscript について
protocol指向:
WWDC 2015 Swiftで値型でより良いアプリを作る
enum:
Swiftの列挙型(enum)おさらい
[Swift] enumはprotocolに準拠できるので、例えばComparableによってシンプルに比較できる