1. はじめに
お疲れ様です。Yahoo! JAPAN2018新卒の瀬尾です。
これはYahoo! JAPAN 18 新卒 Advent Calendar 2018の8日目の記事です!
昨日はHiroakiYamashiro(はてな: @taichiya)さんの[腕時計複数本付けの生活] (https://taichiya.hatenablog.com/entry/2018/12/03/012635)でした。
本稿は導入編と実践編とまとめ編の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 Framework
3とは、端的に言ってしまえばiOS用マイクロサービスアーキテクチャです。
サービス/機能単位でプログラム開発を行い、それを.framework
ファイル(複数のソースコードを1つのファイルとして扱える、iOSでいうモジュールやSDKのようなもの)として扱うための仕組みです。
(以下から、作成しているEmbed Frameowrk
をFramework
と呼称します。)
=== 実践編 ===
ここからは、実践しながら解説していきたいと思います。
3. Xcodeのプロジェクト作成
まず、Frameworkの開発を行うにあたって以下の作業を行ったプロジェクトをご用意ください。
-
Xcode
のインストール/アカウント設定 - プロジェクト作成
- ビルド確認/シミュレーターでの起動確認
4. Emebed Framework
の作成
次からは、本題のEmbed Framework
開発を行っていきます。
準備編
で用意したプロジェクトでの作業を行ってください。
4.1 Xcode
のFile
→ New
→ Target
を選択
4.2 Cocoa Touch Framework
を選択
Choose a template for your new target
画面を下側にスクロールさせ、Cocoa Touch Framework
を選択します。
4.3 各種情報の入力
特別な事情がなければProduct Name
以外は、Xcodeのプロジェクト作成編
で設定した項目以外は同様でいいと思います。
Include Unit Tests
のチェックは外さないでください
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 構造体 ~ イニシャライザまで作成してください
こんな感じの仮のSwiftファイルで大丈夫です。
import Foundation
class Youtube {
init() {
}
}
5.1.2 テストのオプションを表示する(左上のアプリ名 → Edit Scheme
→ Test
)
5.1.3 テストカバレッジのオプションをチェックする(上側にあるOptions
タブ → Code Coverage
をチェック)
チェックが入った後はClose
ボタンを押して閉じましょう。
5.1.4 テスト実行(Cmd + U)
5.1.5 Show the report Navigator
→ Coverage
→ Frameworkの名前が出ているか確認
表示されていて、カバレッジも確認できればOKです。
5.2 ライブラリ管理ツールに対応
今回はCarthage4に対応させることのみを行います。Cocoa Podsなどは他の記事や説明をご参照ください。
また、この記事ではCarthageでの検証は行いません。次回以降の記事で書かせていただきます。
5.2.1 項目4.1.1と同じ要領でManage Scheme
を選択する
5.2.2 FrameworkのShared
にチェックを入れる
これで、githubなどに上げるとCarthage
で対応できます。
6. 設計
6.1 クラス図の作成
ここからはプログラムの設計を行います。今回は項目1.5.1
を参考にCodable
プロトコルを利用することで、取得する動画情報のデータ構造を表現し、通信にはURLSession
を利用します。クラス図は以下のようになります。
6.1.1 クラス図の説明
Youtube
クラスがユーザが操作するクラスとなります。Youtube
クラスからYoutubeのAPIにアクセスするなどを行います。そして、ユーザはYoutubeのAPIへのアクセス結果をYoutubeDelegate
から取得することができます。
クラス図を見た人はお気づきだと思いますが、クラスによって色を分けています。これは、クラスのアクセスコントロール
によって分けたものです。それぞれの色には以下のような意味を表しています。
- 薄い灰色: アクセスコントロールが
internal
- それ以外: アクセスコントロールが
public
6.1.1.1 YoutubeAPIClient
APIClient
でくくっているクラスは基本的に通信を行うクラスになります。
Youtube
クラスは、ユーザからの動画検索指示(videoSearch
メソッドのコール)があった際には、YoutubeAPIClient
を使ってデータ(Data型
、JSON形式)を取得し、それをJSONDecoder
でYoutubeVideoInfo
クラスに変換することで、ユーザが利用しやすい形で動画情報を提供します。
6.1.1.2 YoutubeVideoInfo
YoutubeVideoThumbnail
がサムネイル情報を、YoutubeVideoItem
がタイトルやチャンネルの情報をそれぞれ管理します。そして、これらのクラスを一括で管理しているのがYoutubeVideoInfo
クラスとなります。
6.2 クラス図の説明に出てきた用語の補足
前節の説明中にアクセスコントロール
というSwiftの言語的仕組み、URLSession
やCodable
などの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
Codable
6はSwift4.2
から用意されたプロトコルです。これを、実装した構造体やクラスはJSONEncoder
やJSONDecoder
でJSONへのエンコード/デコードにそれぞれ対応させることができます。
今回の題材で利用するYoutubeのAPIに関する説明は、参考文献に公式リンクを置いています。
YoutubeのAPI説明を見ると各パラメータ名がキャメルケースじゃないなど、Swift
のソースコード上で読み難い部分が散見されます。
今回はenum CodingKeys: String, CodingKey
の仕組みを使ってこの問題を解決します。
(ちなみに、スネークケースがだめとかキャメルケースが良いとかという話ではありません。)
6.2.3 URLSession
URLSession
7は、iOSで標準に用意されている通信プログラム群です。今回はコンフィグもいじらず、より高い汎用性を求めるためURLSession
の通信成否取得にURLSessionDataDelegate
を採用します。
7. 実装
設計でも長々と説明してしまいました。ここまで決めれば一瞬で実装できますね。
新規ソースファイル作成は項目4.1.1
を参考にしてください。
7.1 動画情報 部分(VideoInfo部分)
基本的にはここまで説明してきた技術を網羅するように実装しています。
ただし、Youtubeの動画検索APIでの結果に、snippet
2という構造があったため、これの違い吸収を行うためにYoutubeVideoInfo
とYoutubeVideoItem
の間に、YoutubeVideoItemInfo
という構造体を挟んでいます。
また、各構造体のinit(from decoder: Decoder) throws
でDecodingError
を配列や辞書が空のときなどにthrowするように設定しました。
ちなみに、Codableの実装方法Tipsなどの詳細は参考リンクからご参照ください。
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"
}
}
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"
}
}
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デリゲートのみですが、デリゲートが増えてくるとこの部分は効いてくると思います。
enum YoutubeAPIClientMimeType: String {
case json = "application/json"
}
protocol YoutubeAPIClientDelegate: class {
func youtubeAPIResultSuccess(data: Data)
func youtubeAPIResultFailure()
}
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
での通信成否とデコードの成否がありますが、今回の実装では全て成功すればYoutubeDelegate
のyoutubeResultSuccess
を、どれか一つでも失敗すればyoutubeResultFailure
を呼ぶようにしました。
ここの引数にError
を持たせることで、どの箇所でどんなエラーが起きたか分かるようになるので、興味がある人は試してみてください。
public protocol YoutubeDelegate: class {
func youtubeResultSuccess(videoInfo: YoutubeVideoInfo)
func youtubeResultFailure()
}
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にファイルを簡単に読み込むユーティリティを追加します。このユーティリティにはSwift
のextension
を利用します。
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
部分をテストするコードです。
setup
やtearDown
は各テストメソッドの前後に動作するメソッドですので説明は割愛します(参考リンクにテストコードのライフサイクルなどのドキュメントを記載しています)。
@testable
は、テストの際に外部Frameworkを利用するimport
文につけられるAttribute
9で、本来アクセスできないinternal
なパラメータやメソッド(項目6.2
参考)にアクセスできるようにするものです。
Swift
のテストにはほぼ間違いなく利用します。
また、Data
をインスタンス化しているのがファイルからJSONデータ(Data型)を生成している部分です。
ちなみに、項目をテストするメソッドはXCTAssert~
10のメソッドです。基本的にはXCTAssertEqual
やXCTAssertNil
、throwしているかを試すXCTAssertThrow
などを利用することが多いと思います。
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
ではXCTestExpectation
とwait
を使ってテストします。
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が使える環境なら、OHTTPStubs
11などのURLSession
テスト用のライブラリもありです。
8.5.2.1 自分でパラメータを注入している例
URLSession.ResponseDisposition
HTTPURLResponse
8.5.3 ユーザから直接利用する部分(Youtubeクラス、およびその関連クラス)のテスト
項目8.5.1
~ 項目8.5.2
の内容をもとにテストを充実化させましょう。
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の記事もぜひ見てみてください!