はじめに
これは Swift Tweets 2017 Spring で発表(ツイート)したものをまとめたものです。
これ以降は基本的にツイートしたものと同じです。
こちらは徳島からTweetしてます、こんばんは。よろしくおねがいします。
今回は、テスト向けにモック(またはスタブ)を差し込む方法について、ぼくが色々と妄想💭を膨らませていきます。
コード多めになったのであとでゆっくり見返してください🙇
まずは、簡単な方法です。対象のクラスのサブクラスを作成してメソッドをオーバーライド🏇するだけでも、動作を差し替えたり、確認用コードを差し込むことができますね。
class UserStatistics {
var numberOfYoungUsers: Int {
get {
return allUsers().filter({ $0.age < 35 }).count
}
}
func allUsers() -> [User] {
// ...例えばDBやサーバーからユーザーを取得するコードが
// ここに書かれているとします。
}
}
class UserStatisticsTests: XCTestCase {
func testYoungUserCount() {
class MockUserStatistics: UserStatistics {
// データ取得用メソッドをテスト用に差し替え
override func allUsers() -> [User] {
return [
User(name: "Katsuo", age: 11),
User(name: "Namihei", age: 54),
User(name: "Masuo", age: 28),
User(name: "Wakame", age: 9),
]
}
}
let userStatistics = TestUserStatistics()
XCTAssertEqual(userStatistics.numberOfYoungUsers, 3)
}
}
しかし、モック化したい処理がこんな風にテスト対象とは別のクラス(UserStore)にあると、単純に差し替えがすることができません。なぜなら、UserStoreオブジェクトの生成がクラス内に書かれているからです。
class UserStore {
func allUsers() -> [User] {
// ...例えばDBやサーバーからユーザーを取得するコードが
// ここに書かれているとします。
}
}
class UserStatistics {
let userStore = UserStore()
var numberOfYoungUsers: Int {
get {
return userStore.allUsers().filter({ $0.age < 35 }).count
}
}
}
そういうときはクラスの外でオブジェクトを生成して渡すようにすればいいですね。一般的にDependency Injection (DI)💉と呼ばれているものです。
ついでに受け取るのはプロトコルにしちゃいましょう。
protocol UserStore {
func allUsers() -> [User]
}
class RealUserStore: UserStore {
func allUsers() -> [User] {
// ...例えばDBやサーバーからユーザーを取得するコードが
// ここに書かれているとします。
}
}
class UserStatistics {
let userStore: UserStore
init(userStore: UserStore) {
// 生成済みのUserStoreオブジェクトを外から受け取る
self.userStore = userStore
}
var numberOfYoungUsers: Int {
get {
return userStore.allUsers().filter({ $0.age < 35 }).count
}
}
}
// 依存するオブジェクトを外から注入
let userStatistics = UserStatistics(userStore: RealUserStore())
// ↓テストではこんな風に
class UserStatisticsTests: XCTestCase {
func testYoungUserCount() {
class MockUserStore: UserStore {
func allUsers() -> [User] {
return [
User(name: "Katsuo", age: 11),
User(name: "Namihei", age: 54),
User(name: "Masuo", age: 28),
User(name: "Wakame", age: 9),
]
}
}
// モックオブジェクトを外から注入
let userStatistics = UserStatistics(userStore: MockUserStore())
XCTAssertEqual(userStatistics.numberOfYoungUsers, 3)
}
}
でも、依存するオブジェクトが多いとオブジェクトを生成して渡すコードが増えて辛いとか、依存するオブジェクトを予め作るのが困難なパターンもあります。
// 依存するクラスが多いと、(テスト時以外でも)初期化が辛い
class Users {
let userGetter: UserGetter
let userCreator: UserCreator
let userUpdater: UserUpdater
let userDeleter: UserDeleter
init(userGetter: UserGetter,
userCreator: UserCreator,
userUpdater: UserUpdater,
userDeleter: UserDeleter)
{
self.userGetter = userGetter
self.userCreator = userCreator
self.userUpdater = userUpdater
self.userDeleter = userDeleter
}
}
let users = Users(userGetter: RealUserGetter(),
userCreator: RealUserCreator(),
userUpdater: RealUserUpdater(),
userDeleter: RealUserDeleter()) // 辛い😢
// 依存するオブジェクトの個数が外からではわからない場合はそもそもどうする?
class LockerRoom {
let uniforms: [Uniform]
init() {
let players: [Int] = ... // プレイヤーが何らかの方法で取得されて
// プレイヤーの数だけユニフォームを用意しておく
// これを外から渡すようにしたいが……
uniforms = players.map { _ in RealUniform() }
}
}
じゃあ、予めオブジェクトを作って渡すんじゃなくて、オブジェクトを生成する処理を別に用意して、テスト時はそこを書き換えることでモック化すればいいじゃん。こういうのはService Locator💁とも呼ばれます。
class Locator {
func resolveUserGetter() -> UserGetter { return RealUserGetter() }
func resolveUserCreator() -> UserCreator { return RealUserCreator() }
func resolveUserUpdater() -> UserUpdater { return RealUserUpdater() }
func resolveUserDeleter() -> UserDeleter { return RealUserDeleter() }
func resolveUniform() -> Uniform { return RealUniform() }
}
var locator = Locator()
class Users {
let userGetter: UserGetter
let userCreator: UserCreator
let userUpdater: UserUpdater
let userDeleter: UserDeleter
init() {
userGetter = locator.resolveUserGetter()
userCreator = locator.resolveUserCreator()
userUpdater = locator.resolveUserUpdater()
userDeleter = locator.resolveUserDeleter()
}
...
}
let users = Users() // スッキリ
class LockerRoom {
let uniforms: [Uniform]
init() {
let players: [Int] = ... // プレイヤーが何らかの方法で取得されて
// プレイヤーの数だけユニフォームを用意しておく
uniforms = players.map { _ in locator.resolveUniform() }
}
}
// テスト時はLocatorを差し替え
class MockLocator: Locator {
override func resolveUserGetter() -> UserGetter {
class MockUserGetter: UserGetter {
override func allUsers() -> [User] {
return [
User(name: "Katsuo", age: 11),
User(name: "Namihei", age: 54),
User(name: "Masuo", age: 28),
User(name: "Wakame", age: 9),
]
}
}
return MockUserGetter()
}
}
locator = MockLocator()
欠点は、対象のクラスの依存状況がわかりづらく、モック化するものを見失うことです。
そこで、クラスごとにLocatorを分け、依存するものだけを合成したオブジェクトをイニシャライザで受け取るようにしてみました。
protocol UserGetterLocator {
func resolveUserGetter() -> UserGetter
}
protocol UserCreatorLocator {
func resolveUserCreator() -> UserCreator
}
protocol UserUpdaterLocator {
func resolveUserUpdater() -> UserUpdater
}
protocol UserDeleterLocator {
func resolveUserDeleter() -> UserDeleter
}
protocol UniformLocator {
func resolveUniform() -> Uniform
}
class Users {
typealias Locator = UserGetterLocator
& UserCreatorLocator
& UserUpdaterLocator
& UserDeleterLocator
let userGetter: UserGetter
let userCreator: UserCreator
let userUpdater: UserUpdater
let userDeleter: UserDeleter
init(locator: Locator) {
userGetter = locator.resolveUserGetter()
userCreator = locator.resolveUserCreator()
userUpdater = locator.resolveUserUpdater()
userDeleter = locator.resolveUserDeleter()
}
...
}
// テストコード側でモックを差し込むのはこんな感じ
class MockLocator: Users.Locator {
func resolveUserGetter() -> UserGetter {
class MockUserGetter: UserGetter {
override func allUsers() -> [User] {
return [
User(name: "Katsuo", age: 11),
User(name: "Namihei", age: 54),
User(name: "Masuo", age: 28),
User(name: "Wakame", age: 9),
]
}
}
return MockUserGetter()
}
func resolveUserCreator() -> UserCreator { ... }
func resolveUserUpdater() -> UserUpdater { ... }
func resolveUserDeleter() -> UserDeleter { ... }
}
let users = Users(locator: MockLocator())
さらに、テストコード以外では必要なLocatorを気にしなくていいように、extensionを活用して全てのLocatorを持つクラス💪を1つ作ってしまいます。
// 💪
class DefaultLocator {
}
extension DefaultLocator: UserGetterLocator {
func resolveUserGetter() -> UserGetter { return RealUserGetter() }
}
extension DefaultLocator: UserCreatorLocator {
func resolveUserCreator() -> UserCreator { return RealUserCreator() }
}
extension DefaultLocator: UserUpdaterLocator {
func resolveUserUpdater() -> UserUpdater { return RealUserUpdater() }
}
extension DefaultLocator: UserDeleterLocator {
func resolveUserDeleter() -> UserDeleter { return RealUserDeleter() }
}
extension DefaultLocator: UniformLocator {
func resolveUniform() -> Uniform { return RealUniform() }
}
// テストコード以外では、何が必要なのかは気にせずに常にDefaultLocatorを使う
let users = Users(locator: DefaultLocator())
なんだかややこしくなってきましたが、現実的には、クラスとそのプロトコル、そのLocator、DefaultLocatorの拡張。この4つをセットで書いておくようにしていけばいいのです。
protocol UserGetter {
func allUsers() -> [User]
}
class RealUserGetter: UserGetter {
func allUsers() -> [User] {
// ...例えばDBやサーバーからユーザーを取得するコードが
// ここに書かれているとします。
}
}
protocol UserGetterLocator {
func resolveUserGetter() -> UserGetter
}
extension DefaultLocator: UserGetterLocator {
func resolveUserGetter() -> UserGetter {
return RealUserGetter()
}
}
もし、依存先のクラスがさらに別のクラスに依存する場合でも、DefaultLocatorは全てのLocatorを実装するので、こんな風にして問題ありません。
class RealUserGetter {
// RealUserGetterがUserに依存している
typealias Locator = UserLocator
init(locator: Locator) {
... = locator.resolveUser()
}
}
protocol UserGetterLocator {
func resolveUserGetter() -> UserGetter
}
extension DefaultLocator: UserGetterLocator {
func resolveUserGetter() -> UserGetter {
// locatorとしてselfを渡しておく
// DefaultLocatorはUserLocatorも実装しているはず
return UserGetter(locator: self)
}
}
後半が駆け足気味でしたが、まとめると、
🏇単純なオーバーライド
💉DI
💁Service Locator
🙋Injected Service Locator(勝手に命名)
という4つの差し込み方を紹介しました。
どうも、ごTweet聴ありがとうございました🙂