1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GitHub APIを使った単体、複合テスト実装ガイド

Last updated at Posted at 2024-07-20

はじめに

最近、個人開発アプリのテスト書いて見ようと思ったのですが、スタティックメソッド使っていてモックが使えなかったり、アクセス修飾子に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)")
            }
        }
    }
    
}

ログを確認します。
スクリーンショット 2024-07-20 17.34.03.png

これから、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)")
        }
    }
}

テストを実行して結果を確認します。

スクリーンショット 2024-07-20 23.37.36.png

わざとテストが失敗するコードなども書いてみて色々試してみてください。

結合テスト

ViewControllerとの結合テストを実装していきます。

ストーリーボードを設定

スクリーンショット 2024-07-25 8.33.42.png

TableViewのCellを設定

スクリーンショット 2024-07-25 8.35.04.png

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の確認

テストカバレッジは、ソフトウェアコードのどの部分がテストされているかを示す指標です。テストカバレッジが高いほど、コードの多くの部分がテストされており、潜在的なバグを見つけやすくなります。

Xcodeだと以下画面で確認できます。
スクリーンショット 2024-07-25 8.59.03.png

おわりに

アクセス修飾子にprivateがついてると結合テストしづらくなるので今回は@IBOutlet private var nameLabel: UILabel!のようにはしませんでした。
なにかいい方法がありましたら教えてください。

今回APIからエラーが返ってくる場合をざっくり省いているのでそちらも追記したいです。

参考

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?