##前提
ここでは私自身の備忘録として本記事の執筆に至っています。
仔細な間違い等ございましたらご指摘いただけますと幸いです。
##実装すること
今回はXCTestを用いて実際のAPIを叩きそれに対するテストコードを実装してみたいと思います。
実装する内容としてはユーザー名を用いGithubのレポジトリからスター数を5以上のものを返すModelクラスをテストしてみたいと思います。
##各クラスのロール
クラス | ロール |
---|---|
GitHubRepository | GitHubレポジトリを表現するEntityクラス |
GitHubAPIClient | GitHubのAPIを叩いてデータを取得するAPIクライアント |
GitHubRepositoryManager | 指定したユーザー名のレポジトリ一覧を取得するModelクラス |
##実際のコード
// GitHubリポジトリを表現するEntityクラス
struct GitHubRepository: Codable, Equatable {
let id: Int
let star: Int
let name: String
enum CodingKeys: String, CodingKey {
case id
case star = "stargazers_count"
case name
}
}
protocol GitHubAPIClientProtocol {
func fetchRepositories(user: String, handler: @escaping([GitHubRepository]?) -> Void)
}
// GitHubのAPIを叩いてデータを取得するAPIクライアント
// 作成したプロトコルに準拠するようにする
class GitHubAPIClient: GitHubAPIClientProtocol {
// ユーザー名を受け取り、そのユーザーのリポジトリ一覧を取得する
// Parameters:
// - user: ユーザー名
// - handler: コールバック(引数にリポジトリ一覧が渡される)
func fetchRepositories(user: String, handler: @escaping([GitHubRepository]?) -> Void) {
let url = URL(string: "http://api.github.com/users/\(user)/repos")!
let request = URLRequest(url: url)
let task = URLSession.shared.dataTask(with: request) { data, _, error in
guard let data = data, error == nil else {
handler(nil)
return
}
let repos = try! JSONDecoder().decode([GitHubRepository].self, from: data)
DispatchQueue.main.async {
handler(repos)
}
}
task.resume()
}
}
class GitHubRepositoryManager {
// 内部で利用している型をProtocolに変更し、GitHubAPIClientという具体的な型ではなくGitHubAPIClientProtocolというインターフェースにのみ依存させた
private let client: GitHubAPIClientProtocol
private var repos: [GitHubRepository]?
// スターが5以上のリポジトリを返す(未取得の場合は空)
var majorRepositories: [GitHubRepository] {
guard let repositories = self.repos else { return [] }
return repositories.filter({ $0.star >= 5 })
}
// 内部でAPIClientを生成している
// init() {
// self.client = GitHubAPIClient()
// }
// 内部でGitHubAPIClientを生成せず、コンストラクタ経由で渡せるようにした
init(client: GitHubAPIClientProtocol = GitHubAPIClient()) {
self.client = client
}
// 指定されたユーザー名のリポジトリ一覧を読み込み、完了したらコールバックを呼び出す
func load(user: String, completion: @escaping() -> Void) {
self.client.fetchRepositories(user: user) { repositories in
self.repos = repositories
completion()
}
}
}
今回はManagerクラスのloadメソッドを呼び出した後で、majorRepositoriesプロパティが正しいリポジトリ一覧(5以上のスター数)を返すことをテストします。
###Protocolに準拠させてAPIClientを代替可能にする
今回はよりテストをしやすい環境を作ることを意識し、protocolを利用し、Manager内部でのAPIClientに対する依存関係を解消しています。
このようにすることで、ManagerクラスはAPIClientという具体的な形ではなくAPIClientProtocolというインターフェースにのみ依存することができ、よりテストしやすくなります。
##テスト用のモックを作成する
次にテスト用にAPIClientProtocolに準拠したモックを作成します。
モックには以下の2つの機能が必要です。
1.fetchRepositoriesメソッド呼び出し時の引数userを記録する
(意図した通りの引数で呼び出されているかを検証するため)
2.任意のGitHubRepositoryの配列をコールバックで返却する
(テスト用のデータとして設定した値を返却できるようにするため)
// テスト用にGitHubAPIClientProtocolを準拠したモックを作成する
class MockGitHubAPIClient: GitHubAPIClientProtocol {
// 返却されたリポジトリ一覧を保持
var returnRepositories: [GitHubRepository]
// 呼び出された引数を記録
var argsUser: String?
// コンストラクタでテスト用のデータを受け取る
init(repositories: [GitHubRepository]) {
self.returnRepositories = repositories
}
// 引数を記録
func fetchRepositories(user: String, handler: @escaping ([GitHubRepository]?) -> Void) {
self.argsUser = user
handler(self.returnRepositories)
}
}
ここで作成したモックはGitHubAPIClientProtocolに準拠してるのでテスト対象のGitHubRepositoryMangerにそのまま渡せます。
ここまでで、テストコードを記述するのに必要なものが揃いました。
##作成したモックを使ってテストする
import XCTest
@testable import プロジェクトターゲット名
class SampleTestCodeTests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
func testMajorRepositories() {
// テスト用のリポジトリ一覧 (1)
let testRepositories: [GitHubRepository] = [
GitHubRepository(id: 0, star: 4, name: ""),
GitHubRepository(id: 1, star: 5, name: ""),
GitHubRepository(id: 2, star: 6, name: "")
]
// モックを作成 (2)
let mockClient = MockGitHubAPIClient(repositories: testRepositories)
// テスト対象を生成する際にモックを渡す (3)
let manager = GitHubRepositoryManager(client: mockClient)
//テスト対象のメソッドを呼び出し (4)
manager.load(user: "apple") {
// 引数の検証 (5)
XCTAssertEqual(mockClient.argsUser, "apple")
// 結果の検証 (6)
XCTAssertEqual(manager.majorRepositories.count, 2)
XCTAssertEqual(manager.majorRepositories[0].id, 1)
XCTAssertEqual(manager.majorRepositories[1].id, 2)
}
}
}
(1)~(3)がテスト用の準備、(4)がテスト対象の呼び出し、(5)、(6)が結果の検証です。
このように、テスト対象が依存するクラスをprotocolに置き換え、テスト用に都合の良いモックを渡せるようにするテクニックは、ユニットテストにおいてよく利用されます。
<参考書籍: iOSアプリ開発自動テストの教科書>