【Swift】通信のUnitテスト書いてみる(stubとmock)

【Swift】stubとmockで通信のUnitテスト書いてみる

実行環境

Swift5.6.1
Xcode14.0
macOS12.6

stubとmock

stubとmockとは?

stubとmockは、安定したテストを実現するために使います。

サーバーを介したテストする際、下のような課題が出てきます。
・通信の成功ケース・失敗ケースなど、ケースごとのテストが難しい
・時間によってレスポンスの状態が変化することもあるため、あるときは成功したり失敗したり、不安定なテストになるかも

この課題の
1点目をstubで解決し、2点目をmockで解決します。
僕の考えだと下の図のようなイメージです。次でstubとmockに分けて詳しく紹介します。

stubとは?

一言で表すとすれば、“ダミーの呼び出し先の処理” です。

stubに関しては下のように書かれている記事がありました。

テスト用に用意した、まだ完成していない機能の代わりとなる部品であり
テスト対象から呼び出される部品の代わりとなるやつです。
ちょっと小難しい表現を使うと下位モジュールの代用品と言えます。

スタブとは?(「分かりそう」で「分からない」でも「分かった」気になれるIT用語辞典)

サーバーに依存なくテストを書くにはどうしたら良いのか?そこでstubの出番です。
stubはテスト検証をする際、テストの都合に良い値を返すテスト用のコンポーネントに差し替えて使用するものです。

[iOS]試して学ぶUnitTest[XCTest](Qiita)

要するに、
先程の図で示したように、
テスト環境で安定したテストが行えるように、差し替える部品
と言えるでしょう。

通信の成功ケース・失敗ケースに関するテストが難しいことが、サーバーを介したテストする際の課題でした。
stubを使うことで、
都合が良いように失敗ケースをテストしたり、
それぞれのケースを想定した安定したテストを実装できます。

mockとは?

一言で表すとすれば、“ダミーのデータ” と言えるかと思います。

mockに関しては、下のように書かれている記事がありました。

Mockとは、簡単に言うとクラスの動作をシミュレートするためのオブジェクトです。テスト対象クラスが呼び出している(=依存している)クラスをMockで差し替え、Mockの動作内容を定義することで、望むテスト条件を容易に作ることができます。

Mockでユニットテストを簡単にしよう!

自動化されたユニットテストにおいて、テスト対象オブジェクトが呼び出し先のオブジェクトと意図したとおりに協調動作するかどうかを検証するために、呼び出し相手に換えて使用するテスト用のオブジェクトのこと。

情報システム用語事典 モックオブジェクト(もっくおぶじぇくと)

要するに、テスト用に準備したサーバーのレスポンスデータといえるのではないでしょうか。

先程も述べたように、
サーバーによっては時間によってレスポンスの状態が変化することもあるため、あるときは成功したり失敗したり、不安定なテストになることがテストの課題でした。
mockを使うことで、
テストの際だけレスポンスを固定できるため、安定したテストを実装できます。

作るサンプルアプリ

デモ動画

詳細

前回の記事で、QiitaAPIから最新記事タイトル20件を取得し、List表示させるミニアプリを作りました。
今回はこのミニアプリを利用して、stubとmockを使った通信のUnitテストを実装してみます。
こちらのGitHubにソースコードを載せています。

前回のソースコードを取得したい方は、
下の記事内にGitHubのリポジトリをアップしているのでご覧下さい。

【SwiftUI / 通信入門】通信を行う基本的なミニアプリ作ってみる(QiitaAPIで)【SwiftUI / 通信入門】通信を使った基本的なミニアプリ作ってみる(QiitaAPIで)

サンプルアプリ実装

先程の図と重ねると、下のようなリポジトリ構成と命名で作成しました。
今回はSwiftUIのコードも混ざっていますが、UIKitでもほぼ同じコードになると思います。

通信のプロトコルを定義

このプロトコルを後ほど、
本番環境の処理である ArticleListClient.swift と
テスト環境の処理である MockArticlesAPIClient.swift に準拠させます。

protocol ArticleListAPIClientProtocol {
    func fetch(completion: @escaping ((Result<[Article], APIError>) -> Void))
}

本番環境で通信を行う処理を実装

import Foundation

class ArticleListAPIClient: ArticleListAPIClientProtocol {

    func fetch(completion: @escaping ((Result<[Article], APIError>) -> Void)) {
        guard let url = URL(string: "https://qiita.com/api/v2/items") else {
            return  completion(.failure(.invalidURL))
        }
        let request = URLRequest(url: url)
        URLSession.shared.dataTask(with: request) { (data, response, error ) in
            do {
                guard let data = data else { throw APIError.noneValue }
                guard let articleList = try? JSONDecoder().decode([Article].self, from: data) else {
                    throw APIError.noneValue
                }
                DispatchQueue.main.async {
                    completion(.success(articleList))
                }
            } catch {
                if error as? APIError == APIError.networkError {
                    completion(.failure(.networkError))
                } else if error as? APIError == APIError.noneValue {
                    completion(.failure(.noneValue))
                } else {
                    completion(.failure(.unknown))
                }
            }
        }.resume()
    }
}

テストで使うエラータイプを定義

このenum型は無くても良いのですが、
通信でエラーが発生した際に原因を特定しやすくするために実装しています。

enum APIError: Error {
    case noneValue
    case invalidURL
    case networkError
    case unknown

    var title: String {
        switch self {
        case .noneValue:
            return "値が空で取得されたエラー"
        case .invalidURL:
            return "無効なURLのエラー"
        case .networkError:
            return "ネットワークエラー"
        default:
            return "不明なエラー"
        }
    }

}

ArticleViewModelを定義

class ArticleViewModel: ObservableObject {
    @Published var articles: [Article] = [Article]()
    var apiError: APIError?
    private let articleListAPIClient: ArticleListAPIClientProtocol!

    // テスト用のイニシャライザ
    init(fetchArticlesAPIClient: ArticleListAPIClientProtocol) {
        articleListAPIClient = fetchArticlesAPIClient
        loadArticles()
    }

    init() {
        articleListAPIClient = ArticleListAPIClient()
        loadArticles()
    }

    func loadArticles() {
        articleListAPIClient.fetch(completion: { [weak self] resulte in
            switch resulte {
            case .success(let articleList):
                self?.articles = articleList
            case .failure(let error):
                self?.apiError = error
                print(error)
            }
        })
    }

}

stubとmockの作成

下のようにArticleListAPIClientProtoloに準拠させたクラスを作ると、
stubsを作りますか?と聞かれると思うので、自動で作ってもらいましょう。

class MockArticlesAPIClient: ArticleListAPIClientProtocol {

    var returnArticles: [Article]?
    lazy var fetchResult: Result<[Article], APIError> = .success(mockArticles)

    // mock
    let mockArticles: [Article] = [
        Article(title: "記事0"),
        Article(title: "記事1"),
        Article(title: "記事2"),
        Article(title: "記事3"),
        Article(title: "記事4"),
        Article(title: "記事5"),
        Article(title: "記事6"),
        Article(title: "記事7"),
        Article(title: "記事8"),
        Article(title: "記事9"),
        Article(title: "記事10"),
        Article(title: "記事11"),
        Article(title: "記事12"),
        Article(title: "記事13"),
        Article(title: "記事14"),
        Article(title: "記事15"),
        Article(title: "記事16"),
        Article(title: "記事17"),
        Article(title: "記事18"),
        Article(title: "記事19"),
    ]

    // stub
    func fetch(completion: @escaping ((Result<[Article], APIError>) -> Void)) {
        completion(fetchResult)
        switch fetchResult {
        case .success:
            returnArticles = mockArticles
        default:
            returnArticles = nil
        }
    }
}

テストコード

import XCTest
@testable import MiniApp113_SwiftUI_stubmock02

final class MiniApp113_SwiftUI_stubmock02Tests: XCTestCase {

    private var mockArticlesAPIClient: MockArticlesAPIClient!
    private var articleViewModel: ArticleViewModel!

    override func setUp() async throws {
        mockArticlesAPIClient = MockArticlesAPIClient()
        articleViewModel = ArticleViewModel(fetchArticlesAPIClient: mockArticlesAPIClient)
    }

    func test_記事が正常に取得できている() {
        XCTAssertNotNil(articleViewModel.articles)
        XCTAssertEqual(articleViewModel.articles[0].title, "記事0")
        XCTAssertEqual(articleViewModel.articles[17].title, "記事17")
    }

    func test_通信エラーだとnetworkErrorを返す() {
        mockArticlesAPIClient.fetchResult = .failure(APIError.networkError)
        articleViewModel = ArticleViewModel(fetchArticlesAPIClient: mockArticlesAPIClient)
        XCTAssertEqual(articleViewModel.apiError, APIError.networkError)
    }

    func test_値が空だとnoneValueを返す() {
        mockArticlesAPIClient.fetchResult = .failure(APIError.noneValue)
        articleViewModel = ArticleViewModel(fetchArticlesAPIClient: mockArticlesAPIClient)
        XCTAssertEqual(articleViewModel.apiError, APIError.noneValue)
    }

    func test_無効なURLだとinvalidURLを返す() {
        mockArticlesAPIClient.fetchResult = .failure(APIError.invalidURL)
        articleViewModel = ArticleViewModel(fetchArticlesAPIClient: mockArticlesAPIClient)
        XCTAssertEqual(articleViewModel.apiError, APIError.invalidURL)
    }

    func test_不明なエラーだとunknownを返す() {
        mockArticlesAPIClient.fetchResult = .failure(APIError.unknown)
        articleViewModel = ArticleViewModel(fetchArticlesAPIClient: mockArticlesAPIClient)
        XCTAssertEqual(articleViewModel.apiError, APIError.unknown)
    }

}

まとめ

本記事はstubとmockに関して初学者がまとめました。
出来る限り事実確認は行ったつもりですが、間違いがある可能性もあることをご了承下さい。間違いやアドバイス等は、本記事の末尾にあるコメント欄から是非お願いいたします…!

ということで、stubとmockを使って通信のUnitテスト書いてみました。
最後まで読んでいただきありがとうございました!

参考文献+オススメ記事

>> Swiftの型のMock化パターンまとめ(Qiita)
>> iOSアプリでMockを使ってUnitTestを書く(Qiita)
>> [swift]API通信を利用しているクラスをUnitTestしてみる[stubとmockの使い方](Qiita)
>> SwiftにおけるMockライブラリの活用/swift-mock-library
>> [iOS]試して学ぶUnitTest[XCTest](Qiita)

作業効率がグッと上がるPC道具

間違いなしのSwift書籍2冊



コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です