はじめに
最近、個人開発アプリのテスト書いて見ようと思ったのですが、スタティックメソッド使っていてモックが使えなかったり、アクセス修飾子にprivateがついてるときはどうするんだろうとわからないことが多かったのでのでテストについて学習しました。
理解を深めるためにGitHub APIを使った単体、複合テストの実装していきます。
Xcode Version 15.2
Swift Concurrencyのasync/awaitを使うのでSwift5.5、iOS 15以降が対象です。
↓sampleリポジトリ
完成イメージ
マッピング
https://api.github.com/users/kabikira/repos
上記URLで返ってくるJSONを確認
GitHubのリポジトリをユーザー名で取得して
- id
- リポジトリ名
- リポジトリのURL
- スター数
をマッピングして行きます。
Model実装
EntityとなるGitHubRepository.swiftを作成します。
Entityは、データ構造を定義します。APIから取得したデータを保持するためのものです。
原則としてロジックはもたない。
import Foundation
struct GitHubRepository : Codable, Equatable {
let id: Int
let name: String
let repositoryUrl: String
let star: Int
enum CodingKeys: String, CodingKey {
case id
case name
case repositoryUrl = "html_url"
case star = "stargazers_count"
}
}
GitHubAPIClient.swift作成
APIクライアントは、APIとの通信を担当します。エンドポイントの構築、リクエストの送信、レスポンスの処理を行います。
import Foundation
class GitHubAPIClient {
// ユーザ名を受け取り、そのユーザーのリポジトリ一覧を取得する。
func fetchRepositories(user: String) async throws -> [GitHubRepository] {
let url = URL(string: "https://api.github.com/users/\(user)/repos")!
let request = URLRequest(url: url)
let (data, _) = try await URLSession.shared.data(for: request)
let repos = try JSONDecoder().decode([GitHubRepository].self, from: data)
return repos
}
}
ModelとなるGitHubRepositoryManagerを作成
Modelは、ビジネスロジックやデータの操作を担当します。クライアントを利用してデータを取得し、UIに必要な形に加工します。
import Foundation
class GitHubRepositoryManager {
private let client: GitHubAPIClient
private(set) var repos: [GitHubRepository]?
init() {
self.client = GitHubAPIClient()
}
// 指定されたユーザ名のリポジトリ一覧を非同期で取得
func load(user: String) async throws {
let repositories = try await client.fetchRepositories(user: user)
self.repos = repositories
}
}
これで実際に値が取れているか、ViewControllerで
簡単に確認してみます。
import UIKit
class ViewController: UIViewController {
let repositoryManager = GitHubRepositoryManager()
override func viewDidLoad() {
super.viewDidLoad()
Task {
do {
try await repositoryManager.load(user: "kabikira")
if let repos = repositoryManager.repos {
print("Repositories: \(repos)")
}
} catch {
print("Failed to load repositories: \(error)")
}
}
}
}
これから、ModelであるGitHubRepositoryManagerのテストコードを書いていきます。
プロトコル導入
テスト時にモックを使用し、GitHubAPIClientを外部から差し替え可能にできるようプロトコルを作成し、準拠させます。
protocol GitHubAPIClientProtocol {
func fetchRepositories(user: String) async throws -> [GitHubRepository]
}
class GitHubAPIClient: GitHubAPIClientProtocol {
// ユーザ名を受け取り、そのユーザーのリポジトリ一覧を取得する。
func fetchRepositories(user: String) async throws -> [GitHubRepository] {
let url = URL(string: "https://api.github.com/users/\(user)/repos")!
let request = URLRequest(url: url)
let (data, _) = try await URLSession.shared.data(for: request)
let repos = try JSONDecoder().decode([GitHubRepository].self, from: data)
return repos
}
}
GitHubRepositoryManagerもイニシャライザー経由で渡せるように修正します。
class GitHubRepositoryManager {
// 型をGitHubAPIClientProtocolに変更
private let client: GitHubAPIClientProtocol
private(set) var repos: [GitHubRepository]?
// イニシャライザー経由で渡す、clientが指定されてないときのデフォルト引数を設定
init(client: GitHubAPIClientProtocol = GitHubAPIClient()) {
self.client = client
}
// 指定されたユーザ名のリポジトリ一覧を非同期で取得
func load(user: String) async throws {
let repositories = try await client.fetchRepositories(user: user)
self.repos = repositories
}
}
モック作成
GitHubAPITestTests.swiftの中にモックを作成します。個人的にモックはテストでしか使用しないのでTest内に記述しました。
import XCTest
@testable import GitHubAPITest
class MockGitHubAPIClient: GitHubAPIClientProtocol {
var mockRepositories: [GitHubRepository]
var requestedUser: String?
init(mockRepositories: [GitHubRepository]) {
self.mockRepositories = mockRepositories
}
func fetchRepositories(user: String) async throws -> [GitHubRepository] {
self.requestedUser = user
return mockRepositories
}
}
単体テストする
Swift 5.5以降では、XCTestフレームワークがasync/awaitに対応しているため、XCTestExpectationを使わなくても非同期関数をテストすることができます。
fetchRepositories関数に渡した引数と
testRepositoriesで定義したモックデータのid,name,repositorUrl,starが正しく返ってくるかテストします。
final class GitHubAPITestTests: XCTestCase {
var repositoryManager: GitHubRepositoryManager!
var mockClient: MockGitHubAPIClient!
let testRepositories: [GitHubRepository] = [
GitHubRepository(id: 1, name: "Repo1", repositoryUrl: "https://github.com/repo1", star: 100),
GitHubRepository(id: 2, name: "Repo2", repositoryUrl: "https://github.com/repo2", star: 150),
GitHubRepository(id: 3, name: "Repo3", repositoryUrl: "https://github.com/repo3", star: 0),
]
override func setUp() {
super.setUp()
// 必要な初期化を行う
mockClient = MockGitHubAPIClient(mockRepositories: testRepositories)
repositoryManager = GitHubRepositoryManager(client: mockClient)
}
override func tearDown() {
// 必要なクリーンアップを行う
repositoryManager = nil
mockClient = nil
super.tearDown()
}
func testLoadRepositories() async {
do {
try await repositoryManager.load(user: "kabikira")
// 引数の検証
XCTAssertEqual(mockClient.requestedUser, "kabikira")
// 結果の検証
// 返ってくるリポジトリの数
XCTAssertEqual(repositoryManager.repos?.count, 3)
// id
XCTAssertEqual(repositoryManager.repos?[0].id, 1)
XCTAssertEqual(repositoryManager.repos?[1].id, 2)
// name
XCTAssertEqual(repositoryManager.repos?[0].name, "Repo1")
XCTAssertEqual(repositoryManager.repos?[2].name, "Repo3")
// repositoryUrl
XCTAssertEqual(repositoryManager.repos?[0].repositoryUrl, "https://github.com/repo1")
XCTAssertEqual(repositoryManager.repos?[1].repositoryUrl, "https://github.com/repo2")
// star
XCTAssertEqual(repositoryManager.repos?[0].star, 100)
XCTAssertEqual(repositoryManager.repos?[2].star, 0)
} catch {
XCTFail("Expected success but got failure with error: \(error)")
}
}
}
テストを実行して結果を確認します。
わざとテストが失敗するコードなども書いてみて色々試してみてください。
結合テスト
ViewControllerとの結合テストを実装していきます。
ストーリーボードを設定
TableViewのCellを設定
GitHubTableViewCell.swiftを作成
import UIKit
final class GitHubTableViewCell: UITableViewCell {
static var className: String { String(describing: GitHubTableViewCell.self) }
@IBOutlet weak var nameLabel: UILabel!
@IBOutlet weak var starLabel: UILabel!
override func prepareForReuse() {
super.prepareForReuse()
self.nameLabel.text = nil
self.starLabel.text = nil
}
func configure(gitHubRepository: GitHubRepository) {
self.nameLabel.text = gitHubRepository.name
self.starLabel.text = String(gitHubRepository.star)
}
}
ViewController
import UIKit
class ViewController: UIViewController {
static func make() -> ViewController {
let storyboard = UIStoryboard(name: "Main", bundle: nil)
return storyboard.instantiateInitialViewController() as! ViewController
}
@IBOutlet weak var searchButton: UIButton! {
didSet {
searchButton.addTarget(self, action: #selector(searchButtonTapped), for: .touchUpInside)
}
}
@IBOutlet weak var searchTextField: UITextField!
@IBOutlet weak var indicator: UIActivityIndicatorView!
@IBOutlet weak var tableView: UITableView! {
didSet {
tableView.register(UINib.init(nibName: GitHubTableViewCell.className, bundle: nil), forCellReuseIdentifier: GitHubTableViewCell.className)
tableView.dataSource = self
}
}
var repositoryManager = GitHubRepositoryManager()
var gitHubRepositories: [GitHubRepository] = []
override func viewDidLoad() {
super.viewDidLoad()
}
@objc func searchButtonTapped() {
guard let username = searchTextField.text, !username.isEmpty else {
print("Username is empty")
return
}
performSearch(for: username)
}
func performSearch(for username: String) {
Task {
setIndicatorAnimating(true)
do {
try await repositoryManager.load(user: username)
if let repos = repositoryManager.repos {
updateUI(with: repos)
}
} catch {
print("Failed to load repositories: \(error)")
}
setIndicatorAnimating(false)
}
}
@MainActor
func setIndicatorAnimating(_ animating: Bool) {
if animating {
indicator.startAnimating()
} else {
indicator.stopAnimating()
}
}
@MainActor
func updateUI(with repositories: [GitHubRepository]) {
gitHubRepositories = repositories
tableView.reloadData()
}
}
extension ViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return gitHubRepositories.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: GitHubTableViewCell.className) as? GitHubTableViewCell else {
fatalError()
}
let gitHubRepositories = gitHubRepositories[indexPath.row]
cell.configure(gitHubRepository: gitHubRepositories)
return cell
}
}
結合テスト実装
ViewControllerのテストケースクラスを新たに作成します。
今回はサーチボタンがタップされたときの以下のテストコードを書いていきます。
- APIリクエストが正しいユーザー名で行われたことを確認する。
- 返されたリポジトリデータの数と内容が期待通りであることを確認する。
- インジケータが停止していることを確認する。
final class ViewControllerTest: XCTestCase {
var vc: ViewController!
var window: UIWindow!
var mockClient: MockGitHubAPIClient!
var repositoryManager: GitHubRepositoryManager!
let testRepositories: [GitHubRepository] = [
GitHubRepository(id: 1, name: "Repo1", repositoryUrl: "https://github.com/repo1", star: 100),
GitHubRepository(id: 2, name: "Repo2", repositoryUrl: "https://github.com/repo2", star: 150),
GitHubRepository(id: 3, name: "Repo3", repositoryUrl: "https://github.com/repo3", star: 0),
]
// setUpメソッドは、各テストメソッドの前に呼び出され、テスト環境を初期化します。
override func setUp() {
super.setUp()
mockClient = MockGitHubAPIClient(mockRepositories: testRepositories)
repositoryManager = GitHubRepositoryManager(client: mockClient)
vc = ViewController.make()
vc.repositoryManager = repositoryManager
window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = vc
window.makeKeyAndVisible()
}
// tearDownメソッドは、各テストメソッドの後に呼び出され、テスト環境をクリーンアップします。
override func tearDown() {
vc = nil
window = nil
mockClient = nil
repositoryManager = nil
super.tearDown()
}
// サーチボタンをタップしたときのテスト
@MainActor
func testSearchButtonTapped() async {
// メインスレッドでテキストフィールドにユーザー名を入力し、サーチボタンをタップ
vc.searchTextField.text = "testUser"
vc.searchButton.sendActions(for: .touchUpInside)
// 非同期の検索処理が完了するのを待つ
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
// ユーザー名は同じか
XCTAssertEqual(mockClient.requestedUser, "testUser")
// 返ってくるリポジトリの数
XCTAssertEqual(vc.gitHubRepositories.count, testRepositories.count)
// id
XCTAssertEqual(vc.gitHubRepositories[0].id, 1)
XCTAssertEqual(vc.gitHubRepositories[1].id, 2)
XCTAssertEqual(vc.gitHubRepositories[2].id, 3)
// name
XCTAssertEqual(vc.gitHubRepositories[0].name, "Repo1")
XCTAssertEqual(vc.gitHubRepositories[1].name, "Repo2")
XCTAssertEqual(vc.gitHubRepositories[2].name, "Repo3")
// repositoryUrl
XCTAssertEqual(vc.gitHubRepositories[0].repositoryUrl, "https://github.com/repo1")
XCTAssertEqual(vc.gitHubRepositories[1].repositoryUrl, "https://github.com/repo2")
XCTAssertEqual(vc.gitHubRepositories[2].repositoryUrl, "https://github.com/repo3")
// star
XCTAssertEqual(vc.gitHubRepositories[0].star, 100)
XCTAssertEqual(vc.gitHubRepositories[1].star, 150)
XCTAssertEqual(vc.gitHubRepositories[2].star, 0)
// インジケータが停止していることを確認
XCTAssertFalse(vc.indicator.isAnimating)
}
}
Coverageの確認
テストカバレッジは、ソフトウェアコードのどの部分がテストされているかを示す指標です。テストカバレッジが高いほど、コードの多くの部分がテストされており、潜在的なバグを見つけやすくなります。
おわりに
アクセス修飾子にprivateがついてると結合テストしづらくなるので今回は@IBOutlet private var nameLabel: UILabel!
のようにはしませんでした。
なにかいい方法がありましたら教えてください。
今回APIからエラーが返ってくる場合をざっくり省いているのでそちらも追記したいです。
参考