Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Swiftユニットテスト入門

 Swiftユニットテスト入門

勉強会での発表資料。
サンプルコードはこちら。
https://github.com/yopicpic/UnitTestSample

Fbcf7959082c90ba897732506b2909bb?s=128

Yoshiyuki Tanaka

January 26, 2018
Tweet

Other Decks in Programming

Transcript

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

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

  3. 自己紹介 All rights reserved visasQ inc. • 田中 慶之(Yoshiyuki Tanaka)

    • @yopicpic • 株式会社ビザスク(2017/4〜) • iOSエンジニア • Webマッチングサービス「ビザスク」のプロダクトオーナー
  4. ビザスクとは? 日本最大級のスポットコンサルプラットフォーム

  5. サービス形態

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

  7. アジェンダ All rights reserved visasQ inc. • Xcodeでユニットテスト ◦ テストの作成・実行

    • テストがしやすいアプリの設計 ◦ アーキテクチャ ◦ DI • 実践的なユニットテスト ◦ MVP ◦ APIKit ◦ Realm • まとめ
  8. All rights reserved visasQ inc. Xcodeでユニットテスト

  9. ユニットテストとは All rights reserved visasQ inc. • メソッド単位のテスト • 効果

    ◦ 不具合の発見 ◦ リファクタリングを支える ◦ 設計品質の向上
  10. XCTest All rights reserved visasQ inc. • iOSのテストフレームワーク ◦ Xcode5から導入

    • パフォーマンステスト、UIテストも可能 • テストカバレッジの計測も可能
  11. テスト対象:サンプルコード 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 } }
  12. テストターゲットの追加方法(新規作成) All rights reserved visasQ inc.

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

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

  15. テストコード 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) }
  16. テストの実行方法 All rights reserved visasQ inc. 1. GUIで全部のテストを実行 2.ショートカットで全部のテストを実行 command+U

    3.クラス・メソッドごとにテストを実行
  17. テスト結果 All rights reserved visasQ inc.

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

  19. Assertメソッド All rights reserved visasQ inc. • XCTAssert • XCTAssertEqual

    • XCTAssertNotEqual • XCTAssertTrue • XCTAssertFalse • XCTAssertNil • XCTAssertNotNil • XCTAssertThrowError • XCTAssertNoThrow • XCTAssertLessThan • XCTAssertLessThanOrEqual • XCTAssertGreatherThan • XCTAssertGreatherThanOrEqual
  20. All rights reserved visasQ inc. テストがしやすいアプリの設計

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

  22. FatViewController問題 All rights reserved visasQ inc. • ViewControllerの責務が肥大化していく ◦ ライフサイクルの管理

    ◦ 表示の管理 ◦ 表示ロジックの管理 ◦ 画面遷移の管理 ◦ 通信処理 ◦ DBの保存処理 メンテナンス性の低下・テストが書きにくくなる
  23. iOSアプリのアーキテクチャ All rights reserved visasQ inc. • MVC ◦ 普通に作るとこれになる

    • MVP ◦ 表示処理と表示ロジックを分離できるのでオススメ • MVVM ◦ RxSwift等を導入するコストが高い • Clean Architecture ◦ コード量が多くなるのでアプリの規模次第 • VIPER ◦ コード量が多くなるのでアプリの規模次第
  24. ビザスクの例 All rights reserved visasQ inc. Presentation Domain Data View

    ViewModel Service Entity Repository DataStore Data MVVM + Clean Architecture ライフサイクルの管理 表示の管理 表示ロジックの管理 画面遷移の管理 通信処理 DBの保存処理 View以外はテスト対象
  25. Dependency Injection All rights reserved visasQ inc. • DI(依存性の注入)とは ◦

    依存関係を外部から注入するパターン ◦ テスト時に特定のオブジェクトをモックオブジェクトに差し替えること ができる ◦ イニシャライザ,プロパティ,メソッドで注入する方法がある ◦ Swinject, Cleanse,DIKitなどのライブラリがある
  26. 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() } }
  27. 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 } }
  28. All rights reserved visasQ inc. 実践的なユニットテスト

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

    reserved visasQ inc.
  30. よくあるアプリの表示フロー All rights reserved visasQ inc. 表示開始 キャッシュの 有無 読み込みの

    成否 有 否 無 成 読み込み中 読み込み失敗 読み込み完了 キャッシュ表示
  31. よくあるアプリの実装方針 • アーキテクチャ ◦ MVP • API通信(JSON) ◦ APIKit •

    JSONのオブジェクト化 ◦ Decodable • キャッシュ保存 ◦ Realm
  32. よくあるアプリの設計 All rights reserved visasQ inc. View Presenter Service Repository

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

  34. 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 } }
  35. All rights reserved visasQ inc. Presenter

  36. 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() } } } }
  37. Presenterのテスト準備(ServiceのMockの用意) All rights reserved visasQ inc. protocol UserServiceProtocol { func

    getProfile(_ handler: @escaping (Result<User, SessionTaskError>) -> Void) func getCacheProfile() -> User?) } class MockUserService: UserServiceProtocol { var profileResult: Result<User, SessionTaskError>! var cacheProfile: User? = nil func getProfile(_ handler: @escaping (Result<User, SessionTaskError>) -> Void) { handler(profileResult) } func getCacheProfile() -> User? { return profile } }
  38. 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 } }
  39. 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) } }
  40. All rights reserved visasQ inc. API Client

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

  42. 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 } }
  43. 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" } }
  44. OHHTTPStubsとは • https://github.com/AliSoftware/OHHTTPStubs • レスポンスをStubできるライブラリ • 実際に通信しなくても任意のリクエストに対して任意のレスポンス を返すことができる • エラーを返したり、遅延もできる

  45. 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) }
  46. All rights reserved visasQ inc. Repository

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

  48. 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) } } }
  49. 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) } }
  50. 参考: 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<User, SessionTaskError>) -> 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) } } } }
  51. All rights reserved visasQ inc. まとめ

  52. まとめ All rights reserved visasQ inc. • テストを書いてみるのは簡単 • テストの書きやすさを意識すると設計も綺麗になる

    • 書きやすいところから、書ける範囲で書き始めるのがオススメ
  53. 最後に All rights reserved visasQ inc. • 言語 ◦ Swift4

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