概要
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で出版されていた下の本が非常に役にたちます。
導入
このテスト駆動開発チュートリアルでは、TDDの基礎と、それをiOS開発者として有効にする方法について学びます。
テスト駆動開発(TDD)はソフトウェアを書くための一般的な方法です。方法論としてはあなたがサポートコードを書く前にテストを書くことを指示します。これは後方に見えるかもしれませんが、いくつかの素晴らしい利点があります。
利点の1つは、開発者がアプリの動作をどのように期待しているかに関するテストのドキュメントを提供することです。 テストケースはコードと並行して更新されるため、このドキュメントは最新の状態を維持します。ドキュメントの作成や保守が苦手な開発者には最適です。
もう1つの利点は、TDDを使用して開発されたアプリのコードカバレッジが向上することです。テストとコードは密接に関係しており、テストされていないコードはあり得ません。
TDDはペアプログラミングに適しています。1人の開発者はテストを書き、もう1人の開発者はテストに合格するためのコードを書きます。これはより堅牢なコードと同様により速い開発サイクルにつながります。
最後にTDDを使用している開発者は将来のメインコードのリファクタリングを行う時に、時間を短縮できることです。これはTDDのよく知られている素晴らしいテストカバレッジの副産物です。
このテスト駆動開発のチュートリアルでは、Numeroアプリ用のローマ数字変換を作成するためにTDDを使用します。 その過程で、あなたはTDDの流れに慣れ、TDDがどれほど強力になるのかについての洞察を得るでしょう。
はじめに
開始するには、このチュートリアルの資料をダウンロードすることから始めてください(ダウンロードリンク)。 アプリをビルドして実行します。 あなたはこのようなものを見るでしょう:
アプリは数字とローマ数字を表示します。プレイヤーはローマ数字が数字の正しい表現であるかどうかを選ばなければなりません。 選択した後、ゲームは数字の次のセットを表示します。 ゲームは10回試行すると終了し、その時点でプレイヤーはゲームを再開できます。
ゲームをしてみてください。 あなたはすぐに "ABCD"が正しいコンバージョンを表すことを理解するでしょう。 本当の変換はまだ実装されていないからです。 このチュートリアルでは、その点に注意します。
Xcodeのプロジェクトを見てください。 これらは主なファイルです:
- ViewController.swift: ゲームプレイを制御し、ゲームビューを表示します。
- GameDoneViewController.swift: 最終スコアとゲームを再開するためのボタンを表示します。
- Game.swift: ゲームエンジンを表します。
- Converter.swift: ローマ数字変換器を表すモデル。 現在は空です。
ほとんどの場合あなたが次に作成することになるConverter
そしてconverter testクラスでワークするでしょう。
Creating Your First Test and Functionality (初めてのテストと機能の作成)
典型的なTDDフローは、赤-緑-リファクタリングサイクルで説明できます。
それは次の要素で構成されています:
- 赤:失敗したテストを書いています。
- 緑:テストに合格するのに十分なコードを書いています。
- リファクタリング:コードの整理と最適化
- すべてのユースケースを網羅していることが納得できるまで、前の手順を繰り返します。
Creating a Unit Test Case Class (ユニットテストケースクラスの作成)
NumeroTestsの下に新しいユニットテストケースclassテンプレートファイルを作成し、ConverterTestsという名前を付けます。
ConverterTests.swiftを開き、testExample()
およびtestPerformanceExample()
を削除します。
一番上のimport文の直後に以下を追加します。
@testable import Numero
これにより、単体テストはNumero
のクラスとメソッドにアクセスできるようになります。
ConverterTests
クラスの先頭に次のプロパティを追加します。
let converter = Converter()
これにより、テスト中に使用する新しいConverter
オブジェクトが初期化されます。
Writing Your First Test(最初のテストを書く)
クラスの最後に、次の新しいテストメソッドを追加します。
func testConversionForOne() {
let result = converter.convert(1)
}
テストはconvert(_ :)
を呼び出し、結果を保存します。このメソッドはまだ定義されていないので、Xcodeで次のようなコンパイラエラーが発生します。
Converter.swiftで、クラスに次のメソッドを追加します。
func convert(_ number: Int) -> String {
return ""
}
これはコンパイラエラーを処理します。
コンパイラエラーが解決しない場合は、
Numero
をインポートしている行をコメントアウトしてから、同じ行のコメントを外します。それでもうまくいかない場合は、メニューから[Product]▸[Build For]▸[Testing]の順に選択します。
ConverterTests.swiftで、testConversionForOne()
の最後に以下を追加します。
XCTAssertEqual(result, "I", "Conversion for 1 is incorrect")
これはXCTAssertEqual
を使って期待される変換結果をチェックします。
Command-Uを押してすべてのテストを実行します(現在のところテストは1つだけです)。シミュレータが起動するはずですが、Xcodeのテスト結果にもっと興味を持つべきです。
典型的なTDDサイクルの最初のステップは、失敗したテストを書くことです。 次に、このテストに合格するための作業を進めます。
Fixing Your First Failure(最初の失敗を直す)
Converter.swiftに戻り、convert(_ :)
を次のように置き換えます。
func convert(_ number: Int) -> String {
return "I"
}
重要なのは、テストに合格するのに十分なコードを書くことです。この場合あなたがこれまでに持っている唯一のテストに対して期待される結果を返しています。
テストを実行するには(そしてテストが1つしかないので)ConverterTests.swiftのテストメソッド名の隣にある再生ボタンを押すことができます。
これでテストは成功します。
失敗したテストから始めてそれに合格するようにコードを修正するのは、誤検出を避けるためです。 テストが失敗したことがわからない場合は、正しいシナリオをテストしているとは言えません。
あなたの最初のTDDランを通過するために背中に身を包んでください!
しかしあまり長く祝いすぎないでください。 1つの数字しか処理できないRoman Numeralコンバータのどこが良いのでしょうか。
Extending the Functionality(機能を拡張する)
Working on Test #2 (テスト#2に取り組む)
2の変換を試してみませんか。それは素晴らしい次のステップのようですね。
ConverterTests.swiftで、クラスの末尾に次の新しいテストを追加します。
func testConversionForTwo() {
let result = converter.convert(2)
XCTAssertEqual(result, "II", "Conversion for 2 is incorrect")
}
これは2がIIであると期待される結果をテストします。
新しいテストを実行してください。このシナリオを処理するためのコードを追加していないため、失敗するはずです。
Converter.swiftで、convert(_ :)
を次のように置き換えます。
func convert(_ number: Int) -> String {
return String(repeating: "I", count: number)
}
コードはI
を返し、入力に基づいて何度も繰り返しました。 これはこれまでにテストした両方のケースを網羅しています。
すべてのテストを実行して、変更が回帰を引き起こさなかったことを確認します。 彼らはすべて合格する必要があります。
Working on Test #3 (テスト#3に取り組む)
すでに書いたコードに基づいて合格するはずなので、テスト3はスキップしてください。 少なくとも今のところ、4もスキップします。これは後で対処する特別な場合です。 それで5はどうですか?
ConverterTests.swiftで、クラスの末尾に次の新しいテストを追加します。
func testConversionForFive() {
let result = converter.convert(5)
XCTAssertEqual(result, "V", "Conversion for 5 is incorrect")
}
これは5はVと期待される結果をテストします。
新しいテストを実行してください。5が正しい結果ではないので失敗するでしょう。
Converter.swiftで、convert(_ :)
を次のように置き換えます。
func convert(_ number: Int) -> String {
if number == 5 {
return "V"
} else {
return String(repeating: "I", count: number)
}
}
テストに合格するために最低限の作業をここで行っています。コードは5を別々にチェックします。それ以外の場合は、以前の実装に戻ります。
すべてのテストを実行してください。これらは合格するはずです。
Working on Test #4 (テスト#4に取り組む)
すぐにわかるように、テスト6は別の興味深い課題を提示します。
ConverterTests.swiftで、クラスの末尾に次の新しいテストを追加します。
func testConversionForSix() {
let result = converter.convert(6)
XCTAssertEqual(result, "VI", "Conversion for 6 is incorrect")
}
これは6がVIであると期待される結果をテストします。
新しいテストを実行してください。これはまだ処理前のシナリオなので失敗するでしょう。
Converter.swiftで、convert(_ :)
を次のように置き換えます。
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
}
コードは次のことを行います。
- 空の文字列を初期化します。
- 処理する入力のローカルコピーを作成します。
- input値が5以上かどうかを確認します。
- Vのローマ数字表現を末尾に追加します。
- ローカルinputを5減らします。
- 出力にIのローマ数字変換の繰り返し数を追加します。この数は、前に減らされたローカル入力です。
これまでに見てきたことに基づいて使用するのが妥当なアルゴリズムのようです。あまりに先を見越してテストしていない他のケースを処理するという誘惑を避けるのが最善です。
すべてのテストを実行してください。それらはすべて合格するはずです。
Working on Test #5 (テスト#5に取り組む)
何をテストするのか、いつテストするのかを選択することに賢明でなければなりません。 7と8をテストしても何も新しい結果は得られません。9は別の特別なケースです。そのため、今のところスキップすることができます。
これであなたは10になり、いくつかのナゲットを発見するはずです。
ConverterTests.swiftで、クラスの末尾に次の新しいテストを追加します。
func testConversionForTen() {
let result = converter.convert(10)
XCTAssertEqual(result, "X", "Conversion for 10 is incorrect")
}
これは新しいシンボルXが10であると期待される結果をテストします。
新しいテストを実行してください。 未処理のシナリオによる失敗が表示されます。
Converter.swiftに切り替えて、localNumberが宣言された直後に次のコードconvert(_:)
を追加します。
if localNumber >= 10 { // 1
result += "X" // 2
localNumber = localNumber - 10 // 3
}
これは、以前の5の処理方法と似ています。コードは次のことを行います。
- 入力が10以上かどうかを確認します。
- 出力結果にXのローマ数字表現を追加します。
- 5と1の処理の次のフェーズに実行を渡す前に、入力のローカルコピーから10を減らします。
すべてのテストを実行してください。これらはすべて合格するはずです。
Uncovering a Pattern
あなたのパターンを構築するとき、20を処理することは次に試すために良いと思います。
ConverterTests.swiftで、クラスの末尾に次の新しいテストを追加します。
func testConversionForTwenty() {
let result = converter.convert(20)
XCTAssertEqual(result, "XX", "Conversion for 20 is incorrect")
}
これは、20が期待される結果をテストします。これは、10のローマ数字表現であるXXを2回繰り返したものです。
新しいテストを実行してください。 失敗するでしょう:
実際の結果はXVIIIIIですが、これはあなたの期待とは一致しません。
条件文を置き換えます。
if localNumber >= 10 {
これを次のように:
while localNumber >= 10 {
この小さな変更は、10を処理するときに一度だけ入力するのではなく、入力をループ処理します。 これにより、10の数に基づいて繰り返しXが出力に追加されます。
すべてのテストを実行するとすべて成功します。
あなたは小さなパターンが出現しているのを見ますか? これは戻ってスキップした特殊なケースを処理するための良いタイミングです。 4から始めます。
Handling the Special Cases
ConverterTests.swiftで、クラスの末尾に次の新しいテストを追加します。
func testConversionForFour() {
let result = converter.convert(4)
XCTAssertEqual(result, "IV", "Conversion for 4 is incorrect")
}
これは4がIVであることが期待される結果をテストします。ローマ数字の土地では、4は5 -(引く) 1として表されます。
新しいテストを実行してください。あなたは失敗を見ることにあまりにも驚いてはいけません。未処理のシナリオです。
Converter.swiftで、繰り返しIを追加するステートメントの直前にconvert(_ :)
に次のコードを追加します。
if localNumber >= 4 {
result += "IV"
localNumber = localNumber - 4
}
このコードは、10と5が処理された後のlocal inputが4以上であるかどうかを確認します。次に、local inputを4ずつ減らす前に、4のローマ数字表現を追加します。
すべてのテストを実行してください。もう一度言うと、これらはすべて合格するでしょう。
また、9もスキップしました。今すぐ試してみましょう。
ConverterTests.swiftで、クラスの末尾に次の新しいテストを追加します。
func testConversionForNine() {
let result = converter.convert(9)
XCTAssertEqual(result, "IX", "Conversion for 9 is incorrect")
}
これは9がIXであると期待される結果をテストします。
新しいテストを実行してください。 VIVの結果は正しくありません。
これまで見てきたことすべてに基づいて、これをどのように修正できるかについて考えがありますか。
Converter.swiftに切り替えて、10と5を処理するコードの間にconvert(_ :)
を追加します。
if localNumber >= 9 {
result += "IX"
localNumber = localNumber - 9
}
これは4の処理方法と似ています。
すべてのテストを実行してください。繰り返しますが、これらはすべて合格です。
あなたがそれを見逃した場合のために、これは多くのユースケースを扱うときに出現したパターンです:
- 入力が数値以上かどうかを確認してください。
- その数字にローマ数字の表現を追加して結果を構築します。
- 数値を入力して入力を減らします。
- ループして、特定の数値について入力をもう一度確認してください。
TDDサイクルの次のステップに進む際には、これを頭の中に置いてください。
Refactoring
重複コードを認識し、それをクリーンアップすること(リファクタリングとも呼ばれます)は、TDDサイクルの重要なステップです。
前のセクションの終わりに、パターンが変換ロジックに現れました。 このパターンを完全に識別することになります。
Exposing the Duplicate Code
まだ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方法論でリファクタリングすることの美しさです。 既存の機能を壊していないという安心感を得ることができます。
複製をフルに公開するためのもう1つの変更があります。 convert(_ :)
を修正して置き換えます。
この箇所を
result += String(repeating: "I", count: localNumber)
次のように置き換えます。
while localNumber >= 1 {
result += "I"
localNumber = localNumber - 1
}
これら2つのコードは等価で、繰り返しのI文字列を返します。
すべてのテストを実行してください。 これらはすべて合格します。
Optimizing Your Code
convert(_ :)
のコードのリファクタリングを続け、10を処理するwhile
文を次のように置き換えます。
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
}
}
コードを順番に見ていきましょう。
- 数字とそれに対応するローマ数字記号を表すタプルの配列を作成します。
- 10の値で配列を初期化します。
- 配列をループさせます。
- 数値の変換を処理するために発見したパターンで配列の各項目を実行します。
すべてのテストを実行してください。 これらは合格します:
これで、リファクタリングを論理的な結論に導くことができるはずです。convert(_ :)
を次のように置き換えます。
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に追加した汎用コードで置き換えます。
すべてのテストを実行してください。 それらはすべて合格します。
Handling Other Edge Cases
あなたのコンバータは長い道のりを歩んできましたが、あなたがカバーできるケースがもっとあります。これを実現するために必要なすべてのツールが揃いました。
ゼロの変換から始めます。 ただし、ゼロはローマ数字では表示されません。 つまり、これが渡されたときに例外をスローするか、単に空文字列を返すかを選択できます。
ConverterTests.swiftで、クラスの末尾に次の新しいテストを追加します。
func testConverstionForZero() {
let result = converter.convert(0)
XCTAssertEqual(result, "", "Conversion for 0 is incorrect")
}
これは期待される結果がゼロであるかどうかをテストし、空の文字列を期待します。
新しいテストを実行してください。これはコードをどのように記述したかによって機能します。
Numeroでサポートされている最後の3999の数字を試してみてください。
ConverterTests.swiftで、クラスの末尾に次の新しいテストを追加します。
func testConverstionFor3999() {
let result = converter.convert(3999)
XCTAssertEqual(result, "MMMCMXCIX", "Conversion for 3999 is incorrect")
}
これは3999の期待される結果をテストします。
新しいテストを実行してください。このようなエッジケースを処理するためのコードを追加していないので、失敗するはずです。
Converter.swiftで、convert(_ :)
を変更し、numberSymbols
の初期化を次のように変更します。
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をフルに導入したのであれば、おそらく40と400のnumberSymbols
マッピングはテストに含まれていないので、追加することに抗議すると思われます。そのとおりです!TDDでは最初にテストを書かない限りコードを追加したくありません。このようにしてコードの適用範囲を広くしています。私はあなたのたっぷりの自由な時間にこれらの過ちを正すというエクササイズをあなたに任せるつもりです。
アプリの背後にあるアルゴリズムについては、特別に言及しているJim Weirich - Roman Numerals Kataを参照してください。
Use Your Converter (コンバーターを使用する)
おめでとうございます。今完全に機能するローマ数字変換器を持っています。ゲームで試してみるには、さらにいくつかの変更を加える必要があります。
Game.swiftで、generateAnswers(_:number :)
を変更して、correctAnswer
の割り当てを次のように置き換えます。
let correctAnswer = converter.convert(number)
これはハードコーディングされた値の代わりにあなたのコンバーターを使うことに切り替えます。
アプリをビルドして実行します。
すべてのケースがカバーされていることを確認するために数ラウンドをやってみましょう。
Other Test Methodologies
TDDをもっと深く掘り下げるにつれて、他のテスト方法論について知っているかもしれません。例えば:
- 受入テスト駆動開発(ATDD): TDDと似ていますが、顧客と開発者が共同で受け入れテストを書いています。 プロダクトマネージャは顧客の一例であり、受け入れテストは機能テストと呼ばれることもあります。 テストは一般にユーザーの観点から、インターフェースレベルで行われます。
- 行動駆動型開発(BDD): TDDテストを含むテストの書き方を説明します。 BDDは実装の詳細よりもむしろ望ましい振舞いをテストすることを主張します。 これは、単体テストの構成方法に現れています。 iOSでは、given-when-thenフォーマットを使用できます。 この形式では、最初に必要な値を設定してからテストされるコードを実行してから最終的に結果を確認します。