概要
-
前編: テスト対象と、テストが書きづらいコードはなぜ書きづらいのかを説明します。
-
後編(この記事): テストが書きづらいコードを書きやすいコードへ変更する方法、実際のテストコードを説明します。
テストが書けない/書きづらいコードの原因と回避策
前編のまとめから話を続けます。
クラス外の値を内部で利用している場合
-
テストを行うための値を準備できない
- クラス外の値 = どんなものが返ってくるか分からない = テストできない
→ テスト時のみ、返ってくる値を固定値にする(= 返ってくる値が明確) ことで回避します。
-
テスト失敗時に、原因がクラス内/外どちらなのか明確にできない
- クラス外の値 = どんな実装になっているかわからない = テスト失敗時に原因が明確にできない
→ クラス外とのやりとりはProtocolを通すことで、実際の実装との関係を切り離す ことで回避します。
大域変数(UserDefaultsなど)を利用している場合
-
与えた変更を消し忘れると、本体実行時や他のテストに影響する
→ 大域変数と直接やりとりを行わないようにし、大域変数への変更は最小限にとどめる ことで回避します。
以降では、上記の要望を満たすための方法を具体例とともに説明します。
暗黙的入力のテスト と、 暗黙的出力のテスト と2つに分かれますが、行う作業はほぼ同じです。
暗黙的入力のテスト
- 暗黙的入力 = クラス外から値を取得している = テストが書けない/書きづらい
例えば、以下の本体コードがあるとします。
class ImplicitInput {
private let data: Data
init() {
// 1 ~ 10 までのランダムな数字を使って Data クラスを生成する
let random = Int(arc4random_uniform(10) + 1)
self.data = Data(value: random)
}
func reduce () -> Int {
// クラス外から値を取得している
return self.data.double() - 1
}
class Data {
let value: Int
init(value: Int) {
self.value = value
}
func double() -> Int {
return value * 2
}
}
}
ImplicitInput#reduce()
の部分で、暗黙的入力を行なっています。
現状では「Data#double()
= 4 の時、 ImplicitInput#reduce()
= 3 となる」といったテストは書けません。
しかし、 Data#double()
の値を指定できるようになれば、テストを書けるようになります。
=> スタブ というオブジェクトを利用する手法を紹介します。
スタブとは
- スタブ = 事前に設定した振る舞いをする 偽物のオブジェクト。特に、取得用のオブジェクトとして利用する。
スタブを利用すると何が良いのか
スタブを利用することで、 冒頭で示した 3つの回避策
- テスト時のみ、返ってくる値を固定値にする(= 返ってくる値が明確)
- クラス外とのやりとりはProtocolを通すことで、実際の実装との関係を切り離す
- 大域変数と直接やりとりを行わないようにし、大域変数への変更は最小限にとどめる
を全て満たすことができます。
本番時には 「本物の振る舞い」をするオブジェクトを利用し、
テスト時には「偽物の振る舞い」をするオブジェクト(スタブ)に差し替える。
そうすることで、本番の動きは変えずにテストを書けるようになります。
スタブを利用するために本体コードを変更する
スタブへ差し替え可能にするためには準備が必要です。
- 必要な作業
- 制御したい振る舞いをProtocol定義する
- 暗黙的入力を行なっている箇所でProtocolを利用する
- 本番用にProtocolに準拠したクラスを定義する
1. 制御したい振る舞いをProtocol定義する
「クラス外から取得した値」に相当するProtocolを定義します。
例題の場合は、Data#double()
です。
// 取得用のプロトコル
protocol ReadableRepositoryContract {
// Data#double() に相当する
func read() -> Int
}
2. 暗黙的入力を行なっている箇所でProtocolを利用する
次に、定義したProtocolを本体コードで利用します。
変更後のコード:
class ImplicitInput {
// - 変更1
private let repository: ReadableRepositoryContract
// - 変更2
init (readVia repository: ReadableRepositoryContract) {
self.repository = repository
}
func reduce() -> Int {
// - 変更3
return self.repository.read() - 1
}
}
- 変更1: 取得用の
ReadableRepositoryContractプロトコル
を プロパティで持つ - 変更2: プロパティの実態は 初期化時に
init
の引数としてもらう - 変更3:
ImplicitInput#reduce()
内では、ReadableRepositoryContractプロトコル
を利用する
上記変更のおかげで、テスト時に ReadableRepositoryContractプロトコル
に準拠した偽物のオブジェクト(スタブ)を利用できるようになります。
3. 本番用にProtocolに準拠したクラスを定義する
本番時に利用するオブジェクトは別途必要になるので、元となるクラスを定義します。
// 本番用の ReadableRepositoryContractプロトコル に準拠したクラス
class ReadableRepository: ReadableRepositoryContract {
private let data: ImplicitInput.Data
init(data: ImplicitInput.Data) {
self.data = data
}
// プロトコル準拠部分
// Data#double() に相当する
func read() -> Int {
return self.data.double()
}
}
今回の例題では Dataクラス
から値を取得していますが、 UserDefaults
から取得する場合は以下のようになります。
class ReadableRepository: ReadableRepositoryContract {
func read() -> Int {
return UserDefaults.standard.integer(forKey: "read")
}
}
本体コード変更後
以上でスタブを利用する準備は完了です。
ちなみに、 本番でImplicitInputクラス
を使う時は以下の書き方になります。
// 本番の取得用オブジェクトを生成
let repository = ReadableRepository(
data: ImplicitInput.Data(value: 5)
)
let implicitInput = ImplicitInput(readVia: repository)
// ReadableRepositoryContract を経由して、 Dataクラス から値を取得する
let result = implicitInput.reduce()
テストを書く
スタブを使う準備が整ったので、実際に ImplicitInputクラス
のテストコードを書いていきます。
1. スタブを定義する
スタブ = 取得用の偽物のオブジェクト = 取得用のプロトコル(ReadableRepositoryContract
) に準拠しています。
// スタブを定義する
class ReadableRepositoryStub: ReadableRepositoryContract {
private let base: Int
init(base: Int) {
self.base = base
}
// 偽物の振る舞いを行なっている箇所
func read() -> Int {
// init時に渡された値をそのまま返す
return self.base
}
}
2. スタブを利用してテストを書く
本体コードを変更する前は、「Data#double()
= 4 の時、 ImplicitInput#reduce()
= 3 となる」というテストを書こうとしても書けませんでした。
変更後のコードで、「ReadableRepositoryContract#read()
= 4 の時、ImplicitInput#reduce()
= 3 となる」というテストを書いてみます。
class ImplicitInputTests: XCTestCase {
func testMultiplication() {
// ReadableRepositoryContract#read() = 4 の時、
let int = 4
// ImplicitInput#reduce() = 3 となる
let expected = 3
// - 1: スタブを作成
let repositoryStub = ReadableRepositoryStub(readValue: int)
// - 2: スタブを差し込む
let input = ImplicitInput(readVia: repositoryStub)
// - 3: 内部でスタブが利用される
// これにより、「ReadableRepositoryContract#read() = 4 の時」を再現できる
let actual = input.reduce()
XCTAssertEqual(actual, expected)
}
}
無事、テストが書けるようになりました。
スタブ説明のまとめ
- テスト時のみ、返ってくる値を固定値にする(= 返ってくる値が明確) → 該当
- テストを行う前の準備が用意になり、テストが書きやすくなった
- クラス外とのやりとりはProtocolを通すことで、実際の実装との関係を切り離す → 該当
-
ImplicitInputクラス
内ではReadableRepositoryContractプロトコル
を利用することにより、他のクラスの実装から切り離すことができた
なので、ImplicitInputクラス
のテストが失敗した場合は、ImplicitInputクラス
の実装が間違っているということが明確になった
-
- 大域変数と直接やりとりを行わないようにし、大域変数への変更は最小限にとどめる → 該当
-
ReadableRepositoryContractプロトコル
を通すことで、大域変数と直接やりとりを行わなくなった
本番時に大域変数を利用する箇所は、テスト時に偽のオブジェクトへ差し替えることで、大域変数への変更を行わなくて済むようになった
-
暗黙的出力のテスト
- 暗黙的出力 = 処理の結果または過程で、クラス外へ変更を行っている = テストが書けない/書きづらい
例として、以下の本体コードがあるとします。
class ImplicitOutput {
private let data = Data(value: nil)
func write(int: Int) {
// クラス外へ変更を行っている
self.data.value = int
}
class Data {
var value: Int?
init(value: Int?) {
self.value = value
}
}
}
ImplicitOutput#write(:Int)
の部分で、暗黙的出力を行なっています。
現状では、「ImplicitOutput#write(:Int)
に 2 を渡した時、Data.value
= 2 となる」 といったテストは書けません。
=> スパイ というオブジェクトを利用する手法を紹介します。
スパイとは
- スパイ = 事前に設定した振る舞いをする偽物の関数やオブジェクト。関数の呼び出し回数や、自身への変更を記録するオブジェクトとして利用する。
スパイを利用すると何が良いのか
スパイを利用することで、 冒頭で示したウチの 2つの回避策
- クラス外とのやりとりはProtocolを通すことで、実際の実装との関係を切り離す
- 大域変数と直接やりとりを行わないようにし、大域変数への変更は最小限にとどめる
を満たすことができます。
スパイを利用するために本体コードを変更する
スパイの利用には準備が必要です。
- 必要な作業
- 制御したい振る舞いをProtocolにする
- 暗黙的出力を行なっている箇所ではProtocolを利用する
- 本番用にProtocolに準拠したオブジェクトを作成する
1. 制御したい振る舞いをProtocolにする
「記録したいクラス外の値」に相当するProtocolを定義します。
例題の場合は、Data#value
です。
// 記録用のプロトコル
protocol WritableRepositoryContract {
// Data#value に相当する
func write(int: Int)
}
2. 暗黙的出力を行なっている箇所ではProtocolを利用する
次に、定義したProtocolを本体コードで利用します。
変更後のコード:
class ImplicitOutput {
// - 変更1
private let repository: WritableRepositoryContract
// - 変更2
init(writeVia repository: WritableRepositoryContract) {
self.repository = repository
}
func write(int: Int) {
// - 変更3
self.repository.write(int: int)
}
}
- 変更1: 記録用の
WritableRepositoryContractプロトコル
を プロパティで持つ - 変更2: プロパティの実態は 初期化時に
init
の引数としてもらう - 変更3:
ImplicitOutput#write(:Int)
内では、WritableRepositoryContractプロトコル
を利用する
上記変更のおかげで、テスト時に WritableRepositoryContractプロトコル
に準拠した偽物のオブジェクト(スパイ)を利用できるようになります。
3. 本番用にProtocolに準拠したクラスを定義する
本番時に利用するオブジェクトは別途必要になるので、元となるクラスを定義します。
// 本番の WritableRepositoryContractプロトコル に準拠したクラス
class WritableRepository: WritableRepositoryContract {
private let data: ImplicitOutput.Data
init(data: ImplicitOutput.Data) {
self.data = data
}
// プロトコル準拠部分
// Data#value に相当する
func write(int: Int) {
self.data.value = int
}
}
今回の例題では Dataクラス から値を取得していますが、 UserDefaults を変更場合は以下のようになります。
class WritableRepository: WritableRepositoryContract {
func write(int: Int) {
UserDefaults.standard.set(int, forKey: "write")
}
}
変更後の本体コード
以上でスパイを利用する準備は完了です。
ちなみに、 本番で ImplicitOutputクラス
を使う時は以下の書き方になります。
// 本番の記録用オブジェクトを生成
// ただし、本番では 記録 の必要はないので 変更 のために使用している
let repository = WritableRepository(
data: ImplicitOutput.Data(value: nil)
)
let implicitOutput = ImplicitOutput(writeVia: repository)
// WritableRepositoryContractを経由して、Dataクラス の値を変更する
implicitOutput.write(int: 2)
テストを書く
スパイを使う準備が整ったので、スパイを使って実際にテストコードを書いていきます。
1. スパイオブジェクトを書く
スパイ = 記録用の偽物のオブジェクト = 記録用のプロトコル(WritableRepositoryContract
) に準拠している
// スパイを定義する
class WritableRepositorySpy: WritableRepositoryContract {
// メソッドが呼び出された際の引数列。
private(set) var callArguments: [Int] = []
// 偽物の振る舞いを行なっている箇所
func write(int: Int) {
// 呼び出しを記録してい
self.record(int)
}
private func record(_ args: Int) {
self.callArguments += [args]
}
}
2. スパイを使ってテストを書く
本体コードを変更する前は、「ImplicitOutput#write(:Int)
へ 2 を渡した時、Data.value
= 2 となる」というテストを書こうとしても書けませんでした。
変更後のコードで、「ImplicitOutput#write(:Int)
へ 2 を渡した時、 WritableRepositoryContract#write(:Int)
へ 同じ値 を渡している」というテストを書いてみます。
class ImplicitOutputTests: XCTestCase {
func testWrite() {
// ImplicitOutput#write(:Int) へ 2 を渡した時、
let int = 2
// WritableRepositoryContract#write(:Int) へ 同じ値 を渡している
let expected = int
// - 1: スパイを作成する
let spy = WritableRepositorySpy()
// - 2: スパイを差し込む
let output = ImplicitOutput(writeVia: spy)
// - 3: 内部でスパイに記録される
output.write(int: int)
// - 4: スパイに記録された値を確認する
XCTAssertEqual(expected, spy.callArguments.first!)
}
}
無事、テストが書けるようになりました。
スパイ説明のまとめ
- クラス外とのやりとりはProtocolを通すことで、実際の実装との関係を切り離す
-
ImplicitOutputクラス
内ではWritableRepositoryContractプロトコル
を利用することにより、Dataクラス
の実装から切り離すことができた
なので、ImplicitOutputクラス
のテストが失敗した場合は、ImplicitOutputクラス
の実装が間違っているということが明確になった
-
- 大域変数と直接やりとりを行わないようにし、大域変数への変更は最小限にとどめる
-
WritableRepositoryContractプロトコル
を通すことで、大域変数と直接やりとりを行わなくなった
本番時に大域変数を利用する箇所は、テスト時に偽のオブジェクトへ差し替えることで、大域変数への変更を行わなくて済むようになった
-
おまけ: Repositoryのテスト
ReadableRepository
と WritableRepository
ですが、もちろんこれらもテストする必要があります。
Repository
は ImplicitXxxxクラス
をテストするために必要になったクラスですが、それ以外にも利点があります。
クラス外からの取得/クラス外への変更 のテストを ImplicitXxxxクラス
から 分離 できた、という利点です。
テストを分けられるということは、1つのテストで確認する事柄が減るということであり、テストの書きやすさにつながります。
以降には、 Repository
経由で UserDefaults の値を取得/変更 している想定でのテストを載せています。
この階層まできたら、UserDefaults への変更はもう致し方なしと思っています。。。
追記: UserDefaults への直接操作を回避する
ReadableRepositoryのテスト
// 本体コード
class ReadableRepositoryForUserDefaults: ReadableRepositoryContract {
func read() -> Int {
return UserDefaults.standard.integer(forKey: "read")
}
}
// テストコード
class ReadableRepositoryTests: XCTestCase {
func testRead() {
let testValue = 2
let expected = testValue
// 本体コード、もしくは他のテストから変更されている可能性があるので、UserDefaultsを初期状態にしてからスタート
UserDefaults.standard.removeObject(forKey: "read")
// テストを行うために 「事前に入っていて欲しい値」をUserDefaultsに直接書き込む
UserDefaults.standard.set(testValue, forKey: "read")
/* Repositoryをテスト */
let repository = ReadableRepositoryForUserDefaults()
let actuals = repository.read()
XCTAssertEqual(actuals, expected)
/* Repositoryをテスト */
// 本体コード、もしくは他のテストへ
影響を与えないために、UserDefaultsに加えた変更を取り消す
UserDefaults.standard.removeObject(forKey: "read")
}
}
WritableRepositoryのテスト
// 本体コード
class WritableRepositoryForUserDefaults: WritableRepositoryContract {
func write(int: Int) {
UserDefaults.standard.set(int, forKey: "write")
}
}
// テストコード
class WritableRepositoryTests: XCTestCase {
func testWrite() {
let testValue = 2
let expected = testValue
// 本体コード、もしくは他のテストから変更されている可能性があるので、UserDefaultsを初期状態にしてからスタート
UserDefaults.standard.removeObject(forKey: "write")
/* Repositoryをテスト */
let repository = WritableRepositoryForUserDefaults()
repository.write(int: testValue)
let actuals = UserDefaults.standard.integer(forKey: "write")
XCTAssertEqual(actuals, expected)
/* Repositoryをテスト */
// 本体コード、もしくは他のテストへ
影響を与えないために、UserDefaultsに加えた変更を取り消す
UserDefaults.standard.removeObject(forKey: "write")
}
}
後半まとめ
-
暗黙的入力のテスト: Protocolを使ってスタブ(取得用の偽物のオブジェクト)を使えるクラス設計にしよう
-
暗黙的出力のテスト: Protocolを使ってスパイ(記録用の偽物のオブジェクト)を使えるクラス設計にしよう
-
メリット:
- 偽物に差し替えられるとテストが書きやすい🎉
- 結果的に疎結合になる(
UserDefaults
にべったり依存😱などがなくなる)
おわりに
スタブ・スパイ に初対面だったという方、いかがだったでしょうか。めんどくさかったですか?
私はとてもめんどくさいと思いました(テストを書くようになった当初の話です)。
あと、テストを書くのも本体コードを書くのもめちゃくちゃ時間がかかりました。
「テスト可能な本体コードの書き方」がぜんっぜん身につかなくて投げ出したかったのですが、
車窓からの TDDを読んで(実践して)からだいぶ改善されました。
「テストコード書くようになったけど、時間かかってつらい」 という方はそちらの記事も参考にして見てください。
追記: UserDefaults への直接操作を回避する
回避策をコメントでいただけました。ありがとうございます!
下記手順でUserDefaults への直接操作を回避します。
- UserDefaults の必要な部分だけのプロトコルを定義
- 各Repositoryではプロトコル経由でUserDefaultsと接続する
まずは、UserDefaultsのプロトコル定義部分です。
// 取得用のプロトコル
protocol UserDefaultsReadableContract {
func integer(forKey defaultName: String) -> Int
func double(forKey defaultName: String) -> Double
}
// 記録用のプロトコル
protocol UserDefaultsWritableContract {
func set(_ value: Int, forKey defaultName: String)
func set(_ value: Double, forKey defaultName: String)
}
次に、本物のUserDefaultsに先ほど定義したのプロトコルを extension します。
これにより、UserDefaultsReadableContract
、 UserDefaultsWritableContract
を利用している箇所で
UserDefaults のインスタンスを渡すことが可能になります。
また、今後 Swift バージョンアップなどで UserDefaults のインターフェースが変わった場合、
この部分でコンパイルエラーとなるのでプロトコル定義の間違いに気づけます。
extension UserDefaults: UserDefaultsReadableContract, UserDefaultsWritableContract {}
各Repositoryがプロトコル経由でUserDefaultsと接続するように変更します。
class ReadableRepositoryForUserDefaults: ReadableRepositoryContract {
private let userDefaults: UserDefaultsReadableContract
// 本体コード側では
// ReadableRepositoryForUserDefaults(via: UserDefaults.standard) と
// 宣言することで UserDefaults を利用できる
init(via userDefaults: UserDefaultsReadableContract) {
self.userDefaults = userDefaults
}
func read() -> Int {
return self.userDefaults.integer(forKey: "read")
}
}
class WritableRepositoryForUserDefaults: WritableRepositoryContract {
private let userDefaults: UserDefaultsWritableContract
// 本体コード側では
// WritableRepositoryForUserDefaults(via: UserDefaults.standard) と
// 宣言することで UserDefaults を利用できる
init(via userDefaults: UserDefaultsWritableContract) {
self.userDefaults = userDefaults
}
func write(int: Int) {
self.userDefaults.set(int, forKey: "write")
}
}
最後に、テストの部分です。
// 新しく定義した UserDefaultsReadableContract のスタブ(取得用の偽物)を作成する
struct UserDefaultsStub: UserDefaultsReadableContract {
private let keyAndValues: [String: Any]
init(keyAndValues: [String: Any] = [:]) {
self.keyAndValues = keyAndValues
}
func integer(forKey defaultName: String) -> Int {
// テスト時に値を設定し忘れて nil だった場合、実行時エラーで気づける
return self.keyAndValues[defaultName] as! Int
}
func double(forKey defaultName: String) -> Double {
return self.keyAndValues[defaultName] as! Double
}
}
// テストコード
class ReadableRepositoryTests: XCTestCase {
func testRead() {
let testValue = 2
let expected = testValue
// - 1: スタブを作成
let userDefaultsStub = UserDefaultsStub(
keyAndValues: ["read": testValue]
)
// - 2: スタブを差し込む
let repository = ReadableRepositoryForUserDefaults(via: userDefaultsStub)
// - 3: 内部でスタブが利用される
let actuals = repository.read()
XCTAssertEqual(actuals, expected)
}
}
// 新しく定義した UserDefaultsWritableContract のスパイ(記録用の偽物)を作成する
class UserDefaultsSpy: UserDefaultsWritableContract {
private(set) var callSetIntArguments: [String: Int] = [:]
private(set) var callSetDoubleArguments: [String: Double] = [:]
func set(_ value: Int, forKey defaultName: String) {
self.callSetIntArguments[defaultName] = value
}
func set(_ value: Double, forKey defaultName: String) {
self.callSetDoubleArguments[defaultName] = value
}
}
class WritableRepositoryTests: XCTestCase {
func testWrite() {
let testValue = 2
let expected = testValue
// - 1: スパイを作成する
let userDefaultsSpy = UserDefaultsSpy()
// - 2: スパイを差し込む
let repository = WritableRepositoryForUserDefaults(via: userDefaultsSpy)
// - 3: 内部でスパイに記録される
repository.write(int: testValue)
// - 4: スパイに記録された値を確認する
let actual = userDefaultsSpy.callSetIntArguments["write"]!
XCTAssertEqual(actual, expected)
}
}