SwiftUIでAPI(クロージャとasync/await)を利用するViewModelクラスをテストする


## アプリ
SwiftUIにて、任意のユーザー名のGitHubリポジトリを取得し、スターの数が50以上のものをリスト表示します

## SwiftUIのターゲット構成
GitHubリポジトリを取得するAPIメソッドをクロージャとasync/awaitの２つ用意しました
また、APIを利用するViewModelクラス（GitHubRepositoryViewModel）をXCTestによる単体テストします

| クラス | 説明 |
|:-|:-|
| GitHubRepository  | GitHubリポジトリのEntityクラス  |
| GithubAPIClient  | APIクライアント  |
| GitHubRepositoryViewModel  | リポジトリ一覧を取得するViewModelクラス  |
| GitHubRepositoriesView  | リポジトリ一覧を表示するViewクラス  |

## GitHubリポジトリのEntityクラス
```swift:GitHubRepository.swift
struct GitHubRepository: Codable {
    
    let id: Int
    let stargazers_count: Int
    let name: String
    
}
```

## APIクライアント
APIを叩いてデータを取得するメソッドをプロトコル定義しています
クロージャとasync/awaitの２つ用意

```swift: GithubAPIClient.swift
protocol GithubAPIClientProtocol {
    func fetchRepositories(user: String, completion: @escaping ([GitHubRepository]?, Error?) -> Void)
    func fetchRepositories(user: String) async throws -> [GitHubRepository]?
}

enum FetcherError: Error {
    case badURL
    case badCode
    case missingData
}

class GithubAPIClient: GithubAPIClientProtocol {
   
    /// クロージャ
    func fetchRepositories(user: String, completion: @escaping ([GitHubRepository]?, Error?) -> Void) {
        
        if let url = URL(string: "https://api.github.com/users/\(user)/repos") {
            let request = URLRequest(url: url)
            let task = URLSession.shared.dataTask(with: request) { data, response, error in
                
                if let error = error {
                    completion(nil, error)
                }else if (response as? HTTPURLResponse)?.statusCode != 200{
                    completion(nil, FetcherError.badCode)
                }else {
                    guard let data = data else{
                        completion(nil, FetcherError.missingData)
                        return
                    }
                    let repos = try! JSONDecoder().decode([GitHubRepository].self, from: data)
                    DispatchQueue.main.async {
                        completion(repos, nil)
                    }
                }
            }
            task.resume()
        }else {
            completion(nil, FetcherError.badURL)
        }
    }

    /// Swift Concurrencyのasync/await
    func fetchRepositories(user: String) async throws -> [GitHubRepository]? {
        
        if let url = URL(string: "https://api.github.com/users/\(user)/repos") {
            let (data, response) = try await URLSession.shared.data(from: url)
            guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw FetcherError.badCode }
            let repos = try JSONDecoder().decode([GitHubRepository].self, from: data)
            return repos
        }else {
            throw FetcherError.badURL
        }
    }

}
```

## リポジトリ一覧を取得するViewModelクラス
Viewのモディファイア（.taskと.refreshable）からこのViewModelクラスのloadメソッドを呼び出し、その後、リポジトリ一覧（スターの数が５０以上）を表示します
loadメソッドは、クロージャとasync/awaitの２つ用意
* Viewのモディファイア（.task）から、クロージャのloadメソッドを使用
* Viewのモディファイア（.refreshable）から、async/awaitのloadメソッドを使用

また、コンストラクタ`init(client: GithubAPIClientProtocol = GithubAPIClient())`経由でGithubAPIClientProtocolというインターフェースを渡すようにしています

```swift:GitHubRepositoryViewModel.swift

import Foundation
import SwiftUI


final class GitHubRepositoryViewModel: ObservableObject {
    
    @Published var majorRepositories: [GitHubRepository]?
    @Published var userName: String

    private let client: GithubAPIClientProtocol
    private var repos: [GitHubRepository]?
    
    var majorRepos: [GitHubRepository] {
        guard let repositories = self.repos else { return [] }
        return repositories.filter { repository in
            repository.stargazers_count >= 50
        }
    }
    
    func fetchMajorRepositories() {
        majorRepositories = majorRepos
    }

    init(client: GithubAPIClientProtocol = GithubAPIClient()) {
        self.client = client
        self.userName = ""
    }
    
    func load(user: String, completion: @escaping(Error?) -> Void) {
        self.userName = user
        self.client.fetchRepositories(user: self.userName) { repositories, error in
            if let error = error {
                completion(error)
            }
            self.repos = repositories
            completion(nil)
        }
    }
    
    func load(user: String) async throws {
        self.userName = user
        do {
            self.repos = try await self.client.fetchRepositories(user: self.userName)
        }catch {
            self.repos = []
            throw error
        }
    }
}

```

## リポジトリ一覧を表示するViewクラス
```swift:GitHubRepositoriesView.swift

import SwiftUI

struct GitHubRepositoriesView: View {
    
    @State private var showingAlert = false
    @State private var alertMessage = ""
    @ObservedObject var repositoryViewModel: GitHubRepositoryViewModel
    var countOfmajorRepositories = 1
    
    init(repositoryViewModel: GitHubRepositoryViewModel = GitHubRepositoryViewModel()) {
        self.repositoryViewModel = repositoryViewModel
    }

    var body: some View {
        List(self.repositoryViewModel.majorRepositories ?? [], id: \.name) { item in
            Text(item.name)
        }
        .navigationTitle(repositoryViewModel.userName)
        .task {
            /// クロージャ
            self.repositoryViewModel.load(user: "apple") {error in
                if error != nil {
                    showingAlert = true
                    self.alertMessage = String(describing: error)
                }
                self.repositoryViewModel.fetchMajorRepositories()
            }
        }
        .refreshable {
            /// Swift Concurrencyのasync/await
            do {
                try await self.repositoryViewModel.load(user: "google")
            } catch {
                showingAlert = true
                self.alertMessage = String(describing: error)
            }
            self.repositoryViewModel.fetchMajorRepositories()
        }
        .alert(isPresented: $showingAlert) {
            Alert(title: Text(alertMessage))
        }
    }
}


```

## XCTestによるViewModelクラスを単体テストする
1. テスト用にGithubAPIClientProtocolを実装したモック用APIクライアント（MockGithubAPIClientクラス）を作成します
1. テスト対象のViewModelクラスにコンストラクタ経由でモック用APIクライアントを渡し、モック用のインターフェースから任意のモック用リポジトリ（mockRepositories）が返却されるようにします

```swift:GitHubRepositoryViewModelTest.swift
import XCTest
@testable import TDD

class GitHubrepositoryViewModelTest: XCTestCase {
    
    class MockGithubAPIClient: GithubAPIClientProtocol {
        
        var mockRepositories: [GitHubRepository]
        
        init(repositories:[GitHubRepository]) {
            mockRepositories = repositories
        }
       
        func fetchRepositories(user: String, completion: @escaping ([GitHubRepository]?, Error?) -> Void) {
            completion(mockRepositories, nil)
        }
        
        func fetchRepositories(user: String) async throws -> [GitHubRepository]? {
            return mockRepositories
        }

    }

    var repositoryViewModel: GitHubRepositoryViewModel?

    override func setUpWithError() throws {
        // Put setup code here. This method is called before the invocation of each test method in the class.
        
        /// モック
        let mockRepositories: [GitHubRepository] = [
            GitHubRepository(id: 0, stargazers_count: 48, name: "aaa"),
            GitHubRepository(id: 1, stargazers_count: 49, name: "bbb"),
            GitHubRepository(id: 2, stargazers_count: 50, name: "ccc"),
            GitHubRepository(id: 3, stargazers_count: 51, name: "ddd"),
            GitHubRepository(id: 4, stargazers_count: 52, name: "eee"),
        ]
        
        let mockAPIClient = MockGithubAPIClient(repositories: mockRepositories)
        self.repositoryViewModel = GitHubRepositoryViewModel(client: mockAPIClient)

    }
    
    /// クロージャ
    func testFetchMajorRepositories_Closure() {
        
        self.repositoryViewModel?.load(user: "apple") {_ in
            self.repositoryViewModel?.fetchMajorRepositories()
            XCTAssertEqual(self.repositoryViewModel?.majorRepositories?.count, 3, "stargazers_countが５０以上が３つ存在する")
            XCTAssertEqual(self.repositoryViewModel?.majorRepositories?[0].name, "ccc", "stargazers_countが５０")
            XCTAssertEqual(self.repositoryViewModel?.majorRepositories?[1].name, "ddd", "stargazers_countが５１")
            XCTAssertEqual(self.repositoryViewModel?.majorRepositories?[2].name, "eee", "stargazers_countが５２")
        }
    }

    /// Swift Concurrencyのasync/await
    func testFetchMajorRepositories() async {
        
        do {
            try await self.repositoryViewModel?.load(user: "apple")
            self.repositoryViewModel?.fetchMajorRepositories()
            XCTAssertEqual(self.repositoryViewModel?.majorRepositories?.count, 3, "stargazers_countが５０以上が３つ存在する")
            XCTAssertEqual(self.repositoryViewModel?.majorRepositories?[0].name, "ccc", "stargazers_countが５０")
            XCTAssertEqual(self.repositoryViewModel?.majorRepositories?[1].name, "ddd", "stargazers_countが５１")
            XCTAssertEqual(self.repositoryViewModel?.majorRepositories?[2].name, "eee", "stargazers_countが５２")
        } catch {
            
        }
        
    }
}

```

## プレビュー画面
```swift:GitHubRepositoriesView.swift
struct GitHubRepositoriesView_Previews: PreviewProvider {
    
    class MockGithubAPIClient: GithubAPIClientProtocol {
        
        var mockRepositories: [GitHubRepository]
        
        init(repositories:[GitHubRepository]) {
            mockRepositories = repositories
        }
       
        func fetchRepositories(user: String, completion: @escaping ([GitHubRepository]?, Error?) -> Void) {
            completion(mockRepositories, nil)
        }
        
        func fetchRepositories(user: String) async throws -> [GitHubRepository]? {
            return mockRepositories
        }

    }

    static var previews: some View {
        
        /// モック
        let mockRepositories: [GitHubRepository] = [
            GitHubRepository(id: 0, stargazers_count: 48, name: "aaa"),
            GitHubRepository(id: 1, stargazers_count: 49, name: "bbb"),
            GitHubRepository(id: 2, stargazers_count: 50, name: "ccc"),
            GitHubRepository(id: 3, stargazers_count: 51, name: "ddd"),
            GitHubRepository(id: 4, stargazers_count: 52, name: "eee"),
        ]
        
        let mockAPIClient = MockGithubAPIClient(repositories: mockRepositories)
        let repositoryViewModel = GitHubRepositoryViewModel(client: mockAPIClient)

        GitHubRepositoriesView(repositoryViewModel: repositoryViewModel)
    }
}

```

## 参考
[iOSアプリ開発自動テストの教科書 ～XCTestによる単体テスト・UIテストから，CI/CD，デバッグ技術まで](https://booklive.jp/product/index/title_id/628026/vol_no/001)

## GitHub
[TDD](https://github.com/TakanobuSano/TDD)