アプリ
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クラスを単体テストする
- テスト用にGithubAPIClientProtocolを実装したモック用APIクライアント(MockGithubAPIClientクラス)を作成します
- テスト対象の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,デバッグ技術まで