Slide 1

Slide 1 text

Swiftユニットテスト入門 株式会社ビザスク 田中 慶之

Slide 2

Slide 2 text

All rights reserved visasQ inc. 自己&サービス紹介

Slide 3

Slide 3 text

自己紹介 All rights reserved visasQ inc. ● 田中 慶之(Yoshiyuki Tanaka) ● @yopicpic ● 株式会社ビザスク(2017/4〜) ● iOSエンジニア ● Webマッチングサービス「ビザスク」のプロダクトオーナー

Slide 4

Slide 4 text

ビザスクとは? 日本最大級のスポットコンサルプラットフォーム

Slide 5

Slide 5 text

サービス形態

Slide 6

Slide 6 text

ビザスク for アドバイザー アドバイザーの利用に特化したiOSアプリ

Slide 7

Slide 7 text

アジェンダ All rights reserved visasQ inc. ● Xcodeでユニットテスト ○ テストの作成・実行 ● テストがしやすいアプリの設計 ○ アーキテクチャ ○ DI ● 実践的なユニットテスト ○ MVP ○ APIKit ○ Realm ● まとめ

Slide 8

Slide 8 text

All rights reserved visasQ inc. Xcodeでユニットテスト

Slide 9

Slide 9 text

ユニットテストとは All rights reserved visasQ inc. ● メソッド単位のテスト ● 効果 ○ 不具合の発見 ○ リファクタリングを支える ○ 設計品質の向上

Slide 10

Slide 10 text

XCTest All rights reserved visasQ inc. ● iOSのテストフレームワーク ○ Xcode5から導入 ● パフォーマンステスト、UIテストも可能 ● テストカバレッジの計測も可能

Slide 11

Slide 11 text

テスト対象:サンプルコード All rights reserved visasQ inc. class Calculation { // 引数を足して返す func sum(_ a: Int, _ b: Int) -> Int { return a + b } // 引数の平均を返す func avg(_ a: Int, _ b: Int) -> Float { return Float(a + b) / 2 } }

Slide 12

Slide 12 text

テストターゲットの追加方法(新規作成) All rights reserved visasQ inc.

Slide 13

Slide 13 text

テストファイル All rights reserved visasQ inc.

Slide 14

Slide 14 text

テストターゲットの追加方法(後から追加) All rights reserved visasQ inc.

Slide 15

Slide 15 text

テストコード All rights reserved visasQ inc. func testSum() { let target = Calculation() let result = target.sum(1, 2) XCTAssertEqual(result, 3) } func testAvg() { let target = Calculation() let result = target.avg(1, 2) XCTAssertEqual(result, 1.5) }

Slide 16

Slide 16 text

テストの実行方法 All rights reserved visasQ inc. 1. GUIで全部のテストを実行 2.ショートカットで全部のテストを実行 command+U 3.クラス・メソッドごとにテストを実行

Slide 17

Slide 17 text

テスト結果 All rights reserved visasQ inc.

Slide 18

Slide 18 text

テストを失敗した場合 All rights reserved visasQ inc.

Slide 19

Slide 19 text

Assertメソッド All rights reserved visasQ inc. ● XCTAssert ● XCTAssertEqual ● XCTAssertNotEqual ● XCTAssertTrue ● XCTAssertFalse ● XCTAssertNil ● XCTAssertNotNil ● XCTAssertThrowError ● XCTAssertNoThrow ● XCTAssertLessThan ● XCTAssertLessThanOrEqual ● XCTAssertGreatherThan ● XCTAssertGreatherThanOrEqual

Slide 20

Slide 20 text

All rights reserved visasQ inc. テストがしやすいアプリの設計

Slide 21

Slide 21 text

テストがしやすいとは? All rights reserved visasQ inc. 責務が明確・適切であること 依存関係が少ないこと

Slide 22

Slide 22 text

FatViewController問題 All rights reserved visasQ inc. ● ViewControllerの責務が肥大化していく ○ ライフサイクルの管理 ○ 表示の管理 ○ 表示ロジックの管理 ○ 画面遷移の管理 ○ 通信処理 ○ DBの保存処理 メンテナンス性の低下・テストが書きにくくなる

Slide 23

Slide 23 text

iOSアプリのアーキテクチャ All rights reserved visasQ inc. ● MVC ○ 普通に作るとこれになる ● MVP ○ 表示処理と表示ロジックを分離できるのでオススメ ● MVVM ○ RxSwift等を導入するコストが高い ● Clean Architecture ○ コード量が多くなるのでアプリの規模次第 ● VIPER ○ コード量が多くなるのでアプリの規模次第

Slide 24

Slide 24 text

ビザスクの例 All rights reserved visasQ inc. Presentation Domain Data View ViewModel Service Entity Repository DataStore Data MVVM + Clean Architecture ライフサイクルの管理 表示の管理 表示ロジックの管理 画面遷移の管理 通信処理 DBの保存処理 View以外はテスト対象

Slide 25

Slide 25 text

Dependency Injection All rights reserved visasQ inc. ● DI(依存性の注入)とは ○ 依存関係を外部から注入するパターン ○ テスト時に特定のオブジェクトをモックオブジェクトに差し替えること ができる ○ イニシャライザ,プロパティ,メソッドで注入する方法がある ○ Swinject, Cleanse,DIKitなどのライブラリがある

Slide 26

Slide 26 text

Dependency Injectionの例 All rights reserved visasQ inc. protocol UserServiceProtocol { func getUserName(by userID: String) -> String } class UserService: UserServiceProtocol { func getUserName(by userID: String) -> String { // APIやDBからユーザー名を取得する処理 } } // イニシャライザで注入 class UserPageViewModel { let userService: UserServiceProtocol init(userService: UserServiceProtocol ) { self.userService = userService // DIを使わない場合 // self.userService = UserService() } }

Slide 27

Slide 27 text

Dependency Injectionの例2 All rights reserved visasQ inc. // プロパティで注入 class UserPageViewModel { var userService: UserServiceProtocol? } // メソッドで注入 class UserPageViewModel { private var userService: UserServiceProtocol ? func inject(with userService: UserServiceProtocol ) { self.userService = userService } }

Slide 28

Slide 28 text

All rights reserved visasQ inc. 実践的なユニットテスト

Slide 29

Slide 29 text

● プロフィール画面 ○ サーバから取得したユーザー情報を 表示 ○ キャッシュがあればキャッシュを表示 よくあるアプリの画面 All rights reserved visasQ inc.

Slide 30

Slide 30 text

よくあるアプリの表示フロー All rights reserved visasQ inc. 表示開始 キャッシュの 有無 読み込みの 成否 有 否 無 成 読み込み中 読み込み失敗 読み込み完了 キャッシュ表示

Slide 31

Slide 31 text

よくあるアプリの実装方針 ● アーキテクチャ ○ MVP ● API通信(JSON) ○ APIKit ● JSONのオブジェクト化 ○ Decodable ● キャッシュ保存 ○ Realm

Slide 32

Slide 32 text

よくあるアプリの設計 All rights reserved visasQ inc. View Presenter Service Repository APIKitを使ってユー ザー情報(JSON)を取 得してRealm Object に変換 ユーザー情報 (Realm Object)を保存 Service経由でユー ザー情報を取得。 Viewの表示処理を呼 び出す Presenterの命令に従 い、表示を変更する API Client Data

Slide 33

Slide 33 text

All rights reserved visasQ inc. View

Slide 34

Slide 34 text

ViewのProtocolの実装 All rights reserved visasQ inc. protocol UserPageView: class { func showIcon(url: String?) func showName(name: String) func showIntroduction(introduction: String) func showErrorView() func showIndicator() func hideErrorView() func hideIndicator() } class UserPageViewController: UIViewController, UserPageView { @IBOutlet weak var name: UILabel! private var presenter: UserPagePresenter! override func viewDidload() { presenter = UserPagePresenter(view: self) presenter.show() } func showName(name: String) { self.name.text = name } }

Slide 35

Slide 35 text

All rights reserved visasQ inc. Presenter

Slide 36

Slide 36 text

Presenterの実装 All rights reserved visasQ inc. class UserPagePresenter { private weak var view: UserPageView? private let userService: UserServiceProtocol init(view: UserPageView, userService: UserServiceProtocol = UserService()) { self.view = view self.userService = userService } func show() { // キャッシュ読み込み処理は省略。通信で取得するパターン view?.showIndicator() userService.getProfile { [weak self] result in switch result { case .success(let user): self?.view?.hideIndicator() self?.view?.showIcon(url: user.imageURL) self?.view?.showName(name: user.name) self?.view?.showIntroduction(introduction: user.introduction) case .failure: self?.view?.hideIndicator() self?.view?.showErrorView() } } } }

Slide 37

Slide 37 text

Presenterのテスト準備(ServiceのMockの用意) All rights reserved visasQ inc. protocol UserServiceProtocol { func getProfile(_ handler: @escaping (Result) -> Void) func getCacheProfile() -> User?) } class MockUserService: UserServiceProtocol { var profileResult: Result! var cacheProfile: User? = nil func getProfile(_ handler: @escaping (Result) -> Void) { handler(profileResult) } func getCacheProfile() -> User? { return profile } }

Slide 38

Slide 38 text

Presenterのテスト準備(ViewのMockの用意) All rights reserved visasQ inc. class MockUserPageView: UserPageView { var iconURL: String? = nil var name = "" var introduction = "" var isErrorViewShown = false var isIndicatorShown = false var isErrorViewHidden = false var isIndicatorHidden = false func showIcon(url: String?) { iconURL = url } func showName(name: String) { self.name = name } func showIntroduction(introduction: String) { self.introduction = introduction } func showErrorView() { isErrorViewShown = true } func showIndicator() { isIndicatorShown = true } func hideErrorView() { isErrorViewHidden = true } func hideIndicator() { isIndicatorHidden = true } }

Slide 39

Slide 39 text

Presenterのテスト All rights reserved visasQ inc. class UserPagePresenterTests: XCTestCase { func test正常にプロフィール取得() { // セットアップ let mockUserPageView = MockUserPageView() let mockUserService = MockUserService() let user = User() user.name = "田中" user.imageURL = "http://dummy.co.jp/a.png" user.introduction = "こんにちは" mockUserService.profileResult = .success(user) let presenter = UserPagePresenter(view: mockUserPageView, userService: mockUserService) // 実行 presenter.show() // 評価 XCTAssertTrue(mockUserPageView.isIndicatorShown) XCTAssertEqual(mockUserPageView.name, "田中") XCTAssertEqual(mockUserPageView.iconURL, "http://dummy.co.jp/a.png") XCTAssertEqual(mockUserPageView.introduction, "こんにちは") XCTAssertTrue(mockUserPageView.isIndicatorHidden) } }

Slide 40

Slide 40 text

All rights reserved visasQ inc. API Client

Slide 41

Slide 41 text

APIKitとは ● https://github.com/ishkawa/APIKit ● Swiftで書かれたタイプセーフな軽量HttpClient ● レスポンスをResultで取得できて扱いやすい ● 1エンドポイントごとに1クラス(構造体)なのでわかりやすい

Slide 42

Slide 42 text

API Clientの実装 All rights reserved visasQ inc. struct UserAPI { struct GetMyProfile: BaseRequest { typealias Response = User var method: HTTPMethod { return .get } var path: String { return "/profile" } var parameters: Any? { return nil } // レスポンスをData型で返す必要がある // https://qiita.com/sgr-ksmt/items/e822a379d41462e05e0d var dataParser: DataParser { return DecodableDataParser () } func response(from object: Any, urlResponse: HTTPURLResponse ) throws -> Response { guard let data = object as? Data else { /* エラーを返す */ } return try JSONDecoder().decode(User.self, from: data) } } } class DecodableDataParser: DataParser { var contentType: String? { return "application/json" } func parse(data: Data) throws -> Any { return data } }

Slide 43

Slide 43 text

Userの実装 All rights reserved visasQ inc. @objcMembers class User: Object, Decodable { enum CodingKeys: String, CodingKey { case id case name = "display_name" case imageURL = "image_url" case introduction = "description" } @objc dynamic var id: Int = 0 dynamic var name = "" dynamic var imageURL: String? dynamic var introduction = "" override static func primaryKey() -> String? { return "id" } }

Slide 44

Slide 44 text

OHHTTPStubsとは ● https://github.com/AliSoftware/OHHTTPStubs ● レスポンスをStubできるライブラリ ● 実際に通信しなくても任意のリクエストに対して任意のレスポンス を返すことができる ● エラーを返したり、遅延もできる

Slide 45

Slide 45 text

API Clientのテスト All rights reserved visasQ inc. override func setUp() { _ = stub(condition: isPath("/profile")) { _ in let stubPath = OHPathForFile("profile.json", type(of: self)) return fixture(filePath: stubPath!, headers: nil) } } override func tearDown() { OHHTTPStubs.removeAllStubs() } func testプロフィール取得の成功() { let expectation = XCTestExpectation(description: "get my profile") Session.send(UserAPI.GetMyProfile()) { result in switch result { case .success(let response): XCTAssertEqual(response.name, "田中") XCTAssertEqual(response.introduction, "こんにちは") XCTAssertEqual(response.imageURL, "http://dummy.co.jp/a.png") expectation.fulfill() case .failure: XCTFail() } } wait(for: [expectation], timeout: 10.0) }

Slide 46

Slide 46 text

All rights reserved visasQ inc. Repository

Slide 47

Slide 47 text

Realmとは ● https://realm.io/docs/swift/latest ● モバイルデータベース ● CoreDataよりも扱いやすい ● 比較的サポートが厚い

Slide 48

Slide 48 text

Repositoryの実装 All rights reserved visasQ inc. protocol UserRepositoryProtocol { func getMyProfile() -> User? func createOrUpdateMyProfile( _ user: User) } class UserRepository: UserRepositoryProtocol { func getMyProfile() -> User? { return try! Realm().objects(User.self).first } func createOrUpdateMyProfile( _ user: User) { let realm = try! Realm() try! realm.write { realm.add(user, update: true) } } }

Slide 49

Slide 49 text

Repositoryのテスト All rights reserved visasQ inc. class UserRepositoryTests: XCTestCase { override func setUp() { Realm.Configuration.defaultConfiguration .inMemoryIdentifier = self.name } func tests保存と取得() { // セットアップ let user = User() user.id = 100 let repository = UserRepository() // 実行 repository.createOrUpdateMyProfile (user) // 結果 let result = repository. getMyProfile() XCTAssertEqual(result?.id, 100) } }

Slide 50

Slide 50 text

参考: Serviceの実装 All rights reserved visasQ inc. class UserService: UserServiceProtocol { private let userRepository: UserRepositoryProtocol init(userRepository: UserRepositoryProtocol = UserRepository()){ self.userRepository = userRepository } func getCacheProfile() -> User? { return userRepository.getMyProfile() } func getProfile(_ handler: @escaping (Result) -> Void) { let request = UserAPI.GetMyProfile() Session.shared.send(request) { [weak self] result in switch result { case .success(let user): self?.userRepository.createOrUpdateMyProfile(user) handler(Result.success(user)) case .failure(let error): handler(Result.failure(error) } } } }

Slide 51

Slide 51 text

All rights reserved visasQ inc. まとめ

Slide 52

Slide 52 text

まとめ All rights reserved visasQ inc. ● テストを書いてみるのは簡単 ● テストの書きやすさを意識すると設計も綺麗になる ● 書きやすいところから、書ける範囲で書き始めるのがオススメ

Slide 53

Slide 53 text

最後に All rights reserved visasQ inc. ● 言語 ○ Swift4 ● アーキテクチャ ○ MVVM ● ライブラリ ○ RxSwift, APIKit, Himotoki, Quck, etc... ● CI環境 ○ Fastlane, Bitrise, Slack, API Gatway, Lambda ● 今後やりたいこと ○ 通話機能、ビデオチャット機能、グローバル対応, etc... 2人目のiOSエンジニア募集中! Webエンジニア(Python)も募集中!