Takahiro Octopress Blog

-1から始める情弱プログラミング

QuickでSwiftコードのUnitテストをしよう!(2)

| Comments

はじめに

今回は久しぶりにQuickを使ったSwiftコードでのUnitテストについて見ていきたいと思います。
前回記事を書いてから実に1年以上経過しました。
当時、筆者はテスト駆動型での開発を業務で実行することがありませんでした。 最近は当たり前に単体テストを書かずにコードを書くことがありえないという開発環境になってきました。
しかし、それはWEBの世界に閉じており、iOSの世界では未だ、単体テストを書く工数が見合わないといった話が議論されていたりします。
言わんとすることはわからんでもないものの、WEBの世界でもテスト駆動型開発が広まるまではきっと同じような話をしていたのではないかと思ったりしています。
つまり、今後はiOSでも単体テストを書かないなんてありえないといった世界になるのでは?と期待しているのです。

そんな期待を抱きつつ、本記事を書いていきたいと思います。

テスト内容

今回テスト内容としてRealmに関わる処理を取り上げたいと思います。
狙いとしてはSpring BootでWEBアプリケーションを開発したときで言うところのRepositoryに関するテストといったイメージになります。

テスト対象処理

さて、具体的なテストの対象となる処理を書きます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
// ViewController.swift
import UIKit
import RealmSwift

class ViewController: UIViewController {
  <省略>

  // IDからデータを検索
  func searchEngineerById(_ id: Int) -> Engineer? {

    let realm = try! Realm()
    let engineers = realm.objects(Engineer.self).filter("id == \(id)")
    if engineers.count > 0 {
      return engineers[0]
    }
    return nil
  }

  // 名前からデータを検索
  func searchEngineerByName(_ name: String) -> Results<Engineer>? {
    let realm = try! Realm()
    let engineers = realm.objects(Engineer.self).filter("name == '\(name)'")
    if engineers.count > 0 {
      return engineers
    }
    return nil
  }

  // 保存しているデータ数を取得
  func countEngineer() -> Int {
    let realm = try! Realm()
    return realm.objects(Engineer.self).count
  }

  // 新規データ作成
  func createEngineer(name: String, level: Int, skills: [String]) {
    if searchEngineerByName(name) != nil {
      // 既に検索結果がある場合は処理を終了
      return
    }

    // 検索結果がない場合は処理を継続
    let skillList = List<Skill>()
    for skill in skills {
      let newSkill = Skill()
      newSkill.name = skill
      skillList.append(newSkill)
    }

    let engineer = Engineer()
    engineer.id = countEngineer()
    engineer.name = name
    engineer.level = level
    engineer.skills.append(objectsIn: skillList)
    let realm = try! Realm()

    try! realm.write {
      realm.create(Engineer.self, value: engineer, update: false)
    }
  }

  // IDを元にレベルを更新
  func updateEngineerLevelById(_ id: Int, level: Int) -> Bool {
    if let engineer = searchEngineerById(id) {
      // 検索結果がある場合は処理を継続
      let realm = try! Realm()
      try! realm.write {
        engineer.level = level
      }
      return true
    }

    // 検索結果がない場合は処理を終了
    return false
  }

  // IDを元にスキルを更新
  func updateEngineerSkillById(_ id: Int, skills: [String]) -> Bool {
    if let engineer = searchEngineerById(id) {
      // 検索結果がある場合は処理を継続
      let realm = try! Realm()
      try! realm.write {
        for skill in skills {
          let newSkill = Skill()
          newSkill.name = skill
          engineer.skills.append(newSkill)
        }
      }
      return true
    }

    // 検索結果がない場合は処理を終了
    return false
  }
}

Quickの導入方法

Swift3になっていることもあるので、念のためQuickの導入方法を書いておきたいと思います。
(Realmも使っていることに注意してください。)

CocoaPodsにて導入します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Podfile
use_frameworks!

target "QuickTestSample" do
  # Normal libraries
  pod 'RealmSwift'

  abstract_target 'Tests' do
    inherit! :search_paths
    target "QuickTestSampleTests"
    target "QuickTestSampleUITests"

    pod 'Quick'
    pod 'Nimble'
  end
end

post_install do |installer|
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      config.build_settings['SWIFT_VERSION'] = '3.0'
    end
  end
end

Quickでのテストコードの実装

早速、Quickでのテストコードを実装していきましょう。

Quickでテスト実行するための準備

Quickでテストを実行するためには、テスト用に用意されたファイルのクラスを変更する必要があります。

1
2
3
4
5
6
7
8
9
10
11
// QuickTestSampleTests
import XCTest
import Quick
import Nimble
@testable import QuickTestSample

class QuickTestSampleTests: QuickSpec {
  override func spec() {
    ...
  }
}

テスト用データの作成について

今回、Realm関連の処理を実装するにあたって悩んだのが、モック用のデータをどうするかという問題です。
今回取り上げている処理がかなりシンプルな処理であるため、manual mocking で作成するのも微妙です。

結果、下記のように実装しました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// QuickTestSampleTests
import XCTest
import Quick
import Nimble
import RealmSwift   // 追加
@testable import QuickTestSample

class QuickTestSampleTests: QuickSpec {

  override func spec() {

    describe("Realm Database") {
      // テスト用のRealmデータ保存ファイルを作成
      // 保存場所はdefault.realmと同じでファイル名のみtest.realmに変更
      var config = Realm.Configuration()
      config.fileURL = config.fileURL!.deletingLastPathComponent().appendingPathComponent("test.realm")
      Realm.Configuration.defaultConfiguration = config
      // 上記の設定情報を利用してRealmを扱う
      let realm = try! Realm(configuration: config)

      beforeEach {
        // テスト用にモックデータを追加
        let engineer = Engineer()
        engineer.name = "mock_name"
        engineer.level = 1
        let skill = Skill()
        skill.name = "mock_skill_name"
        engineer.skills.append(skill)

        try! realm.write {
          realm.create(Engineer.self, value: engineer, update: false)
        }

        expect(engineer).notTo(beNil())
      }

      <省略>

      afterEach {
        // テスト終了後にデータを全て削除
        try! realm.write {
          realm.deleteAll()
        }
      }
    }
  }
}

このように、最低限必要なモックデータをテスト用のRealmのデータベースに作成してみました。
Realmの使い方さえ間違えなければ、こちらでテストしたい独自処理には影響を与えないはずです。

上記のソースコードにコメントとして書いていますが、ポイントとしては下記の通りです。

  • 実際のアプリで利用するRealmファイルとは別のテスト用のRealmファイルを利用する
  • テスト実行前にテスト用のモックデータを作成する
  • テスト実行後はテスト用のモックデータを全て削除する

テストの記載箇所

さて、実際にテストを実装すると下記のようになります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// QuickTestSampleTests
<省略>

override func spec() {
  var subject: ViewController!
  describe("Realm Database") {
    beforeEach {
      <省略>
    }

    describe("searchEngineerById") {
      it("find realm data by id") {
        let engineer = subject.searchEngineerById(0)
        expect(engineer).notTo(beNil())
        expect(engineer?.name).to(equal("mock_name"))
      }
    }

    afterEach {
      <省略>
    }
  }
}

<省略>

ポイント次の通りです。

  • テストの内容はbeforeEachafterEachの間に書く
  • メソッドごとにテストを記載していることをわかりやすくするために、describeでメソッドごとにくくる
  • テストで確かめたい内容次第でnotTo / to / equal などを使い分ける

テストの内容

では、テスト内容の詳細を個別に見ていきたいと思います。

searchEngineerByIdのテスト

まずは、searchEngineerByIdのテストを書きます。
このメソッドは検索成功時にEngineer型のオブジェクトを返し、検索失敗時にはnilを返します。

そのため、このメソッドに対するテストとしては、

  • 検索成功:取得内容の整合性をチェック
  • 検索失敗:nilであることをチェック

を書くことになります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// QuickTestSampleTests
describe("searchEngineerById") {
  // 取得内容の整合性チェック
  it("find realm data by id") {
    let engineer = subject.searchEngineerById(0)
    expect(engineer).notTo(beNil())
    expect(engineer?.name).to(equal("mock_name"))
    expect(engineer?.skills[0].name).to(equal("mock_skill_name"))
  }

  // nilであることのチェック
  it("cannot find realm data by id") {
    let engineer = subject.searchEngineerById(1)
    expect(engineer).to(beNil())
  }
}
searchEngineerByNameのテスト

続いて、searchEngineerByNameのテストを書きます。
このメソッドは検索成功時にList<Engineer>型のオブジェクトを返し、検索失敗時にはnilを返します。

そのため、このメソッドに対するテストとしては、

  • 検索成功:取得内容の整合性およびカウント数のチェック
  • 検索失敗:nilであることをチェック

を書くことになります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
describe("searchEngineerByName") {
  // 取得内容の整合性およびカウント数のチェック
  it("find realm data by name") {
    let engineers = subject.searchEngineerByName("mock_name")
    expect(engineers).notTo(beNil())
    expect(engineers?.count).to(equal(1))
    expect(engineers?[0].name).to(equal("mock_name"))
    expect(engineers?[0].skills[0].name).to(equal("mock_skill_name"))
  }

  // nilであることのチェック
  it("cannot find realm data by name") {
    let engineers = subject.searchEngineerByName("mock_mistake_name")
    expect(engineers).to(beNil())
  }
}
countEngineerのテスト

次はcountEngineerのテストを書きます。
このメソッドは検索成功時にデータのレコード数を返却します。
検索失敗時の処理は独自実装していないため成功時のテストのみ書きます。

1
2
3
4
5
6
7
describe("countEngineer") {
  // カウント数のチェック
  it("count realm data") {
    let count = subject.countEngineer()
    expect(count).to(equal(1))
  }
}
createEngineerのテスト

今度はcreateEngineerのテストを書きます。
このメソッドは同名のEngineerオブジェクトが保存されている場合は、新規オブジェクトを作成しないということがポイントです。

そのため、このメソッドに対するテストとしては、

  • 新規Engineerオブジェクト作成が成功した場合:取得内容の整合性およびカウント数のチェック
  • 新規Engineerオブジェクト作成が失敗した場合:カウント数が変わっていないことをチェック

を書くことになります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
describe("createEngineer") {
  // カウント数が変わっていないことのチェック
  it("cannot create engineer") {
    subject.createEngineer(name: "mock_name", level: 1, skills: ["swift"])
    let engineers = subject.searchEngineerByName("mock_name")
    expect(engineers).notTo(beNil())
    expect(engineers?.count).to(equal(1))
  }

  // 取得内容の整合性およびカウント数のチェック
  it("create new engineer") {
    subject.createEngineer(name: "takahiro", level: 1, skills: ["swift", "spring boot", "react.js"])
    let engineers = subject.searchEngineerByName("takahiro")
    expect(engineers).notTo(beNil())
    expect(engineers?.count).to(equal(1))
    expect(engineers?[0].name).to(equal("takahiro"))
    expect(engineers?[0].skills.count).to(equal(3))
    expect(engineers?[0].skills[0].name).to(equal("swift"))
    expect(engineers?[0].skills[1].name).to(equal("spring boot"))
    expect(engineers?[0].skills[2].name).to(equal("react.js"))
  }
}
updateEngineerLevelByIdのテスト

updateEngineerLevelByIdのテストを書きます。
このメソッドは成功可否に応じてtrue / falseを返却します。

そのため、このメソッドに対するテストとしては、

  • 更新成功:trueであることをチェック
  • 更新失敗:falseであることをチェック

を書くことになります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
describe("updateEngineerLevelById") {
  // trueであることをチェック
  it("update level for realm data by id") {
    let updated = subject.updateEngineerLevelById(0, level: 2)
    let engineer = subject.searchEngineerById(0)
    expect(updated).to(beTrue())
    expect(engineer?.level).to(equal(2))
  }

  // falseであることをチェック
  it("cannot update level for realm data by id") {
    let updated = subject.updateEngineerLevelById(2, level: 2)
    expect(updated).to(beFalse())
  }
}
updateEngineerSkillByIdのテスト

updateEngineerSkillByIdのテストを書きます。
このメソッドは成功可否に応じてtrue / falseを返却します。

そのため、このメソッドに対するテストとしては、

  • 更新成功:trueであることをチェック
  • 更新失敗:falseであることをチェック

を書くことになります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
describe("updateEngineerSkillById") {
  // trueであることをチェック
  it("update skill for realm data by id") {
    let updated = subject.updateEngineerSkillById(0, skills: ["mock_skill_name_2"])
    let engineer = subject.searchEngineerById(0)
    expect(updated).to(beTrue())
    expect(engineer?.skills.count).to(equal(2))
    expect(engineer?.skills[0].name).to(equal("mock_skill_name"))
    expect(engineer?.skills[1].name).to(equal("mock_skill_name_2"))
  }

  // falseであることをチェック
  it("cannot update skill for realm data by id") {
    let updated = subject.updateEngineerSkillById(2, skills: ["mock_skill_name_2"])
    expect(updated).to(beFalse())
  }
}

Quickでのテストコードの書式

今回新たに出た書式についてまとめておきます。

notTo

対象と異なることを期待するときに利用します。

  • 書式例:expect(engineer).notTo(beNil())
  • 期待値:engineerオブジェクトがnilでない

to

対象と一致することを期待するときに利用します。

equalと組み合わせた場合
  • 書式例:expect(engineer?.name).to(equal("mock_name"))
  • 期待値:engineerオブジェクトのnameプロパティがmock_nameと一致すること
beTrue / beFalseと組み合わせた場合
  • 書式例:expect(updated).to(beTrue())
  • 期待値:updatedの値がtrueと一致すること

beNil / equal / beTrue / beFalse

それぞれ、

  • beNil():nilであること
  • equal():引数の値であること(数字や文字列などを引数に設定します)
  • beTrue():trueであること
  • beFalse():falseであること

を意味します。

まとめ

さて如何でしたでしょうか。
今回は最近の経験を元にiOSでテスト駆動型の開発をするなら…をイメージして書いてみました。
次はもっと複雑なパターンのテストを試しに書いてみても良いかなと思いつつ、本日はここまで。

参考:

Comments