SwiftによるiOS開発でテスト駆動開発 (TDD)を行うチュートリアル

概要

iOSアプリ開発でのUnitTestの取り組み方がわかりませんでしたので下記の記事を参考にしました。おそらく2019年はiOSの業界でもTestの重要性が浸透するのではないかなと思います。

というのもiOSのテストには

  • UnitTest
  • UI Test

の2種類のテストが存在してそれぞれの役割がちょっと曖昧な印象があります。

それに伴ってアプリ開発の現場でもUnitTestは存在するけれどもUI Testまでは対応できていないところもあれば、その逆パターンもありました。

これまで約5年間で数十種類ものアプリを運用・開発してきた結果、

  • UnitTestもUI Testもしているアプリ 10%
  • UnitTestのみのアプリ 20%
  • UITestのみのアプリ 20%
  • テストコードが存在しない 60%

これぐらいの割合で導入されていました。

さらにコードカバレッジにおいてはiOSアプリ開発者の数が圧倒的に足りないこともあって
テストコードの重要性を説いている会社であってもMax30%がいいところでした。

(コードカバレッジGithubやGitLabではCIを組み込んでいるとPRにコードカバレッジが表示されるのでその数字を参考にしています。)

なので、これからはテストコードもしっかり書けるiOSエンジニアの需要が高まると思って次の記事を翻訳してみました。今年はテストコードにも力を入れていきたいと思います。

Test Driven Development Tutorial for iOS: Getting Started

ちなみにiOSユニットテストのノウハウは去年の技術書展5で出版されていた下の本が非常に役にたちます。

さわって学べる!iOSテスト駆動開発

スクリーンショット 2019-01-13 15.02.44.png

導入

このテスト駆動開発チュートリアルでは、TDDの基礎と、それをiOS開発者として有効にする方法について学びます。

テスト駆動開発(TDD)はソフトウェアを書くための一般的な方法です。方法論としてはあなたがサポートコードを書く前にテストを書くことを指示します。これは後方に見えるかもしれませんが、いくつかの素晴らしい利点があります。

利点の1つは、開発者がアプリの動作をどのように期待しているかに関するテストのドキュメントを提供することです。 テストケースはコードと並行して更新されるため、このドキュメントは最新の状態を維持します。ドキュメントの作成や保守が苦手な開発者には最適です。

もう1つの利点は、TDDを使用して開発されたアプリのコードカバレッジが向上することです。テストとコードは密接に関係しており、テストされていないコードはあり得ません。

TDDはペアプログラミングに適しています。1人の開発者はテストを書き、もう1人の開発者はテストに合格するためのコードを書きます。これはより堅牢なコードと同様により速い開発サイクルにつながります。

最後にTDDを使用している開発者は将来のメインコードのリファクタリングを行う時に、時間を短縮できることです。これはTDDのよく知られている素晴らしいテストカバレッジの副産物です。

このテスト駆動開発チュートリアルでは、Numeroアプリ用のローマ数字変換を作成するためにTDDを使用します。 その過程で、あなたはTDDの流れに慣れ、TDDがどれほど強力になるのかについての洞察を得るでしょう。

はじめに

開始するには、このチュートリアルの資料をダウンロードすることから始めてください(ダウンロードリンク)。 アプリをビルドして実行します。 あなたはこのようなものを見るでしょう:

tdd_app_starter_1.png

アプリは数字とローマ数字を表示します。プレイヤーはローマ数字が数字の正しい表現であるかどうかを選ばなければなりません。 選択した後、ゲームは数字の次のセットを表示します。 ゲームは10回試行すると終了し、その時点でプレイヤーはゲームを再開できます。

ゲームをしてみてください。 あなたはすぐに "ABCD"が正しいコンバージョンを表すことを理解するでしょう。 本当の変換はまだ実装されていないからです。 このチュートリアルでは、その点に注意します。

Xcodeのプロジェクトを見てください。 これらは主なファイルです:

  • ViewController.swift: ゲームプレイを制御し、ゲームビューを表示します。
  • GameDoneViewController.swift: 最終スコアとゲームを再開するためのボタンを表示します。
  • Game.swift: ゲームエンジンを表します。
  • Converter.swift: ローマ数字変換器を表すモデル。 現在は空です。

ほとんどの場合あなたが次に作成することになるConverterそしてconverter testクラスでワークするでしょう。

Creating Your First Test and Functionality (初めてのテストと機能の作成)

典型的なTDDフローは、赤-緑-リファクタリングサイクルで説明できます。

tdd_red_green_refactor_cycle.png

それは次の要素で構成されています:

  1. :失敗したテストを書いています。
  2. :テストに合格するのに十分なコードを書いています。
  3. リファクタリング:コードの整理と最適化
  4. すべてのユースケースを網羅していることが納得できるまで、前の手順を繰り返します。

Creating a Unit Test Case Class (ユニットテストケースクラスの作成)

NumeroTestsの下に新しいユニットテストケースclassテンプレートファイルを作成し、ConverterTestsという名前を付けます。

tdd_add_unit_test_case_class.gif

ConverterTests.swiftを開き、testExample()およびtestPerformanceExample()を削除します。

一番上のimport文の直後に以下を追加します。

ConverterTests.swift
@testable import Numero

これにより、単体テストNumeroのクラスとメソッドにアクセスできるようになります。
ConverterTestsクラスの先頭に次のプロパティを追加します。

ConverterTests.swift
let converter = Converter()

これにより、テスト中に使用する新しいConverterオブジェクトが初期化されます。

Writing Your First Test(最初のテストを書く)

クラスの最後に、次の新しいテストメソッドを追加します。

ConverterTests.swift
func testConversionForOne() {
  let result = converter.convert(1)
}

テストはconvert(_ :)を呼び出し、結果を保存します。このメソッドはまだ定義されていないので、Xcodeで次のようなコンパイラエラーが発生します。

tdd_error_no_member_convert.png

Converter.swiftで、クラスに次のメソッドを追加します。

ConverterTests.swift
func convert(_ number: Int) -> String {
  return ""
}

これはコンパイラエラーを処理します。

コンパイラエラーが解決しない場合は、Numeroをインポートしている行をコメントアウトしてから、同じ行のコメントを外します。それでもうまくいかない場合は、メニューから[Product]▸[Build For]▸[Testing]の順に選択します。

ConverterTests.swiftで、testConversionForOne()の最後に以下を追加します。

ConverterTests.swift
XCTAssertEqual(result, "I", "Conversion for 1 is incorrect")

これはXCTAssertEqualを使って期待される変換結果をチェックします。

Command-Uを押してすべてのテストを実行します(現在のところテストは1つだけです)。シミュレータが起動するはずですが、Xcodeのテスト結果にもっと興味を持つべきです。

tdd_convert_1_fail.png

典型的なTDDサイクルの最初のステップは、失敗したテストを書くことです。 次に、このテストに合格するための作業を進めます。

Fixing Your First Failure(最初の失敗を直す)

Converter.swiftに戻り、convert(_ :)を次のように置き換えます。

Converter.swift
func convert(_ number: Int) -> String {
  return "I"
}

重要なのは、テストに合格するのに十分なコードを書くことです。この場合あなたがこれまでに持っている唯一のテストに対して期待される結果を返しています。

テストを実行するには(そしてテストが1つしかないので)ConverterTests.swiftのテストメソッド名の隣にある再生ボタンを押すことができます。

tdd_run_one_test-1.png

これでテストは成功します。

tdd_convert_1_pass.png

失敗したテストから始めてそれに合格するようにコードを修正するのは、誤検出を避けるためです。 テストが失敗したことがわからない場合は、正しいシナリオをテストしているとは言えません。

あなたの最初のTDDランを通過するために背中に身を包んでください!

tdd_pay_me.png

しかしあまり長く祝いすぎないでください。 1つの数字しか処理できないRoman Numeralコンバータのどこが良いのでしょうか。

Extending the Functionality(機能を拡張する)

Working on Test #2 (テスト#2に取り組む)

2の変換を試してみませんか。それは素晴らしい次のステップのようですね。

ConverterTests.swiftで、クラスの末尾に次の新しいテストを追加します。

ConverterTests.swift
func testConversionForTwo() {
  let result = converter.convert(2)
  XCTAssertEqual(result, "II", "Conversion for 2 is incorrect")
}

これは2がIIであると期待される結果をテストします。
新しいテストを実行してください。このシナリオを処理するためのコードを追加していないため、失敗するはずです。

tdd_convert_2_fail.png

Converter.swiftで、convert(_ :)を次のように置き換えます。

Converter.swift
func convert(_ number: Int) -> String {
  return String(repeating: "I", count: number)
} 

コードはIを返し、入力に基づいて何度も繰り返しました。 これはこれまでにテストした両方のケースを網羅しています。

すべてのテストを実行して、変更が回帰を引き起こさなかったことを確認します。 彼らはすべて合格する必要があります。

tdd_convert_2_pass.png

Working on Test #3 (テスト#3に取り組む)

すでに書いたコードに基づいて合格するはずなので、テスト3はスキップしてください。 少なくとも今のところ、4もスキップします。これは後で対処する特別な場合です。 それで5はどうですか?

ConverterTests.swiftで、クラスの末尾に次の新しいテストを追加します。

ConverterTests.swift
func testConversionForFive() {
  let result = converter.convert(5)
  XCTAssertEqual(result, "V", "Conversion for 5 is incorrect")
}

これは5はVと期待される結果をテストします。
新しいテストを実行してください。5が正しい結果ではないので失敗するでしょう。

tdd_convert_5_fail.png

Converter.swiftで、convert(_ :)を次のように置き換えます。

Converter.swift
func convert(_ number: Int) -> String {
  if number == 5 {
    return "V"
  } else {
    return String(repeating: "I", count: number)
  }
}

テストに合格するために最低限の作業をここで行っています。コードは5を別々にチェックします。それ以外の場合は、以前の実装に戻ります。

すべてのテストを実行してください。これらは合格するはずです。

tdd_convert_5_pass.png

Working on Test #4 (テスト#4に取り組む)

すぐにわかるように、テスト6は別の興味深い課題を提示します。

ConverterTests.swiftで、クラスの末尾に次の新しいテストを追加します。

ConverterTests.swift
func testConversionForSix() {
  let result = converter.convert(6)
  XCTAssertEqual(result, "VI", "Conversion for 6 is incorrect")
}

これは6がVIであると期待される結果をテストします。
新しいテストを実行してください。これはまだ処理前のシナリオなので失敗するでしょう。

tdd_convert_6_fail.png

Converter.swiftで、convert(_ :)を次のように置き換えます。

Converter.swift
func convert(_ number: Int) -> String {
  var result = "" // 1
  var localNumber = number // 2
  if localNumber >= 5 { // 3
    result += "V" // 4
    localNumber = localNumber - 5 // 5
  }
  result += String(repeating: "I", count: localNumber) // 6
  return result
}

コードは次のことを行います。

  1. 空の文字列を初期化します。
  2. 処理する入力のローカルコピーを作成します。
  3. input値が5以上かどうかを確認します。
  4. Vのローマ数字表現を末尾に追加します。
  5. ローカルinputを5減らします。
  6. 出力にIのローマ数字変換の繰り返し数を追加します。この数は、前に減らされたローカル入力です。

これまでに見てきたことに基づいて使用するのが妥当なアルゴリズムのようです。あまりに先を見越してテストしていない他のケースを処理するという誘惑を避けるのが最善です。

すべてのテストを実行してください。それらはすべて合格するはずです。

tdd_convert_6_pass.png

Working on Test #5 (テスト#5に取り組む)

何をテストするのか、いつテストするのかを選択することに賢明でなければなりません。 7と8をテストしても何も新しい結果は得られません。9は別の特別なケースです。そのため、今のところスキップすることができます。

tdd_your_move.png

これであなたは10になり、いくつかのナゲットを発見するはずです。

ConverterTests.swiftで、クラスの末尾に次の新しいテストを追加します。

ConverterTests.swift
func testConversionForTen() {
  let result = converter.convert(10)
  XCTAssertEqual(result, "X", "Conversion for 10 is incorrect")
}

これは新しいシンボルXが10であると期待される結果をテストします。
新しいテストを実行してください。 未処理のシナリオによる失敗が表示されます。

tdd_convert_10_fail.png

Converter.swiftに切り替えて、localNumberが宣言された直後に次のコードconvert(_:)を追加します。

Converter.swift
if localNumber >= 10 { // 1
  result += "X" // 2
  localNumber = localNumber - 10 // 3
}

これは、以前の5の処理方法と似ています。コードは次のことを行います。

  1. 入力が10以上かどうかを確認します。
  2. 出力結果にXのローマ数字表現を追加します。
  3. 5と1の処理の次のフェーズに実行を渡す前に、入力のローカルコピーから10を減らします。

すべてのテストを実行してください。これらはすべて合格するはずです。

tdd_convert_10_pass.png

Uncovering a Pattern

あなたのパターンを構築するとき、20を処理することは次に試すために良いと思います。

ConverterTests.swiftで、クラスの末尾に次の新しいテストを追加します。

ConverterTests.swift
func testConversionForTwenty() {
  let result = converter.convert(20)
  XCTAssertEqual(result, "XX", "Conversion for 20 is incorrect")
}

これは、20が期待される結果をテストします。これは、10のローマ数字表現であるXXを2回繰り返したものです。
新しいテストを実行してください。 失敗するでしょう:

tdd_convert_20_fail.png

実際の結果はXVIIIIIですが、これはあなたの期待とは一致しません。

条件文を置き換えます。

ConverterTests.swift
if localNumber >= 10 {

これを次のように:

ConverterTests.swift
while localNumber >= 10 {

この小さな変更は、10を処理するときに一度だけ入力するのではなく、入力をループ処理します。 これにより、10の数に基づいて繰り返しXが出力に追加されます。

すべてのテストを実行するとすべて成功します。

tdd_convert_20_pass.png

あなたは小さなパターンが出現しているのを見ますか? これは戻ってスキップした特殊なケースを処理するための良いタイミングです。 4から始めます。

Handling the Special Cases

ConverterTests.swiftで、クラスの末尾に次の新しいテストを追加します。

ConverterTests.swift
func testConversionForFour() {
  let result = converter.convert(4)
  XCTAssertEqual(result, "IV", "Conversion for 4 is incorrect")
}

これは4がIVであることが期待される結果をテストします。ローマ数字の土地では、4は5 -(引く) 1として表されます。

tdd_y_u_math.png

新しいテストを実行してください。あなたは失敗を見ることにあまりにも驚いてはいけません。未処理のシナリオです。

tdd_convert_4_fail.png

Converter.swiftで、繰り返しIを追加するステートメントの直前にconvert(_ :)に次のコードを追加します。

Converter.swift
if localNumber >= 4 {
  result += "IV"
  localNumber = localNumber - 4
}

このコードは、10と5が処理された後のlocal inputが4以上であるかどうかを確認します。次に、local inputを4ずつ減らす前に、4のローマ数字表現を追加します。

すべてのテストを実行してください。もう一度言うと、これらはすべて合格するでしょう。

tdd_convert_4_pass.png

また、9もスキップしました。今すぐ試してみましょう。

ConverterTests.swiftで、クラスの末尾に次の新しいテストを追加します。

ConverterTests.swift
func testConversionForNine() {
  let result = converter.convert(9)
  XCTAssertEqual(result, "IX", "Conversion for 9 is incorrect")
}

これは9がIXであると期待される結果をテストします。

新しいテストを実行してください。 VIVの結果は正しくありません。

tdd_convert_9_fail.png

これまで見てきたことすべてに基づいて、これをどのように修正できるかについて考えがありますか。

Converter.swiftに切り替えて、10と5を処理するコードの間にconvert(_ :)を追加します。

Converter.swift
if localNumber >= 9 {
  result += "IX"
  localNumber = localNumber - 9
}

これは4の処理方法と似ています。

すべてのテストを実行してください。繰り返しますが、これらはすべて合格です。

tdd_convert_9_pass.png

あなたがそれを見逃した場合のために、これは多くのユースケースを扱うときに出現したパターンです:

  1. 入力が数値以上かどうかを確認してください。
  2. その数字にローマ数字の表現を追加して結果を構築します。
  3. 数値を入力して入力を減らします。
  4. ループして、特定の数値について入力をもう一度確認してください。

TDDサイクルの次のステップに進む際には、これを頭の中に置いてください。

Refactoring

重複コードを認識し、それをクリーンアップすること(リファクタリングとも呼ばれます)は、TDDサイクルの重要なステップです。

前のセクションの終わりに、パターンが変換ロジックに現れました。 このパターンを完全に識別することになります。

Exposing the Duplicate Code

まだConverter.swiftで、変換方法を見てください。

Converter.swift
func convert(_ number: Int) -> String {
  var result = ""
  var localNumber = number
  while localNumber >= 10 {
    result += "X"
    localNumber = localNumber - 10
  }
  if localNumber >= 9 {
    result += "IX"
    localNumber = localNumber - 9
  }
  if localNumber >= 5 {
    result += "V"
    localNumber = localNumber - 5
  }
  if localNumber >= 4 {
    result += "IV"
    localNumber = localNumber - 4
  }
  result += String(repeating: "I", count: localNumber)
  return result
}

コードの重複を際立たせるには、convert(_ :)を修正し、ifの出現箇所をwhileで変更します。

回帰分析を導入していないことを確認するために、すべてのテストを実行してください。 これらはまだ合格するはずです。

tdd_convert_refactor.png

これがあなたのコードをきれいにしてTDD方法論でリファクタリングすることの美しさです。 既存の機能を壊していないという安心感を得ることができます

tdd_bug_no_fun.png

複製をフルに公開するためのもう1つの変更があります。 convert(_ :)を修正して置き換えます。

この箇所を

Converter.swift
result += String(repeating: "I", count: localNumber)

次のように置き換えます。

Converter.swift
while localNumber >= 1 {
  result += "I"
  localNumber = localNumber - 1
}

これら2つのコードは等価で、繰り返しのI文字列を返します。
すべてのテストを実行してください。 これらはすべて合格します。

tdd_convert_refactor (1).png

Optimizing Your Code

convert(_ :)のコードのリファクタリングを続け、10を処理するwhile文を次のように置き換えます。

Converter.swift
let numberSymbols: [(number: Int, symbol: String)] // 1
  = [(10, "X")] // 2

for item in numberSymbols { // 3
  while localNumber >= item.number { // 4
    result += item.symbol
    localNumber = localNumber - item.number
  }
}

コードを順番に見ていきましょう。
1. 数字とそれに対応するローマ数字記号を表すタプルの配列を作成します。
2. 10の値で配列を初期化します。
3. 配列をループさせます。
4. 数値の変換を処理するために発見したパターンで配列の各項目を実行します。

すべてのテストを実行してください。 これらは合格します:

tdd_convert_refactor (2).png

これで、リファクタリングを論理的な結論に導くことができるはずです。convert(_ :)を次のように置き換えます。

Converter.swift
func convert(_ number: Int) -> String {
  var localNumber = number
  var result = ""

  let numberSymbols: [(number: Int, symbol: String)] =
    [(10, "X"),
     (9, "IX"),
     (5, "V"),
     (4, "IV"),
     (1, "I")]

  for item in numberSymbols {
    while localNumber >= item.number {
      result += item.symbol
      localNumber = localNumber - item.number
    }
  }

  return result
}

これは、numberSymbolsをさらなる数字と記号で初期化します。 次に各番号の前のコードをプロセス10に追加した汎用コードで置き換えます。

すべてのテストを実行してください。 それらはすべて合格します。

tdd_convert_refactor (3).png

Handling Other Edge Cases

あなたのコンバータは長い道のりを歩んできましたが、あなたがカバーできるケースがもっとあります。これを実現するために必要なすべてのツールが揃いました。

ゼロの変換から始めます。 ただし、ゼロはローマ数字では表示されません。 つまり、これが渡されたときに例外をスローするか、単に空文字列を返すかを選択できます。

ConverterTests.swiftで、クラスの末尾に次の新しいテストを追加します。

ConverterTests.swift
func testConverstionForZero() {
  let result = converter.convert(0)
  XCTAssertEqual(result, "", "Conversion for 0 is incorrect")
}

これは期待される結果がゼロであるかどうかをテストし、空の文字列を期待します。
新しいテストを実行してください。これはコードをどのように記述したかによって機能します。

tdd_convert_0_pass.png

Numeroでサポートされている最後の3999の数字を試してみてください。

ConverterTests.swiftで、クラスの末尾に次の新しいテストを追加します。

ConverterTests.swift
func testConverstionFor3999() {
  let result = converter.convert(3999)
  XCTAssertEqual(result, "MMMCMXCIX", "Conversion for 3999 is incorrect")
}

これは3999の期待される結果をテストします。

新しいテストを実行してください。このようなエッジケースを処理するためのコードを追加していないので、失敗するはずです。

tdd_convert_3999_fail.png

Converter.swiftで、convert(_ :)を変更し、numberSymbolsの初期化を次のように変更します。

Converter.swift
let numberSymbols: [(number: Int, symbol: String)] =
  [(1000, "M"),
   (900, "CM"),
   (500, "D"),
   (400, "CD"),
   (100, "C"),
   (90, "XC"),
   (50, "L"),
   (40, "XL"),
   (10, "X"),
   (9, "IX"),
   (5, "V"),
   (4, "IV"),
   (1, "I")]

このコードは、40から1,000までの関連番号のマッピングを追加します。 これは3,999のテストもカバーしています。

すべてのテストを実行してください。それらはすべて合格します。

tdd_convert_3999_pass.png

TDDをフルに導入したのであれば、おそらく40と400のnumberSymbolsマッピングはテストに含まれていないので、追加することに抗議すると思われます。そのとおりです!TDDでは最初にテストを書かない限りコードを追加したくありません。このようにしてコードの適用範囲を広くしています。私はあなたのたっぷりの自由な時間にこれらの過ちを正すというエクササイズをあなたに任せるつもりです。

アプリの背後にあるアルゴリズムについては、特別に言及しているJim Weirich - Roman Numerals Kataを参照してください。

Use Your Converter (コンバーターを使用する)

おめでとうございます。今完全に機能するローマ数字変換器を持っています。ゲームで試してみるには、さらにいくつかの変更を加える必要があります。

Game.swiftで、generateAnswers(_:number :)を変更して、correctAnswerの割り当てを次のように置き換えます。

Game.swift
let correctAnswer = converter.convert(number)

これはハードコーディングされた値の代わりにあなたのコンバーターを使うことに切り替えます。

アプリをビルドして実行します。

tdd_app_converter_done.png

すべてのケースがカバーされていることを確認するために数ラウンドをやってみましょう。

Other Test Methodologies

TDDをもっと深く掘り下げるにつれて、他のテスト方法論について知っているかもしれません。例えば:

  • 受入テスト駆動開発(ATDD): TDDと似ていますが、顧客と開発者が共同で受け入れテストを書いています。 プロダクトマネージャは顧客の一例であり、受け入れテストは機能テストと呼ばれることもあります。 テストは一般にユーザーの観点から、インターフェースレベルで行われます。
  • 行動駆動型開発(BDD): TDDテストを含むテストの書き方を説明します。 BDDは実装の詳細よりもむしろ望ましい振舞いをテストすることを主張します。 これは、単体テストの構成方法に現れています。 iOSでは、given-when-thenフォーマットを使用できます。 この形式では、最初に必要な値を設定してからテストされるコードを実行してから最終的に結果を確認します。