1
Help us understand the problem. What are the problem?

posted at

updated at

Organization

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

アプリ

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

SwiftUIのターゲット構成

GitHubリポジトリを取得するAPIメソッドをクロージャとasync/awaitの2つ用意しました
また、APIを利用するViewModelクラス(GitHubRepositoryViewModel)をXCTestによる単体テストします

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

GitHubリポジトリのEntityクラス

GitHubRepository.swift
struct GitHubRepository: Codable {
    
    let id: Int
    let stargazers_count: Int
    let name: String
    
}

APIクライアント

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

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メソッドを呼び出し、その後、リポジトリ一覧(スターの数が50以上)を表示します
loadメソッドは、クロージャとasync/awaitの2つ用意

  • Viewのモディファイア(.task)から、クロージャのloadメソッドを使用
  • Viewのモディファイア(.refreshable)から、async/awaitのloadメソッドを使用

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

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クラス

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クラス)を作成します
  2. テスト対象のViewModelクラスにコンストラクタ経由でモック用APIクライアントを渡し、モック用のインターフェースから任意のモック用リポジトリ(mockRepositories)が返却されるようにします
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が50以上が3つ存在する")
            XCTAssertEqual(self.repositoryViewModel?.majorRepositories?[0].name, "ccc", "stargazers_countが50")
            XCTAssertEqual(self.repositoryViewModel?.majorRepositories?[1].name, "ddd", "stargazers_countが51")
            XCTAssertEqual(self.repositoryViewModel?.majorRepositories?[2].name, "eee", "stargazers_countが52")
        }
    }

    /// 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が50以上が3つ存在する")
            XCTAssertEqual(self.repositoryViewModel?.majorRepositories?[0].name, "ccc", "stargazers_countが50")
            XCTAssertEqual(self.repositoryViewModel?.majorRepositories?[1].name, "ddd", "stargazers_countが51")
            XCTAssertEqual(self.repositoryViewModel?.majorRepositories?[2].name, "eee", "stargazers_countが52")
        } catch {
            
        }
        
    }
}

プレビュー画面

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,デバッグ技術まで

GitHub

TDD

Register as a new user and use Qiita more conveniently

  1. You can follow users and tags
  2. you can stock useful information
  3. You can make editorial suggestions for articles
What you can do with signing up
1
Help us understand the problem. What are the problem?