はじめに
Combine
を使った通信まわりのテストコードの情報がまだ少ないように感じましたので、作ったサンプルアプリのコードを載せてみようと思い、書いてみました。
今回のアプリのアーキテクチャは、MVVMです。
この記事でわかること
-
Combine
でURLSession
を使ったリクエストを書いたときのテストコードの書き方 - MVVMで実装したときのテストコード
Modelのテストよりも、個人的にはViewModelのテストのほうが、Combine
特有の実装が必要だと思うので、ViewModelの方が重要かなと思います。
Model層のテスト
今回のModel層は、実際にAPIに対してリクエストするところが該当します。
コードには、すべてCombine
をimportしてください。
アプリのコード
protocol GithubAPIClientProtocl: AnyObject {
func searchRepositories(searchWord: String) -> AnyPublisher<GithubRepositryModel, Error>
}
final class GithubAPIClient: GithubAPIClientProtocl {
static let shared = GithubAPIClient()
private init() {}
func searchRepositories(searchWord: String) -> AnyPublisher<GithubRepositryModel, Error> {
let url = URL(string: "https://api.github.com/search/repositories?q=\(searchWord)&per_page=20")!
return URLSession.shared.dataTaskPublisher(for: url)
.map { $0.data }
.decode(type: GithubRepositryModel.self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
Combine
でURLSession
を使うときの一般的な書き方と同じだと思います。
GithubAPIClientProtocl
は、あとでViewModelのテストをする時に使いますが、一旦関係ありません。
アプリ側で使うデータモデルについては、下記のAPI仕様書をご参考にしてください。
https://docs.github.com/ja/rest/reference/search
テストコード
var cancellable = Set<AnyCancellable>()
func testGithubAPIClientSearch() throws {
let expectation = expectation(description: "testGithubAPIClientSearch")
GithubAPIClient.shared.searchRepositories(searchWord: "Swift")
.sink { completion in
switch completion {
case .finished:
break
case .failure(let e):
print(e.localizedDescription)
XCTFail()
}
} receiveValue: { list in
print(list)
XCTAssert(true)
expectation.fulfill()
}
.store(in: &cancellable)
wait(for: [expectation], timeout: 10)
}
Model層のテストの場合、普通にModelのリクエストを読んで、それを.sink
(購読)するだけなので、シンプルだと思います。
ViewModel層のテスト
アプリのコード
class SearchGithubRepositoriesViewModel: ObservableObject {
private let githubApiClient: GithubAPIClientProtocl
private var cancellables = Set<AnyCancellable>()
@Published var repositories = [GithubRepositryModel.Item]()
init(githubApiClient: GithubAPIClientProtocl = GithubAPIClient.shared) {
self.githubApiClient = githubApiClient
}
func searchButtonTapped(searchWord: String) {
githubApiClient
.searchRepositories(searchWord: searchWord)
.sink { completion in
switch completion {
case .finished:
break
case .failure(_):
break
}
} receiveValue: { model in
self.repositories = model.items
}
.store(in: &cancellables)
}
}
searchButtonTapped(searchWord: String)
が画面側の検索ボタンが押されたら呼び出されるそうていです。今回は、このメソッドに対するテストコードを書いてみました。
テストコード
class GitHubAPIClientMock: GithubAPIClientProtocl {
let expectedItems: [GithubRepositryModel.Item] = (1...2).map {
.init(id: $0, fullName: "\($0) FullName", htmlURL: "", stargazersCount: $0, forksCount: $0, watchersCount: $0, description: "Description \($0)")
}
func searchRepositories(searchWord: String) -> AnyPublisher<GithubRepositryModel, Error> {
Just(GithubRepositryModel(totalCount: 10, incompleteResults: false, items: expectedItems))
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
}
class SearchGithubRepositoriesTest: XCTestCase {
var cancellable = Set<AnyCancellable>()
func testGitHubSearchViewModel() {
let mockModel = GitHubAPIClientMock()
let viewModel = SearchGithubRepositoriesViewModel(githubApiClient: mockModel)
viewModel.searchButtonTapped(searchWord: "")
XCTAssertEqual(mockModel.expectedItems, viewModel.repositories)
}
}
まず、最初のModel側のコードに記載した、GithubAPIClientProtocl
を継承した、Mockを作ります。
これで、実際にAPIにリクエストしなくても、テストケースで使いたい仮データをコードで生成することができるようになります。
= 単体テスト
ポイントは、2つあります。
1つ目は、
Just
を使って瞬時に結果を返すように制御するところです。
このように書けば、APIから値が返されたことを仮定してテストすることができます。
Just
について
2つ目は、
.setFailureType(to: Error.self)
のところです。
AnyPublisher<GithubRepositryModel, Error>
型で返すためには、何らかのエラーを想定させなければなりません。
.setFailureType(to: Error.self)
を書かないと、戻り値がAnyPublisher<GithubRepositryModel, Never>
になってしまい、コンパイルエラーになります。
setFailureType(to:)
について
今回は以上になります。