Edited at

iOSアプリでMockを使ってUnitTestを書く

More than 1 year has passed since last update.

VIPERやClean Architectureなどでは当然されていますが、UTをするためにはProtocolを用いて各レイヤー間の依存度を下げテストをしやすくする必要があります。


テストしにくい(依存度が高い)コード

class BookmarkViewModel {

let model = BookmarkModel()
var bookmarks = [Bookmark]()

// DBからブックマーク一覧を取得
func loadBookmarkList() {
bookmarks = model.load()
}

// DBからi番目のブックマークを削除
func deleteBookmark(at index: Int) {
model.delete(at: index)
}
}

このコードではBookmarkViewModelBookmarkModelに依存しているため、

BookmarkViewModelのテストをするときは以下のようにViewModelの範囲を超えてModelレイヤーのことまで意識しないといけなくなってしまいます。

class BookmarkViewModelTests: XCTestCase {

var vm = BookmarkViewModel()
func testLoadBookmarkList() {
let model = BookmarkModel()
// 例:BookmarkModelが操作するDBを意識してテスト用に2件データを追加
model.insert(Bookmark(id: "bookmark1"))
model.insert(Bookmark(id: "bookmark2"))

vm.loadBookmarkList()
// 上で2件追加したので取得も2件でbookmarks変数に2件あるはず
XCTAssertEqual(vm.bookmarks.count, 2)
}
}

このような単純なModelならそこまでテストに苦労はしませんが、複雑になってくるにつれテストが行いにくくなってきます。そのような状況になってもテストをするために、Protocolを用いてViewModelからModelの実態を意識させないようにします。


テストしやすい(依存度が低い)コード

protocol BookmarkModelProtocol {

func load() -> [Bookmark]
func delete(at index: Int)
}

class BookmarkModel: BookmarkModelProtocol {
// 本来の実装すべきロジック
}

class BookmarkViewModel {
let model: BookmarkModelProtocol
var bookmarks = [Bookmark]()

init(model: BookmarkModelProtocol = BookmarkModel()) {
self.model = model
}

func loadBookmarkList() {
bookmarks = model.load()
}

func deleteBookmark(at index: Int) {
model.delete(at: index)
}
}

このコードになるとBookmarkViewModelBookmarkModelProtocolにしか依存してなくinit経由で自由にオブジェクトを差し替えることができます。

そのためテストでは以下のように通常の時とは違う挙動できるようになります。

class BookmarkModelMock: BookmarkModelProtocol {

func load() -> [Bookmark] {
// 2件返す
return [Bookmark(id: "bookmark1"), Bookmark(id: "bookmark2")]
}

func delete(at index: Int) { // なにもしない }
}

class BookmarkViewModelTests: XCTestCase {
var vm = BookmarkViewModel(model: BookmarkModelMock())
func testLoadBookmarkList() {
vm.loadBookmarkList()
// mockでは2件返すので、それをちゃんと変数に入れてるかのチェックだけ気にする
XCTAssertEqual(vm.bookmarks.count, 2)
}
}

このようにMockを使うことで他レイヤーのことを気にせずTest対象クラスに閉じた挙動をテストできます。

また他レイヤーに影響を与えないので、最後にDBの全削除などの後処理も不要になり影響範囲が小さくなります。

ただ現状のMockだと固定値を返すだけで、返却値がないメソッドは動作の確認をできないので、以下のように変更を加えテストできるようにします。


// インスタンス変数を保持するのでテストケース毎の初期化が必要
class BookmarkModelMock: BookmarkModelProtocol {
var isLoadCalled = false
var loadResult = [Bookmark]()
func load() -> [Bookmark] {
isLoadCalled = true
return loadResult
}

var isDeleteCalled = false
var deleteParam: Int?
func delete(at index: Int) {
isDeleteCalled = true
deleteParam = index
}
}

class BookmarkViewModelTests: XCTestCase {
func testLoadBookmarkList() {
let mock = BookmarkModelMock()
// テストパターンに応じて外部から返却値を設定(今回は2件返す)
mock.loadResult = [Bookmark(id: "bookmark1"), Bookmark(id: "bookmark2")]

let vm = BookmarkViewModel(model: mock)
vm.loadBookmarkList()
// mockでは2件返すので、それをちゃんと変数に入れてるかチェック
XCTAssertEqual(vm.bookmarks.count, 2)

mock.loadResult.removeAll()
vm.loadBookmarkList()
// modelの値が変わったらちゃんと反映されるかチェック
XCTAssertEqual(vm.bookmarks.count, 0)
}

func testDelete() {
let mock = BookmarkModelMock()
let vm = BookmarkViewModel(model: mock)
// まだ呼ばれないのでfalse
XCTAssertFalse(mock.isDeleteCalled)

vm.remove(at: 0)
// BookmarkViewModelから正しくModelのメソッドが呼ばれているかチェック
XCTAssertTrue(mock.isDeleteCalled)
XCTAssertEqual(mock.deleteParam!, 0)
}
}

このようにすることでレイヤーまたぎの処理なども影響範囲を小さくしつつ、正しい振る舞いができているかテストすることができます。

ちなみにAndroidでは、このようなことができるmockitoというライブラリがありますが、iOSではなかったのでこの様な形で実装しました。