はじめに
この記事では、WWDC 2017でのセッション Engineering for Testabilityの内容をまとめます。
このセッションでは、テストがしやすいコードの書き方(前半で紹介)や、保守・スケールしやすいテストコードの書き方(後半で紹介)などが紹介されました。
また、テストコードの大切さについても話されています。
図はAppleが公開しているスライドから引用しました。
記事の内容に誤りなどがございましたら、気軽に、優しく、コメントしていただけると嬉しいです。
よろしければ後半もお読みください。
こちらにXcode9からの新しいテストまわりのAPIが紹介されたセッションもまとめているので、合わせてお読みください。
読むのがめんどくさい方へ
結構長いので、すべて読むのがめんどくさい方はこれだけでも覚えていってください。
Treat your test code with the same amount of care as your app code
アプリのコードと同じくらいテストコードに気を配ろう
Code reviews for test code, not code reviews with test code
テストコードと一緒にレビューするのではなく、テストコード自体をレビューしよう
つまり、
ではなく、
である。
まとめ
Testable app code 〜テストができるアプリコードを書く〜
Structure of a Unit Test
例えばsorted()
メソッドは以下のようにUnit Testすることができる。
Unit Testは 「入力を準備」→「テストされるコードの実行」→「出力を評価」 という構成になっている。
func testArraySorting() {
let input = [1, 7, 6, 3, 10]
let output = input.sorted()
XCTAssertEqual(output, [1, 3, 6, 7, 10])
}
Characteristic of Testable Code
テストができるコードの特徴は次の3つである。
- 入力を制御できる
- 出力に可視性がある
- 隠れた状態がない
Testability Techniques 〜テストができるアプリコードの書き方〜
ここでは、2つのテストしにくいコードをテストできるコードに変更していく例を紹介します。
1つ目では、プロトコルとパラメータ化を利用してテストできるコードにしていきます。
2つ目では、ロジックとその効果を分離することでテストできるコードにしていきます。
テストしにくいコード その1
「ドキュメントを選択し、閲覧するか編集するかを選んで開く」アプリを考える。
Openボタンを押した際のアクションが次のように実装されていたとする。
@IBAction func openTapped(_ sender: Any) {
let mode: String
switch segmentedControl.selectedSegmentIndex {
case 0: mode = "view"
case 1: mode = "edit"
default: fatalError("Impossible case")
}
let url = URL(string: "myappscheme://open?id=\(document.identifier)&mode=\(mode)")!
if UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
} else {
handleURLError()
}
}
そして上記のコードをテストするコードを下記のように考えると、// ???
の部分のコードで何をassert
すれば良いのかわからなくなってしまう。
func testOpensDocumentURLWhenButtonIsTapped() {
let controller = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "Preview") as! PreviewViewController
controller.loadViewIfNeeded()
controller.segmentedControl.selectedSegmentIndex = 1
controller.document = Document(identifier: "TheID")
controller.openTapped(controller.button)
// ???
}
コード その1の問題点
-
@IBAction
にすべての処理定義してしまっているため、テスト対象の入力がわからない- テストができるコードの特徴の1つ「入力を制御できる」に反する
-
segmentedControl.selectedSegmentIndex
を利用しているため、入力以外にも情報が必要- テストができるコードの特徴の1つ「隠れた情報がない」に反する
-
canOpenURL(_:)
など、返り値が予想しにくいメソッドを使用している- テストができるコードの特徴の1つ「隠れた情報がない」に反する
- ドキュメントを開く動作をどうテストしていいかわからない。
- テストができるコードの特徴の1つ「出力に可視性がある」に反する
コード その1の改善方法
まずはドキュメントを開くクラスを分離する。
Openボタンが押されたときのアクションをテストするのではなく、このクラスをUnit Testするようにすると、入力の制御が可能になる。
ついでに「閲覧」と「編集」の状態もenum
で管理するようにリファクタリングしておく。
class DocumentOpener {
enum OpenMode: String {
case view
case edit
}
func open(_ document: Document, mode: OpenMode) {
let modeString = mode.rawValue
let url = URL(string: "myappscheme://open?id=\(document.identifier)&mode=\(modeString)")!
if UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
} else {
handleURLError()
}
}
}
Xcode9から、アプリを複数起動してそれぞれにテストを実行する機能が登場することなどから、UIApplication.shared
への参照は極力減らしたほうがいいので、次のようにInitializerにUIApplication
を渡せるようにすると良い。
それに伴って、open(_:mode:)
メソッド内のUIApplication.shared
も修正する。
class DocumentOpener {
let application: UIApplication
init(application: UIApplication = UIApplication.shared) {
self.application = application
}
/* … */
func open(_ document: Document, mode: OpenMode) {
let modeString = mode.rawValue
let url = URL(string: "myappscheme://open?id=\(document.identifier)&mode=\(modeString)")!
if application.canOpenURL(url) {
application.open(url, options: [:], completionHandler: nil)
} else {
handleURLError()
}
}
}
次に、テストから返り値が予想しにくいcanOpenURL(_:)
などによる問題を解決するために、テスト用のモックを作成する。
そのために次のようにprotocol
を定義しておく。
canOpenURL(_:)
などはUIApplication
には元から実装されており、モックをこのプロトコルに準拠させれば、返り値がテストから予想できるcanOpenURL(_:)
が使用できるようになる。
protocol URLOpening {
func canOpenURL(_ url: URL) -> Bool
func open(_ url: URL, options: [String: Any], completionHandler: ((Bool) -> Void)?)
}
extension UIApplication: URLOpening {}
それに伴って、先ほどのDocumentOpener
のInitializerまわりもapplication: UIApplication
→urlOpener: URLOpener
と修正しておく。
class DocumentOpener {
let urlOpener: URLOpening
init(urlOpener: URLOpening = UIApplication.shared) {
self.urlOpener = urlOpener
}
/* … */
func open(_ document: Document, mode: OpenMode) {
let modeString = mode.rawValue
let url = URL(string: "myappscheme://open?id=\(document.identifier)&mode=\(modeString)")!
if urlOpener.canOpenURL(url) {
urlOpener.open(url, options: [:], completionHandler: nil)
} else {
handleURLError()
}
}
}
そして、上記のプロトコルを利用してテスト用のモックを次のように作成する。
class MockURLOpener: URLOpening {
var canOpen = false
var openedURL: URL?
func canOpenURL(_ url: URL) -> Bool {
return canOpen
}
func open(_ url: URL, options: [String: Any], completionHandler: ((Bool) -> Void)?) {
openedURL = url
}
}
これで準備完了です。
次のようにテストすることができるようになりました。
func testDocumentOpenerWhenItCanOpen() {
let urlOpener = MockURLOpener()
urlOpener.canOpen = true
let documentOpener = DocumentOpener(urlOpener: urlOpener)
documentOpener.open(Document(identifier: "TheID"), mode: .edit)
XCTAssertEqual(urlOpener.openedURL, URL(string: "myappscheme://open?id=TheID&mode=edit"))
}
テストしにくいコード その2
ディスク上のキャッシュデータを管理する次のようなクラスが実装されていたとする。
class OnDiskCache {
struct Item {
let path: String
let age: TimeInterval
let size: Int
}
var currentItems: Set<Item> { /* … */ }
/* … */
func cleanCache(maxSize: Int) throws {
let sortedItems = self.currentItems.sorted { $0.age < $1.age }
var cumulativeSize = 0
for item in sortedItems {
cumulativeSize += item.size
if cumulativeSize > maxSize {
try FileManager.default.removeItem(atPath: item.path)
}
}
}
}
コード その2の問題点
- 入力の1つと考えられる
currentItems
がディスク上のデータ- テストができるコードの特徴の1つ「入力を制御できる」に反する
- 出力がディスク上のデータ
- テストができるコードの特徴の1つ「出力に可視性がある」に反する
コード その2の改善方法
次のようにプロトコルを定義し、テスト対象とするstruct
をそれに準拠させる。
protocol CleanupPolicy {
func itemsToRemove(from items: Set<OnDiskCache.Item>) -> Set<OnDiskCache.Item>
}
struct MaxSizeCleanupPolicy: CleanupPolicy {
let maxSize: Int
func itemsToRemove(from items: Set<OnDiskCache.Item>) -> Set<OnDiskCache.Item> {
var itemsToRemove = Set<OnDiskCache.Item>()
var cumulativeSize = 0
let sortedItems = allItems.sorted { $0.age < $1.age }
for item in sortedItems {
cumulativeSize += item.size
if cumulativeSize > maxSize {
itemsToRemove.insert(item)
}
}
return itemsToRemove
}
}
上記のように実装すると、入力にあったディスク上のデータがitems
となり、出力がSet<OnDiskCache.Item>
となるので、次のようにテストすることができるようになります。
func testMaxSizeCleanupPolicy() {
let inputItems = Set([
OnDiskCache.Item(path: "/item1", age: 5, size: 7),
OnDiskCache.Item(path: "/item2", age: 3, size: 2),
OnDiskCache.Item(path: "/item3", age: 9, size: 9)
])
let outputItems = MaxSizeCleanupPolicy(maxSize: 10).itemsToRemove(from: inputItems)
XCTAssertEqual(outputItems, [OnDiskCache.Item(path: "/item3", age: 9, size: 9)])
}
さいごに、OnDiskCache
クラスのキャッシュ削除のためのメソッドを次のように修正しておくのを忘れずに。
class OnDiskCache {
/* … */
func cleanCache(policy: CleanupPolicy) throws {
let itemsToRemove = policy.itemsToRemove(from: self.currentItems)
for item in itemsToRemove {
try FileManager.default.removeItem(atPath: item.path)
}
}
}
後半へ続く
最後までお読みいただき、ありがとうございました!
後半もありますので、よろしければそちらもお読みください。
後半は「保守・スケールしやすいテストの書き方」です。