Help us understand the problem. What is going on with this article?

Swiftの汎用的なモジュール(Framework)開発

Swiftの汎用的なモジュール(Framework)開発

by falcon0328
1 / 4

1. はじめに

お疲れ様です。Yahoo! JAPAN2018新卒の瀬尾です。
これはYahoo! JAPAN 18 新卒 Advent Calendar 2018の8日目の記事です!
昨日はHiroakiYamashiro(はてな: @taichiya)さんの腕時計複数本付けの生活でした。

本稿は導入編と実践編とまとめ編の3つに分け、その中の各章に対して連番を振っています。右側目次も参考にしてください。
以下から導入編がスタートです。

=== 導入編 ===

1.1 筆者について

著者はYahoo! JAPANで、動画関係のFramework開発(iOS,Swift)を行っています。
より具体的に言うと、主に全社サービス共通で、iOSのネイティブアプリケーションから動画を参照し再生するためのプレイヤーを提供するFrameworkを開発しています。

1.2 今回の内容

今回は「iOSアプリケーションを細かいモジュール(Framework)単位に分割し、開発する術」を紹介します。

1.3 なぜ、この内容を紹介するのか

昨今のスマートフォンアプリケーション開発では、スマートフォン端末の進化に伴い多機能かつ高品質なものが求められるようになってきています。そのため、UI/UX部分の処理とビジネスロジックが両方とも巨大になりがちです。

そのうえiOSのアプリケーション開発では、UIのロジックが独自のライフサイクル(UIViewControllerのviewDidLoad,viewWillAppearなど)を持ち、この部分に巨大なビジネスロジックを書き、開発者本人以外、後で誰も読めないし改修できないようなソースコードを生みがちです(いわゆるFatViewController 1 の話ですね。ちなみに開発者本人でも読めないパターンもあります)。

そこで、今回はビジネスロジックやUIのロジックを適切に分解し、分解したロジックを管理しやすい形でまとめる技術として「iOSアプリケーションを細かいモジュール(Framework)単位に分割し、開発する術」を紹介しようと思い至りました。

1.4 対象者

  • iOSアプリケーション開発 初級者 ~ 中級者

1.5 今回のゴール

「iOSのアプリケーションを細かいモジュール(Framework)単位に分割し、開発する術」であるEmbed Frameworkの開発方法とテスト方法について理解でき、ある程度動くFrameworkが作れるようになることです。

1.5 本稿の進め方

説明する上で、今回は題材を決めて行います。
開発するアプリケーションの題材は「Youtube上の動画を検索するアプリ」。
API2にアクセスして、動画情報を取得するところまで行うものを想定しています。

つまり、本稿ではYoutubeのAPIにアクセスし、動画情報を検索するモジュール(iOSで言うところのEmbed Framework)を開発します。
また、開発したEmbed Frameworkのテストをすることを目標とします。
(テストは正常系/異常系を含む、カバレッジ 80% 以上が目標です)

加えて、今回は作ったモジュールが広く色々な場面で使えるよう、実装部分で外部のFrameworkなどは利用しません!

1.6 注意事項

  • 本稿の趣旨は”開発の仕方”であるため、以下などは取り扱いません
    • Xcodeのインストール方法/使い方
    • ProvisioningProfileの作成方法
    • iTunes Connectの使い方
    • YoutubeのAPI利用方法
    • watchOS,tvOSの開発/気をつけること

また、今回説明に利用するプログラム開発環境は以下のようになっています。

- macOS High Sierra Version 10.13.6(17G3025)
- Xcode Version 10.1(10B61)
- Swift 4.2
- iOS

2. キーワードの解説

本章では、ここまでの説明で出てきたキーワードについて説明し、次の章から始まる実践と解説をしていきます。

2.1 取得する動画情報とは

今回開発するFrameworkで取り扱う動画情報2とは、以下のような項目から構成されています。
- 基本情報
- チャンネルID: 文字列
- 動画のタイトル: 文字列
- 動画の説明: 文字列
- サムネイル
- URL
- 幅: 整数
- 高さ: 整数

2.2 Embed Frameworkとは

Embed Framework3とは、端的に言ってしまえばiOS用マイクロサービスアーキテクチャです。

サービス/機能単位でプログラム開発を行い、それを.frameworkファイル(複数のソースコードを1つのファイルとして扱える、iOSでいうモジュールやSDKのようなもの)として扱うための仕組みです。
(以下から、作成しているEmbed FrameowrkFrameworkと呼称します。)


=== 実践編 ===

ここからは、実践しながら解説していきたいと思います。

3. Xcodeのプロジェクト作成

まず、Frameworkの開発を行うにあたって以下の作業を行ったプロジェクトをご用意ください。
1. Xcodeのインストール/アカウント設定
2. プロジェクト作成
3. ビルド確認/シミュレーターでの起動確認

4. Emebed Frameworkの作成

次からは、本題のEmbed Framework開発を行っていきます。
準備編で用意したプロジェクトでの作業を行ってください。

4.1 XcodeFileNewTarget を選択

スクリーンショット 2018-12-02 15.00.08.png

4.2 Cocoa Touch Frameworkを選択

Choose a template for your new target画面を下側にスクロールさせ、Cocoa Touch Frameworkを選択します。
スクリーンショット 2018-12-02 15.07.30.png

4.3 各種情報の入力

特別な事情がなければProduct Name以外は、Xcodeのプロジェクト作成編で設定した項目以外は同様でいいと思います。
Include Unit Testsのチェックは外さないでください
スクリーンショット 2018-12-05 9.52.34.png

4.3.1 例題の場合

今回の例題では以下のように入力しました。
- Product Name: YoutubeSDK
- Organization Name: Yahoo! JAPAN
- Organization Identifier: jp.co.yahoo
(アカウントは個人のものです。また、AdventCalendarの関係上、Organaizationに会社名を入れていますが、実在するFrameworkなどではなく、あくまで今回説明用に準備したものになります。)

5. テスト/フレームワーク配信用の準備

実装する前にテストや配信の準備をすると、後でトラブルになりづらいです。
特に筆者が大切にしているのは、一度テストカバレッジがFramework単位で表示されるのを確認しておくことです。
今後、コードを追記したらすぐさまテストを用意、カバレッジが表示できて、達成度も分かりやすいと思います。

5.1 テストカバレッジ表示できることの確認

5.1.1 仮のソースコードを作成(Frameworkを右クリック → New File...Swift File

テストカバレッジを表示するためには、コードを用意しとく必要があります。
SwiftファイルをFramework上で用意し、仮でもいいのでクラス or 構造体 ~ イニシャライザまで作成してください
スクリーンショット 2018-12-02 16.17.34.png
スクリーンショット 2018-12-02 16.32.37.png

こんな感じの仮のSwiftファイルで大丈夫です。

import Foundation

class Youtube {
   init() {
   }
}

5.1.2 テストのオプションを表示する(左上のアプリ名 → Edit SchemeTest

スクリーンショット 2018-12-02 16.23.19.png
スクリーンショット 2018-12-02 16.31.44.png

5.1.3 テストカバレッジのオプションをチェックする(上側にあるOptionsタブ → Code Coverageをチェック)

スクリーンショット 2018-12-02 16.24.47.png

チェックが入った後はCloseボタンを押して閉じましょう。

5.1.4 テスト実行(Cmd + U)

5.1.5 Show the report NavigatorCoverage → Frameworkの名前が出ているか確認

スクリーンショット 2018-12-02 16.39.37.png

スクリーンショット 2018-12-02 16.40.39.png

表示されていて、カバレッジも確認できればOKです。

5.2 ライブラリ管理ツールに対応

今回はCarthage4に対応させることのみを行います。Cocoa Podsなどは他の記事や説明をご参照ください。
また、この記事ではCarthageでの検証は行いません。次回以降の記事で書かせていただきます。

5.2.1 項目4.1.1と同じ要領でManage Schemeを選択する

スクリーンショット 2018-12-03 17.27.20.png

5.2.2 FrameworkのSharedにチェックを入れる

スクリーンショット 2018-12-03 17.29.24.png

これで、githubなどに上げるとCarthageで対応できます。

6. 設計

6.1 クラス図の作成

ここからはプログラムの設計を行います。今回は項目1.5.1を参考にCodableプロトコルを利用することで、取得する動画情報のデータ構造を表現し、通信にはURLSessionを利用します。クラス図は以下のようになります。
YoutubeSDK.png

6.1.1 クラス図の説明

Youtubeクラスがユーザが操作するクラスとなります。YoutubeクラスからYoutubeのAPIにアクセスするなどを行います。そして、ユーザはYoutubeのAPIへのアクセス結果をYoutubeDelegateから取得することができます。

クラス図を見た人はお気づきだと思いますが、クラスによって色を分けています。これは、クラスのアクセスコントロールによって分けたものです。それぞれの色には以下のような意味を表しています。
- 薄い灰色: アクセスコントロールがinternal
- それ以外: アクセスコントロールがpublic

6.1.1.1 YoutubeAPIClient

APIClientでくくっているクラスは基本的に通信を行うクラスになります。

Youtubeクラスは、ユーザからの動画検索指示(videoSearchメソッドのコール)があった際には、YoutubeAPIClientを使ってデータ(Data型、JSON形式)を取得し、それをJSONDecoderYoutubeVideoInfoクラスに変換することで、ユーザが利用しやすい形で動画情報を提供します。

6.1.1.2 YoutubeVideoInfo

YoutubeVideoThumbnailがサムネイル情報を、YoutubeVideoItemがタイトルやチャンネルの情報をそれぞれ管理します。そして、これらのクラスを一括で管理しているのがYoutubeVideoInfoクラスとなります。

6.2 クラス図の説明に出てきた用語の補足

前節の説明中にアクセスコントロールというSwiftの言語的仕組み、URLSessionCodableなどのSwift独自クラスがそれぞれ登場しました。これまでiOSアプリケーションを作ったことがある人は使ったことがある人も多いと思いますが、これらの用語を補足説明します。

6.2.1 アクセスコントロールについて

SwiftにもJavaやC#のように、クラスや各変数にアクセスできる範囲/権利を示すレベルがあります。
アクセスコントロールの説明については、詳細は省きますが(詳しくは参照文献参照5)、

framework作成にあたって、特に重要なのはinternal(無記入でも可)とpublic、そしてopenの差です。

internal
- framework外からアクセス不可

public
- framework外からアクセス可能
- ただし、overrideは不可

open
- framework外からアクセスもoverrideも可能

ちなみに、public initがないと、framework外からインスタンスの生成もできません。

6.2.2 Codable

Codable6Swift4.2から用意されたプロトコルです。これを、実装した構造体やクラスはJSONEncoderJSONDecoderでJSONへのエンコード/デコードにそれぞれ対応させることができます。

今回の題材で利用するYoutubeのAPIに関する説明は、参考文献に公式リンクを置いています。
YoutubeのAPI説明を見ると各パラメータ名がキャメルケースじゃないなど、Swiftのソースコード上で読み難い部分が散見されます。

今回はenum CodingKeys: String, CodingKeyの仕組みを使ってこの問題を解決します。
(ちなみに、スネークケースがだめとかキャメルケースが良いとかという話ではありません。)

6.2.3 URLSession

URLSession7は、iOSで標準に用意されている通信プログラム群です。今回はコンフィグもいじらず、より高い汎用性を求めるためURLSessionの通信成否取得にURLSessionDataDelegateを採用します。

7. 実装

設計でも長々と説明してしまいました。ここまで決めれば一瞬で実装できますね。

新規ソースファイル作成は項目4.1.1を参考にしてください。

7.1 動画情報 部分(VideoInfo部分)

基本的にはここまで説明してきた技術を網羅するように実装しています。
ただし、Youtubeの動画検索APIでの結果に、snippet2という構造があったため、これの違い吸収を行うためにYoutubeVideoInfoYoutubeVideoItemの間に、YoutubeVideoItemInfoという構造体を挟んでいます。

また、各構造体のinit(from decoder: Decoder) throwsDecodingErrorを配列や辞書が空のときなどにthrowするように設定しました。

ちなみに、Codableの実装方法Tipsなどの詳細は参考リンクからご参照ください。

YoutubeVideoInfo.swift
import Foundation

public struct YoutubeVideoInfo: Codable {
    public let items: [YoutubeVideoItem]
    private let results: [YoutubeVideoItemInfo]

    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        results = try container.decode([YoutubeVideoItemInfo].self, forKey: .results)
        if results.isEmpty {
            throw DecodingError.dataCorruptedError(forKey: .results,
                                                   in: container,
                                                   debugDescription: "results is empty.")
        }
        var resultItems: [YoutubeVideoItem] = []
        for result in results {
            resultItems.append(result.snippet)
        }
        items = resultItems
    }

    private enum CodingKeys: String, CodingKey {
        case results = "items"
    }
}

fileprivate struct YoutubeVideoItemInfo: Codable {
    let snippet: YoutubeVideoItem

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        snippet = try container.decode(YoutubeVideoItem.self, forKey: .snippet)
    }

    private enum CodingKeys: String, CodingKey {
        case snippet = "snippet"
    }
}

YoutubeVideoItem.swift
import Foundation

public struct YoutubeVideoItem: Codable {
    public let channelId: String
    public let title: String
    public let description: String
    public let thumbnailes: [String: YoutubeThumbnail]

    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        channelId = try container.decode(String.self, forKey: .channelId)
        title = try container.decode(String.self, forKey: .title)
        description = try container.decode(String.self, forKey: .description)
        thumbnailes = try container.decode([String: YoutubeThumbnail].self, forKey: .thumbnailes)
        if channelId.isEmpty {
            throw DecodingError.dataCorruptedError(forKey: .channelId,
                                                   in: container,
                                                   debugDescription: "channelId is empty.")
        } else if title.isEmpty {
            throw DecodingError.dataCorruptedError(forKey: .title,
                                                   in: container,
                                                   debugDescription: "title is empty.")
        } else if thumbnailes.isEmpty {
            throw DecodingError.dataCorruptedError(forKey: .thumbnailes,
                                                   in: container,
                                                   debugDescription: "thumbnailes is empty.")
        }
    }

    private enum CodingKeys: String, CodingKey {
        case channelId = "channelId"
        case title = "title"
        case description = "description"
        case thumbnailes = "thumbnails"
    }
}

YoutubeVideoThumbnail.swift
import Foundation

public struct YoutubeThumbnail: Codable {
    public let url: URL
    public let width: Int?
    public let height: Int?

    private let urlStr: String

    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        do {
            width = try container.decode(Int?.self, forKey: .width)
        } catch {
            width = nil
        }
        do {
            height = try container.decode(Int?.self, forKey: .height)
        } catch {
            height = nil
        }
        urlStr = try container.decode(String.self, forKey: .urlStr)
        if let url = URL(string: urlStr) {
            self.url = url
        } else  {
            throw DecodingError.dataCorruptedError(forKey: .urlStr,
                                                   in: container,
                                                   debugDescription: "URL format is invalid.")
        }
    }

    private enum CodingKeys: String, CodingKey {
        case urlStr = "url"
        case width = "width"
        case height = "height"
    }
}

7.2 動画情報を取得する部分(ApiClient部分)

URLSessionを利用して動画情報を取得する部分はよりシンプルです。
このリンクから参照できるFetching Website Data into Memory(URLSessionでデータをウェブサイトからfetchしてくるサンプル【Apple公式】)の通りに実装し、実装したプログラムにmimeTypeの部分を列挙型の値で判定、通信成否をデリゲートで通知するように機能を追加して終わりです。

ただし、VideoInfo(6.1)との違いがあるとすれば、アクセスコントロールです。こちらは全てFramework利用側からは見える必要がないので、internal(今回は無記入)にしています。

また、デリゲートの実装は可読性を上げるために、extensionでくくりだしています。
今回は1デリゲートのみですが、デリゲートが増えてくるとこの部分は効いてくると思います。

YoutubeAPIClientMimeType.swift
enum YoutubeAPIClientMimeType: String {
    case json = "application/json"
}
YoutubeAPIClientDelegate.swift
protocol YoutubeAPIClientDelegate: class {
    func youtubeAPIResultSuccess(data: Data)
    func youtubeAPIResultFailure()
}
YoutubeAPIClinet.swift
class YoutubeAPIClinet: NSObject {
    private var session: URLSession?
    private var receivedData: Data
    private let mimeType: YoutubeAPIClientMimeType
    private weak var apiRequestDelegate: YoutubeAPIClientDelegate?

    init(mimeType: YoutubeAPIClientMimeType,
         urlSessionDataDelegate: URLSessionDataDelegate) {
        self.receivedData = Data()
        self.mimeType = mimeType
        super.init()
        self.session = URLSession(configuration: .default,
                                  delegate: self,
                                  delegateQueue: nil)
    }


    func request(urlRequest: URLRequest,
                 youtubeAPIClientDelegate: YoutubeAPIClientDelegate) {
        self.apiRequestDelegate = youtubeAPIClientDelegate
        session?.dataTask(with: urlRequest).resume()
    }
}

extension YoutubeAPIClinet: URLSessionDataDelegate {
    func urlSession(_ session: URLSession,
                    dataTask: URLSessionDataTask,
                    didReceive response: URLResponse,
                    completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
        guard let response = response as? HTTPURLResponse,
            (200...299).contains(response.statusCode),
            response.mimeType == self.mimeType.rawValue else {
                completionHandler(.cancel)
                return
        }
        completionHandler(.allow)
    }

    func urlSession(_ session: URLSession,
                    dataTask: URLSessionDataTask,
                    didReceive data: Data) {
        receivedData.append(data)
    }

    func urlSession(_ session: URLSession,
                    task: URLSessionTask,
                    didCompleteWithError error: Error?) {
        if error != nil {
            self.apiRequestDelegate?.youtubeAPIResultFailure()
        } else {
            self.apiRequestDelegate?.youtubeAPIResultSuccess(data: receivedData)
        }
        self.receivedData.removeAll()
    }

}

7.3 ユーザから直接利用する部分(Youtubeクラス、およびその関連クラス)

YoutubeクラスはYoutubeAPIClientクラスの通信結果をYoutubeVideoInfoクラスの形式にJSONDecoderでデコードします。

Youtubeクラスの内部的には、APIClientでの通信成否とデコードの成否がありますが、今回の実装では全て成功すればYoutubeDelegateyoutubeResultSuccessを、どれか一つでも失敗すればyoutubeResultFailureを呼ぶようにしました。
ここの引数にErrorを持たせることで、どの箇所でどんなエラーが起きたか分かるようになるので、興味がある人は試してみてください。

YoutubeDelegate.swift
public protocol YoutubeDelegate: class {
    func youtubeResultSuccess(videoInfo: YoutubeVideoInfo)
    func youtubeResultFailure()
}
Youtube.swift
public class Youtube {
    weak var delegate: YoutubeDelegate?
    private let apiClient = YoutubeAPIClient(mimeType: .json)
    private let jsonDecoder = JSONDecoder()

    private let apiVersion: String
    private let apiKey: String
    private let baseURL: URL

    public init(apiVersion: String, apiKey: String, baseURL: URL) {
        self.apiVersion = apiVersion
        self.apiKey = apiKey
        self.baseURL = baseURL
    }

    func videoSearch(keyword: String, delegate: YoutubeDelegate) {
        self.delegate = delegate
        if let url = URL(string: baseURL.absoluteString + "/part=snippet&q=\(keyword)&key=\(apiKey)") {
            var urlRequest = URLRequest(url: url)
            urlRequest.httpMethod = "GET"
            apiClient.request(urlRequest: urlRequest, youtubeAPIClientDelegate: self)
        } else {
            self.delegate?.youtubeResultFailure()
        }
    }
}

extension Youtube: YoutubeAPIClientDelegate {
    func youtubeAPIResultSuccess(data: Data) {
        do {
            let result = try jsonDecoder.decode(YoutubeVideoInfo.self, from: data)
            self.delegate?.youtubeResultSuccess(videoInfo: result)
        } catch {
            self.delegate?.youtubeResultFailure()
        }
    }

    func youtubeAPIResultFailure() {
        self.delegate?.youtubeResultFailure()
    }
}

これで実装は完了です。最後にテストを行い、デリゲートが正しく呼ばれるか、モデルクラスに正しく変換できるかを確認していきましょう。

8. テスト

ここからは、プログラムのテスト作業を行います。
テストのときも、通常の開発時と同じように定期的にテストを走らせて結果を確認しながら進めましょう。
(テストの走らせ方、カバレッジの確認は項目5.1.4 ~ 項目5.1.5を参照してください。)

8.1 テスト前下準備

項目5.1の段階でFramework名+Testsという名前が付いたテスト用Frameworkができていると思います。
(サンプルでは、YoutubeSDKTestsとなっています。)

今回はモデルクラスや通信結果の成否をテストで判断するために、ファイルから動画検索結果のデータが読み込めると、非常に楽です。
そこで、今回はテスト用Frameworkにファイルを簡単に読み込むユーティリティを追加します。このユーティリティにはSwiftextensionを利用します。

XCTestcase+Bundle.swift
import Foundation
import XCTest
extension XCTestCase {
    var testBundle: Bundle {
        get {
            return Bundle(for: type(of: self) as AnyClass)
        }
    }
}

8.2 テストについておさらい

テストには正常系(想定される動作パターン)と異常系(想定されない動作パターン、エラーなど)が存在し、これらを可能な限り網羅的にテストすることが重要です。
ちなみに、テストの網羅率を数値化したものをコードカバレッジ(ソースコード全体に対して、テストでどの程度アクセスして検証しているかを数値化したもの)といいます。

項目1.2で上げたカバレッジ80%とは、テストがソースコード全体の80%以上をカバーできているかを示します。

より詳細なテストについての知識は、技術書籍8などを参考にしてください。

8.3 異常系(エラーの起こる/起こりそうな箇所)の列挙

ここで、今回開発しているYoutubeの動画検索モジュールでは、どんなエラーが生じるかをいかに列挙してみます。

8.3.1 VideoInfo部分

  • 動画検索結果がない場合
  • 各項目のどれかが空のとき
    • チャンネルID
    • 動画のタイトル
    • 動画の説明
    • サムネイル(ちなみに、幅や高さは設定されてない場合もある)
      • URL
      • 幅(任意項目、あってもなくても正常に終了するか)
      • 高さ(任意項目、あってもなくても正常に終了するか)

8.3.2 ApiClient部分

  • HTTP以外の通信レスポンスが返ったとき
  • MimeTypeが指定したものと違う場所にアクセスしようとしたとき
  • ステータスコードが200番台以外が返ったとき

8.4.1 テスト用ファイルの用意

次に列挙した中でテスト検証用データをファイルなどで入れたほうが良さそうなものがあるか見てみます。
おそらく、VideoInfo部分はJSONのデータが必要なのでJSONファイルを用意すると良いと思われます。
今回は以下に該当するJSONファイルを用意します。

8.4.1.1 正常系

  • ただし、サムネイルの幅や高さがない項目が混ざったもの

8.4.1.2 異常系

  • 動画の検索結果がなし
  • 動画のタイトルのみ空
  • 動画の説明のみ空
  • サムネイル情報のみなし

8.5 テストコードの用意

ここまで終われば、テストコードも用意していけますね。
テストコードの作成は項目5.1.1を参考に、新規ファイルの選択画面を開き、Unit Test Case Classを選択して作成してください。

ちなみに、テストのときはカバレッジを挙げることを最初の目標にするとやる気になれやすく、おすすめです。

8.5.1 VideoInfo部分のテストコード用意

以下に表示しているのがCodable部分をテストするコードです。
setuptearDownは各テストメソッドの前後に動作するメソッドですので説明は割愛します(参考リンクにテストコードのライフサイクルなどのドキュメントを記載しています)。

@testableは、テストの際に外部Frameworkを利用するimport文につけられるAttribute9で、本来アクセスできないinternalなパラメータやメソッド(項目6.2参考)にアクセスできるようにするものです。
Swiftのテストにはほぼ間違いなく利用します。

また、Dataをインスタンス化しているのがファイルからJSONデータ(Data型)を生成している部分です。

ちなみに、項目をテストするメソッドはXCTAssert~10のメソッドです。基本的にはXCTAssertEqualXCTAssertNil、throwしているかを試すXCTAssertThrowなどを利用することが多いと思います。

YoutubeVideoInfoTests.swift
import XCTest
@testable import YoutubeSDK

class YoutubeVideoInfoTests: XCTestCase {
    let jsonDecoder = JSONDecoder()

    override func setUp() {
        // Put setup code here. This method is called before the invocation of each test method in the class.
    }

    override func tearDown() {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }

    func test_正常系_デコード成功() {
        let data = try! Data(contentsOf: self.testBundle.url(forResource: "search",
                                                             withExtension: "json")!)
        let videoInfo = try! jsonDecoder.decode(YoutubeVideoInfo.self, from: data)
        XCTAssertNotNil(videoInfo)
        XCTAssertEqual(videoInfo.items.count, 5)
        XCTAssertEqual(videoInfo.items.first!.channelId, "UCHWV69kSR5Tpe1FqOvzXo9g")
        XCTAssertEqual(videoInfo.items.first!.title, "YahooJAPANPR")
        XCTAssertEqual(videoInfo.items.first!.description, "Yahoo! JAPANの公式チャンネルです。")
        XCTAssertEqual(videoInfo.items.first!.thumbnailes.count, 3)
        XCTAssertNil(videoInfo.items.first!.thumbnailes["default"]?.width)
        XCTAssertNil(videoInfo.items.first!.thumbnailes["default"]?.height)
        XCTAssertEqual(videoInfo.items.last!.thumbnailes["default"]!.url.absoluteString, "https://i.ytimg.com/vi/E1-vAjO9wwc/default.jpg")
        XCTAssertEqual(videoInfo.items.last!.thumbnailes["default"]!.width, 120)
        XCTAssertEqual(videoInfo.items.last!.thumbnailes["default"]!.height, 90)
    }

    func test_異常系_thumbnailのURLがnil() {
        let data = try! Data(contentsOf: self.testBundle.url(forResource: "search_thumbnail_url_invalidate",
                                                             withExtension: "json")!)
        XCTAssertThrowsError(try jsonDecoder.decode(YoutubeVideoInfo.self, from: data))
    }

    func test_異常系_thumbnailが空() {
        let data = try! Data(contentsOf: self.testBundle.url(forResource: "search_no_thumbnail",
                                                             withExtension: "json")!)
        XCTAssertThrowsError(try jsonDecoder.decode(YoutubeVideoInfo.self, from: data))
    }

    func test_異常系_titleが空() {
        let data = try! Data(contentsOf: self.testBundle.url(forResource: "search_no_title",
                                                             withExtension: "json")!)
        XCTAssertThrowsError(try jsonDecoder.decode(YoutubeVideoInfo.self, from: data))
    }

    func test_異常系_channelIdが空() {
        let data = try! Data(contentsOf: self.testBundle.url(forResource: "search_no_channelId",
                                                             withExtension: "json")!)
        XCTAssertThrowsError(try jsonDecoder.decode(YoutubeVideoInfo.self, from: data))
    }

    func test_異常系_itemsが空() {
        let data = try! Data(contentsOf: self.testBundle.url(forResource: "search_no_items",
                                                             withExtension: "json")!)
        XCTAssertThrowsError(try jsonDecoder.decode(YoutubeVideoInfo.self, from: data))
    }

}

これは自論ですが、テストはforced unwrap(!)全角文字列の変数や関数を使ってもありだと思います。
意味が伝わりやすく、書きやすい状態を心がけ、しっかりテストすることに注力しましょう。

8.5.2 ApiClient部分のテストコード用意

ApiClientのような通信系のテストでは非同期メソッドのテストを必ず行う必要があります。
そこで、SwiftではXCTestExpectationwaitを使ってテストします。

YoutubeAPIClientTests.swift
import XCTest
@testable import YoutubeSDK

class YoutubeAPIClientTests: XCTestCase {
    var apiClient: YoutubeAPIClient?

    var urlSession: URLSession?
    let dataTask = URLSessionDataTask()
    let httpURLResponse = HTTPURLResponse(url: URL(string: "http://www.example.com")!,
                                          statusCode: 200,
                                          httpVersion: nil,
                                          headerFields: ["Content-Type":"application/json"])
    let 正常系_resultSuccess = XCTestExpectation(description: "test_正常系_resultSuccess")
    let 異常系_resultFailure = XCTestExpectation(description: "test_異常系_resultFailure")


    override func setUp() {
        // Put setup code here. This method is called before the invocation of each test method in the class.
        urlSession = URLSession(configuration: .default,
                                delegate: self,
                                delegateQueue: nil)
        apiClient = YoutubeAPIClient(mimeType: .json)
    }

    override func tearDown() {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
        urlSession?.finishTasksAndInvalidate()
    }

    func test_正常系_completionHandlerの判定が通る() {
        apiClient?.urlSession(urlSession!,
                             dataTask: dataTask,
                             didReceive: httpURLResponse!,
                             completionHandler: { responseDisposition in XCTAssertEqual(responseDisposition, .allow) })
    }

    func test_正常系_resultSuccess() {
        let data = try! Data(contentsOf: testBundle.url(forResource: "search", withExtension: "json")!)
        apiClient?.apiRequestDelegate = self
        apiClient?.urlSession(urlSession!, dataTask: dataTask, didReceive: data)
        apiClient?.urlSession(urlSession!, task: dataTask, didCompleteWithError: nil)
        wait(for: [正常系_resultSuccess], timeout: 2.0)
    }

    func test_異常系_HttpURLResponse以外() {
        let urlResponse = URLResponse()
        apiClient?.urlSession(urlSession!,
                             dataTask: dataTask,
                             didReceive: urlResponse,
                             completionHandler: { responseDisposition in XCTAssertEqual(responseDisposition, .cancel) })
    }

    func test_異常系_statusCode違い() {
        let httpURLResponse = HTTPURLResponse(url: URL(string: "http://www.example.com")!,
                                              statusCode: 300,
                                              httpVersion: nil,
                                              headerFields: ["Content-Type":"application/json"])
        apiClient?.urlSession(urlSession!,
                             dataTask: dataTask,
                             didReceive: httpURLResponse!,
                             completionHandler: { responseDisposition in XCTAssertEqual(responseDisposition, .cancel) })
    }

    func test_異常系_mimeType違い() {
        let httpURLResponse = HTTPURLResponse(url: URL(string: "http://www.example.com")!,
                                              statusCode: 200,
                                              httpVersion: nil,
                                              headerFields: ["Content-Type":"application/xml"])
        apiClient?.urlSession(urlSession!,
                             dataTask: dataTask,
                             didReceive: httpURLResponse!,
                             completionHandler: { responseDisposition in XCTAssertEqual(responseDisposition, .cancel) })
    }

    func test_異常系_resultFailure() {
        let urlRequest = URLRequest(url: URL(string: "http://www.example.com")!,
                                    cachePolicy: .useProtocolCachePolicy,
                                    timeoutInterval: 1.0)
        apiClient?.apiRequestDelegate = self

        apiClient?.request(urlRequest: urlRequest, youtubeAPIClientDelegate: self)
        wait(for: [異常系_resultFailure], timeout: 2.0)
    }
}

extension YoutubeAPIClientTests: YoutubeAPIClientDelegate {
    func youtubeAPIResultSuccess(data: Data){
        正常系_resultSuccess.fulfill()
    }

    func youtubeAPIResultFailure() {
        異常系_resultFailure.fulfill()
    }
}

extension YoutubeAPIClientTests: URLSessionDataDelegate {
    func urlSession(_ session: URLSession,
                    dataTask: URLSessionDataTask,
                    didReceive response: URLResponse,
                    completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
    }

    func urlSession(_ session: URLSession,
                    dataTask: URLSessionDataTask,
                    didReceive data: Data) {

    }

    func urlSession(_ session: URLSession,
                    task: URLSessionTask,
                    didCompleteWithError error: Error?) {
    }
}

通信の完全な再現は他の仕組みなしでは難しいですが、外部からテスト対象のデリゲートメソッドを呼んだりするのは可能なので、必要なパラメータをテストしたい形で自分で注入してXCTAssert~をかけるのがいいと思います。

もちろんOSSが使える環境なら、OHTTPStubs11などのURLSessionテスト用のライブラリもありです。

8.5.2.1 自分でパラメータを注入している例

  • URLSession.ResponseDisposition
  • HTTPURLResponse

8.5.3 ユーザから直接利用する部分(Youtubeクラス、およびその関連クラス)のテスト

項目8.5.1 ~ 項目8.5.2の内容をもとにテストを充実化させましょう。

YoutubeTests.swift
import XCTest
@testable import YoutubeSDK

class YoutubeTests: XCTestCase {
    private let youtube = Youtube(apiVersion: "V3",
                                  apiKey: "APIKEY",
                                  baseURL: URL(string: "https://www.example.com")!)
    var 正常系_youtubeAPIResultSuccess = XCTestExpectation(description: "test_正常系_youtubeAPIResultSuccess")
    var 正常系_youtubeResultSuccess = XCTestExpectation(description: "test_正常系_youtubeResultSuccess")
    var 異常系_JSONのデコード失敗 = XCTestExpectation(description: "test_異常系_JSONのデコード失敗")
    var 異常系_apiRequestFailure = XCTestExpectation(description: "test_異常系_apiRequestFailure")
    var 異常系_URL生成失敗 = XCTestExpectation(description: "test_異常系_URL生成失敗")

    override func setUp() {
        // Put setup code here. This method is called before the invocation of each test method in the class.
        正常系_youtubeResultSuccess = XCTestExpectation(description: "test_正常系_youtubeResultSuccess")
        異常系_apiRequestFailure = XCTestExpectation(description: "test_異常系_apiRequestFailure")
        異常系_URL生成失敗 = XCTestExpectation(description: "test_異常系_URL生成失敗")
        異常系_JSONのデコード失敗 = XCTestExpectation(description: "test_異常系_JSONのデコード失敗")
    }

    override func tearDown() {
        // Put teardown code here. This method is called after the invocation of each test method in the class.

    }

    func test_正常系_youtubeResultSuccess() {
        youtube.videoSearch(keyword: "Yahoo!%20JAPAN", delegate: self)
        youtube.youtubeAPIResultSuccess(data: try! Data(contentsOf: testBundle.url(forResource: "search",
                                                                                   withExtension: "json")!))
        wait(for: [正常系_youtubeResultSuccess], timeout: 2.0)
    }

    func test_異常系_JSONのデコード失敗() {
        youtube.delegate = self
        youtube.youtubeAPIResultSuccess(data: try! Data(contentsOf: testBundle.url(forResource: "search_no_items",
                                                                                   withExtension: "json")!))
        wait(for: [異常系_JSONのデコード失敗], timeout: 2.0)
    }

    func test_異常系_URL生成失敗() {
        youtube.videoSearch(keyword: "あああああ", delegate: self)
        wait(for: [異常系_URL生成失敗], timeout: 2.0)
    }

    func test_異常系_apiRequestFailure() {
        youtube.delegate = self
        youtube.youtubeAPIResultFailure()
        wait(for: [異常系_apiRequestFailure], timeout: 2.0)
    }
}

extension YoutubeTests: YoutubeDelegate {
    func youtubeResultSuccess(videoInfo: YoutubeVideoInfo) {
        正常系_youtubeResultSuccess.fulfill()
    }

    func youtubeResultFailure() {
        異常系_apiRequestFailure.fulfill()
        異常系_URL生成失敗.fulfill()
        異常系_JSONのデコード失敗.fulfill()
    }
}

テストカバレッジが80% 以上を超えていれば確認も完了です。お疲れ様でした。

=== まとめ ===

9. 終わりに

今回はEmbed Framework開発を一連の流れに沿って行ってみました。この紹介した知識を持っておくだけでもソースコードの見通しがずいぶん変わると思います。

アプリケーションの仕様によっては、複数の機能やモジュールを分けるニーズもあると思います。
その際は、複数のEmbed frameworkを作って対応してください。

また、今回はテストを行ってプログラムの品質を確認しましたが、この後本来なら結合テストが必要です。
(アプリからこのモジュールを呼びだし、実環境にアクセスさせる作業)
必要に応じて試してみてください。

あと、テストはよりよい方法があると思いますので、判明次第、新しい記事を書いて対応したいと思います。

勢いで割愛しながらだったので、読みにくかったと思いますが、長い文章を読んでいただき、ありがとうございました。

他の日に関するYahoo! JAPAN 18 新卒 Advent Calendar 2018の記事もぜひ見てみてください!

参考情報

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away