iOS用の自動テストを作成する方法

公開: 2022-03-11

優れた開発者として、あなたはあなたが書いたソフトウェアのすべての機能とすべての可能なコードパスと結果をテストするために最善を尽くします。 しかし、考えられるすべての結果と、ユーザーがたどる可能性のあるすべてのパスを手動でテストできることは非常にまれであり、珍しいことです。

アプリケーションが大きく複雑になるにつれて、手動テストで何かを見逃す可能性が大幅に高まります。

UIとバックエンドサービスAPIの両方の自動テストにより、すべてが意図したとおりに機能することを確信でき、開発、リファクタリング、新機能の追加、または既存の機能の変更時のストレスを軽減できます。

自動テストを使用すると、次のことができます。

  • バグを減らす:コード内のバグの可能性を完全に取り除く方法はありませんが、自動テストによってバグの数を大幅に減らすことができます。
  • 自信を持って変更を加える:新しい機能を追加するときにバグを回避します。つまり、すばやく簡単に変更を加えることができます。
  • コードを文書化する:テストを見ると、特定の機能に何が期待されているか、条件は何か、コーナーケースは何かがはっきりとわかります。
  • 痛みを伴わないリファクタリング:開発者として、特に大量のコードをリファクタリングする必要がある場合は、リファクタリングを恐れることがあります。 単体テストは、リファクタリングされたコードが意図したとおりに機能することを確認するためにここにあります。

この記事では、iOSプラットフォームで自動テストを構築して実行する方法について説明します。

ユニットテストとUIテスト

ユニットテストとUIテストを区別することが重要です。

単体テストは、特定のコンテキスト特定の機能をテストします。 単体テストは、コードのテストされた部分(通常は単一の関数)が想定どおりに機能することを確認します。 ユニットテストに関する本や記事がたくさんあるので、この投稿では取り上げません。

UIテストは、ユーザーインターフェイスをテストするためのものです。 たとえば、ビューが意図したとおりに更新されるかどうか、またはユーザーが特定のUI要素を操作したときに特定のアクションがトリガーされるかどうかをテストできます。

各UIテストは、アプリケーションのUIとの特定のユーザーインタラクションをテストします。 自動テストは、単体テストとUIテストの両方のレベルで実行できます。

自動テストの設定

XCodeは、ユニットとUIのテストをすぐにサポートするため、プロジェクトに追加するのは簡単で簡単です。 新しいプロジェクトを作成するときは、「単体テストを含める」と「UIテストを含める」をチェックするだけです。

プロジェクトが作成されると、これら2つのオプションがオンになっている場合、2つの新しいターゲットがプロジェクトに追加されます。 新しいターゲット名には、名前の最後に「Tests」または「UITests」が追加されます。

それでおしまい。 プロジェクトの自動テストを作成する準備が整いました。

画像:XCodeでの自動テストの設定。

すでに既存のプロジェクトがあり、UIと単体テストのサポートを追加したい場合は、もう少し作業を行う必要がありますが、これも非常に単純で単純です。

[ファイル]→[新規]→[ターゲット]に移動し、単体テストの場合は[ iOS単体テストバンドル]を選択し、 UIテストの場合は[iOSUIテストバンドル]を選択します。

画像:iOSユニットテストバンドルの選択。

[次へ]を押します。

[ターゲットオプション]画面では、すべてをそのままにしておくことができます(複数のターゲットがあり、特定のターゲットのみをテストする場合は、[テストするターゲット]ドロップダウンでターゲットを選択します)。

[完了]を押します。 UIテストに対してこの手順を繰り返すと、既存のプロジェクトで自動テストの作成を開始する準備が整います。

ユニットテストの作成

単体テストを書き始める前に、それらの構造を理解する必要があります。 プロジェクトに単体テストを含めると、サンプルのテストクラスが作成されます。 この場合、次のようになります。

 import XCTest class TestingIOSTests: XCTestCase { override func setUp() { super.setUp() // Put setup code here. This method is called before the invocation of each test method in the class. } override func tearDown() { // Put teardown code here. This method is called after the invocation of each test method in the class. super.tearDown() } func testExample() { // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct results. } func testPerformanceExample() { // This is an example of a performance test case. self.measure { // Put the code you want to measure the time of here. } } }

理解するための最も重要なメソッドは、 setUptearDownです。 setUpメソッドはすべてのテストメソッドのに呼び出され、 tearDownメソッドはすべてのテストメソッドのに呼び出されます。 このサンプルテストクラスで定義されたテストを実行すると、メソッドは次のように実行されます。

setUp→testExample→tearDownsetUp→testPerformanceExample→tearDown

ヒント:テストは、cmd + Uを押すか、[製品]→[テスト]を選択するか、オプションメニューが表示されるまで[実行]ボタンをクリックして押したままにして、メニューから[テスト]を選択することで実行されます。

特定のテストメソッドを1つだけ実行する場合は、メソッド名の左側にあるボタンを押します(下の画像を参照)。

画像:1つの特定のテスト方法を選択しています。

これで、テストを作成する準備がすべて整ったら、テストするサンプルクラスといくつかのメソッドを追加できます。

ユーザー登録を担当するクラスを追加します。 ユーザーは、電子メールアドレス、パスワード、およびパスワード確認を入力します。 サンプルクラスは、入力を検証し、電子メールアドレスの可用性を確認し、ユーザー登録を試みます。

注:この例では、MVVM(またはModel-View-ViewModel)アーキテクチャパターンを使用しています。

MVVMが使用されるのは、アプリケーションのアーキテクチャがよりクリーンでテストしやすくなるためです。

MVVMを使用すると、ビジネスロジックをプレゼンテーションロジックから簡単に分離できるため、ViewControllerの大規模な問題を回避できます。

MVVMアーキテクチャの詳細はこの記事の範囲外ですが、この記事で詳細を読むことができます。

ユーザー登録を担当するビューモデルクラスを作成しましょう。 。

 class RegisterationViewModel { var emailAddress: String? { didSet { enableRegistrationAttempt() } } var password: String? { didSet { enableRegistrationAttempt() } } var passwordConfirmation: String? { didSet { enableRegistrationAttempt() } } var registrationEnabled = Dynamic(false) var errorMessage = Dynamic("") var loginSuccessful = Dynamic(false) var networkService: NetworkService init(networkService: NetworkService) { self.networkService = networkService } }

まず、いくつかのプロパティ、動的プロパティ、およびinitメソッドを追加しました。

Dynamicタイプについては気にしないでください。 これはMVVMアーキテクチャの一部です。

Dynamic<Bool>値がtrueに設定されている場合、 RegistrationViewModelにバインド(接続)されているViewControllerは登録ボタンを有効にします。 loginSuccessfulがtrueに設定されている場合、接続されたビューは自動的に更新されます。

ここで、パスワードと電子メール形式の有効性を確認するためのいくつかのメソッドを追加しましょう。

 func enableRegistrationAttempt() { registrationEnabled.value = emailValid() && passwordValid() } func emailValid() -> Bool { let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}" let emailTest = NSPredicate(format:"SELF MATCHES %@", emailRegEx) return emailTest.evaluate(with: emailAddress) } func passwordValid() -> Bool { guard let password = password, let passwordConfirmation = passwordConfirmation else { return false } let isValid = (password == passwordConfirmation) && password.characters.count >= 6 return isValid }

ユーザーが電子メールまたはパスワードフィールドに何かを入力するたびに、 enableRegistrationAttemptメソッドは、電子メールとパスワードが正しい形式であるかどうかを確認し、 registrationEnabled動的プロパティを介して登録ボタンを有効または無効にします。

例を単純にするために、2つの簡単な方法を追加します。1つは電子メールの可用性を確認する方法、もう1つは指定されたユーザー名とパスワードで登録を試みる方法です。

 func checkEmailAvailability(email: String, withCallback callback: @escaping (Bool?)->(Void)) { networkService.checkEmailAvailability(email: email) { (available, error) in if let _ = error { self.errorMessage.value = "Our custom error message" } else if !available { self.errorMessage.value = "Sorry, provided email address is already taken" self.registrationEnabled.value = false callback(available) } } } func attemptUserRegistration() { guard registrationEnabled.value == true else { return } // To keep the example as simple as possible, password won't be hashed guard let emailAddress = emailAddress, let passwordHash = password else { return } networkService.attemptRegistration(forUserEmail: emailAddress, withPasswordHash: passwordHash) { (success, error) in // Handle the response if let _ = error { self.errorMessage.value = "Our custom error message" } else { self.loginSuccessful.value = true } } }

これらの2つの方法は、NetworkServiceを使用して、電子メールが利用可能かどうかを確認し、登録を試みます。

この例を単純にするために、NetworkServiceの実装はバックエンドAPIを使用していませんが、結果を偽造する単なるスタブです。 NetworkServiceは、プロトコルとその実装クラスとして実装されます。

 typealias RegistrationAttemptCallback = (_ success: Bool, _ error: NSError?) -> Void typealias EmailAvailabilityCallback = (_ available: Bool, _ error: NSError?) -> Void protocol NetworkService { func attemptRegistration(forUserEmail email: String, withPasswordHash passwordHash: String, andCallback callback: @escaping RegistrationAttemptCallback) func checkEmailAvailability(email: String, withCallback callback: @escaping EmailAvailabilityCallback) }

NetworkServiceは、登録試行と電子メールの可用性チェック方法の2つの方法のみを含む非常に単純なプロトコルです。 プロトコルの実装はNetworkServiceImplクラスです。

 class NetworkServiceImpl: NetworkService { func attemptRegistration(forUserEmail email: String, withPasswordHash passwordHash: String, andCallback callback: @escaping RegistrationAttemptCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(true, nil) }) } func checkEmailAvailability(email: String, withCallback callback: @escaping EmailAvailabilityCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(true, nil) }) } }

どちらのメソッドも、(ネットワーク要求の時間遅延を偽って)しばらく待ってから、適切なコールバックメソッドを呼び出すだけです。

ヒント:プロトコル(他のプログラミング言語ではインターフェースとも呼ばれます)を使用することをお勧めします。 「インターフェース原理へのプログラミング」を検索すると、詳細を読むことができます。 また、単体テストでどのように機能するかを確認できます。

これで、例を設定すると、このクラスのメソッドをカバーする単体テストを作成できます。

  1. ビューモデルの新しいテストクラスを作成します。 Project NavigatorペインのTestingIOSTestsフォルダーを右クリックし、[NewFile]→[UnitTest Case Class]を選択して、 RegistrationViewModelTestsという名前を付けます。

  2. 独自のテストメソッドを作成するため、 testExampleメソッドとtestPerformanceExampleメソッドを削除します。

  3. Swiftはモジュールを使用し、テストはアプリケーションのコードとは異なるモジュールにあるため、アプリケーションのモジュールを@testableとしてインポートする必要があります。 importステートメントとクラス定義の下に、 @testable import TestingIOS (またはアプリケーションのモジュール名)を追加します。 これがないと、アプリケーションのクラスやメソッドを参照できません。

  4. registrationViewModel変数を追加します。

空のテストクラスは次のようになります。

 import XCTest @testable import TestingIOS class RegistrationViewModelTests: XCTestCase { var registrationViewModel: RegisterationViewModel? override func setUp() { super.setUp() } override func tearDown() { super.tearDown() } }

emailValidメソッドのテストを書いてみましょう。 testEmailValidという新しいテストメソッドを作成します。 名前の先頭にtestキーワードを追加することが重要です。 そうしないと、メソッドはテストメソッドとして認識されません。

テスト方法は次のようになります。

 func testEmailValid() { let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl()) registrationVM.emailAddress = "email.test.com" XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") registrationVM.emailAddress = "email@test" XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") registrationVM.emailAddress = nil XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") registrationVM.emailAddress = "[email protected]" XCTAssert(registrationVM.emailValid(), "\(registrationVM.emailAddress) should be correct") }

テストメソッドはアサーションメソッドXCTAssertを使用します。この場合、条件がtrueかfalseかをチェックします。

条件がfalseの場合、assertは(テストとともに)失敗し、メッセージが書き出されます。

テストで使用できるassertメソッドはたくさんあります。 各assertメソッドを説明して表示すると、簡単に独自の記事を作成できるため、ここでは詳しく説明しません。

使用可能なassertメソッドの例としては、 XCTAssertEqualObjectsXCTAssertGreaterThanXCTAssertNilXCTAssertTrue 、またはXCTAssertThrowsがあります。

利用可能なassertメソッドの詳細については、こちらをご覧ください。

ここでテストを実行すると、テストメソッドは合格します。 最初のテストメソッドは正常に作成されましたが、まだ準備が整っていません。 このテスト方法には、以下に詳述するように、まだ3つの問題(1つは大きいものと2つは小さいもの)があります。

問題1:NetworkServiceプロトコルの実際の実装を使用しています

単体テストのコア原則の1つは、すべてのテストが外部の要因や依存関係から独立している必要があるということです。 ユニットテストはアトミックである必要があります。

ある時点でサーバーからAPIメソッドを呼び出すメソッドをテストしている場合、テストはネットワークコードとサーバーの可用性に依存します。 テスト時にサーバーが機能していない場合、テストは失敗し、テストされた方法が機能していないと誤って非難されます。

この場合、 RegistrationViewModelのメソッドをテストしています。

テストされたメソッドemailValidNetworkServiceImplに直接依存していないことがわかっている場合でも、 RegistrationViewModelNetworkServiceImplクラスに依存します。

単体テストを作成するときは、外部の依存関係をすべて削除する必要があります。 しかし、 RegistrationViewModelクラスの実装を変更せずに、NetworkServiceの依存関係をどのように削除する必要がありますか?

この問題には簡単な解決策があり、それはオブジェクトモックと呼ばれます。 RegistrationViewModelをよく見ると、実際にはNetworkServiceプロトコルに依存していることがわかります。

 class RegisterationViewModel { … // It depends on NetworkService. RegistrationViewModel doesn't even care if NetworkServiceImple exists var networkService: NetworkService init(networkService: NetworkService) { self.networkService = networkService } ...

RegistrationViewModelが初期化されると、 NetworkServiceプロトコルの実装がRegistrationViewModelオブジェクトに渡されます(または注入されます)。

この原則は、コンストラクターを介した依存性注入と呼ばれます(依存性注入にはさらに多くの種類があります)。

objc.ioのこの記事など、オンラインでの依存性注入に関する興味深い記事がたくさんあります。

ここには、依存性注入を簡単でわかりやすい方法で説明する、短いが興味深い記事もあります。

さらに、単一責任の原則とDIに関する優れた記事がToptalブログで入手できます。

RegistrationViewModelがインスタンス化されると、コンストラクターにNetworkServiceプロトコルの実装が注入されます(したがって、依存性注入の原則の名前が付けられています)。

 let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl())

ビューモデルクラスはプロトコルにのみ依存するため、カスタム(またはモック) NetworkService実装クラスを作成し、モッククラスをビューモデルオブジェクトに挿入することを妨げるものは何もありません。

模擬のNetworkServiceプロトコル実装を作成しましょう。

プロジェクトナビゲータでTestingIOSTestsフォルダを右クリックして新しいSwiftファイルをテストターゲットに追加し、[新しいファイル]を選択して[Swiftファイル]を選択し、 NetworkServiceMockという名前を付けます。

これは、モッククラスがどのように見えるかです。

 import Foundation @testable import TestingIOS class NetworkServiceMock: NetworkService { func attemptRegistration(forUserEmail email: String, withPasswordHash passwordHash: String, andCallback callback: @escaping RegistrationAttemptCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(true, nil) }) } func checkEmailAvailability(email: String, withCallback callback: @escaping EmailAvailabilityCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(false, nil) }) } }

この時点では、実際の実装( NetworkServiceImpl )と大差ありませんが、実際の状況では、実際のNetworkServiceImplには、ネットワークコード、応答処理、および同様の機能があります。

私たちのモッククラスは何もしません。これがモッククラスのポイントです。 それが私たちのテストに干渉しないよりも何もしない場合。

テストの最初の問題を修正するために、次のように置き換えてテストメソッドを更新しましょう。

 let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl())

と:

 let registrationVM = RegisterationViewModel(networkService: NetworkServiceMock())

問題2:テストメソッド本体でregistrationVMをインスタンス化しています

理由のためにsetUpメソッドとtearDownメソッドがあります。

これらのメソッドは、テストに必要なすべての必要なオブジェクトを初期化またはセットアップするために使用されます。 これらのメソッドを使用して、すべてのテストメソッドで同じinitメソッドまたはsetupメソッドを記述して、コードの重複を回避する必要があります。 特に特定のテストメソッドに対して本当に特定の構成がある場合は、setupメソッドとtearDownメソッドを使用しないことは必ずしも大きな問題ではありません。

RegistrationViewModelクラスの初期化は非常に簡単なので、setupメソッドとtearDownメソッドを使用するようにテストクラスをリファクタリングします。

RegistrationViewModelTestsは次のようになります。

 class RegistrationViewModelTests: XCTestCase { var registrationVM: RegisterationViewModel! override func setUp() { super.setUp() registrationVM = RegisterationViewModel(networkService: NetworkServiceMock()) } override func tearDown() { registrationVM = nil super.tearDown() } func testEmailValid() { registrationVM.emailAddress = "email.test.com" XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") ... } }

問題3:1つのテストメソッドに複数のアサートがあります

これは大きな問題ではありませんが、メソッドごとに1つのアサーションを持つことを支持する人もいます。

この原則の主な理由は、エラー検出です。

1つのテストメソッドに複数のアサートがあり、最初のテストメソッドが失敗した場合、テストメソッド全体が失敗としてマークされます。 他のアサートはテストされません。

このようにして、一度に1つのエラーのみを検出します。 他のアサートが失敗するか成功するかはわかりません。

一度に修正できるエラーは1つだけなので、1つのメソッドに複数のアサートがあることは必ずしも悪いことではありません。したがって、一度に1つのエラーを検出することは、それほど大きな問題ではない可能性があります。

この場合、電子メール形式の有効性がテストされます。 これは1つの関数にすぎないため、テストを読みやすく理解しやすくするために、すべてのアサートを1つのメソッドにグループ化する方が論理的かもしれません。

この問題は実際には大きな問題ではなく、まったく問題ではないと主張する人もいるため、テスト方法はそのままにしておきます。

独自の単体テストを作成する場合、各テスト方法でどのパスを使用するかを決定するのはあなた次第です。 ほとんどの場合、テスト哲学ごとに1つの主張が理にかなっている場所と、そうでない場所があります。

非同期呼び出しを使用したメソッドのテスト

アプリケーションがどれほど単純であっても、特にUIを独自のスレッドで実行したい場合は特に、別のスレッドで非同期に実行する必要があるメソッドが存在する可能性が高くなります。

単体テストと非同期呼び出しの主な問題は、非同期呼び出しの終了に時間がかかることですが、単体テストは終了するまで待機しません。 単体テストは非同期ブロック内のコードが実行される前に終了するため、テストは常に同じ結果で終了します(非同期ブロックに何を書き込んでも)。

これを実証するために、 checkEmailAvailabilityメソッドのテストを作成しましょう。

 func testCheckEmailAvailability() { registrationVM.registrationEnabled.value = true registrationVM.checkEmailAvailability(email: "[email protected]") { available in XCTAssert(self.registrationVM.registrationEnabled.value == false, "Email address is not available, registration should be disabled") } }

ここでは、電子メールが利用できない(別のユーザーがすでに取得している)ことをメソッドが通知した後、registrationEnabled変数がfalseに設定されるかどうかをテストします。

このテストを実行すると、合格します。 しかし、もう1つ試してみてください。 アサートを次のように変更します。

 XCTAssert(self.registrationVM.registrationEnabled.value == true, "Email address is not available, registration should be disabled")

テストを再度実行すると、再度合格します。

これは、アサートがアサートされていないためです。 ユニットテストは、コールバックブロックが実行される前に終了しました(モックされたネットワークサービスの実装では、1秒待ってから戻るように設定されていることに注意してください)。

幸い、Xcode 6では、AppleはXCTestExpectationクラスとしてXCTestフレームワークにテスト期待値を追加しました。 XCTestExpectationクラスは次のように機能します。

  1. テストの開始時に、テストの期待値を設定します。テストから何を期待したかを説明する簡単なテキストを使用します。
  2. テストコードが実行された後の非同期ブロックで、期待を満たします。
  3. テストの最後に、 waitForExpectationWithTimerブロックを設定する必要があります。 期待が満たされたとき、またはタイマーが切れたときのいずれか早い方で実行されます。
  4. これで、期待が満たされるか、期待タイマーが切れるまで、単体テストは終了しません。

XCTestExpectationクラスを使用するようにテストを書き直してみましょう。

 func testCheckEmailAvailability() { // 1. Setting the expectation let exp = expectation(description: "Check email availability") registrationVM.registrationEnabled.value = true registrationVM.checkEmailAvailability(email: "[email protected]") { available in XCTAssert(self.registrationVM.registrationEnabled.value == true, "Email address is not available, registration should be disabled") // 2. Fulfilling the expectation exp.fulfill() } // 3. Waiting for expectation to fulfill waitForExpectations(timeout: 3.0) { error in if let _ = error { XCTAssert(false, "Timeout while checking email availability") } } }

ここでテストを実行すると、失敗します-当然のことです。 テストに合格するように修正しましょう。 アサーションを次のように変更します。

 XCTAssert(self.registrationVM.registrationEnabled.value == false, "Email address is not available, registration should be disabled")

テストを再度実行して、合格することを確認します。 ネットワークサービスのモック実装で遅延時間を変更して、期待タイマーが切れた場合に何が起こるかを確認できます。

コールバックなしの非同期呼び出しを使用したメソッドのテスト

この例のプロジェクトメソッドattemptUserRegistrationは、非同期で実行されるコードを含むNetworkService.attemptRegistrationメソッドを使用します。 このメソッドは、ユーザーをバックエンドサービスに登録しようとします。

このデモアプリケーションでは、メソッドは1秒間待機してネットワーク呼び出しをシミュレートし、登録が成功したように見せかけます。 登録が成功した場合、 loginSuccessful値はtrueに設定されます。 この動作を検証するために単体テストを作成しましょう。

 func testAttemptRegistration() { registrationVM.emailAddress = "[email protected]" registrationVM.password = "123456" registrationVM.attemptUserRegistration() XCTAssert(registrationVM.loginSuccessful.value, "Login must be successful") }

実行された場合、非同期のnetworkService.attemptRegistrationメソッドが終了するまでloginSuccessful値がtrueに設定されないため、このテストは失敗します。

正常な登録を返す前にattemptRegistrationメソッドが1秒間待機する、モックされたNetworkServiceImplを作成したので、Grand Central Dispatch(GCD)を使用し、 asyncAfterメソッドを使用して1秒後にアサーションをチェックできます。 GCDのasyncAfterを追加した後、テストコードは次のようになります。

 func testAttemptRegistration() { registrationVM.emailAddress = "[email protected]" registrationVM.password = "123456" registrationVM.passwordConfirmation = "123456" registrationVM.attemptUserRegistration() DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { XCTAssert(self.registrationVM.loginSuccessful.value, "Login must be successful") } }

注意を払っていれば、 asyncAfterブロックが実行される前にテストメソッドが実行され、その結果、メソッドは常に正常に渡されるため、これはまだ機能しないことがわかります。 幸い、 XCTestExceptionクラスがあります。

XCTestExceptionクラスを使用するようにメソッドを書き直してみましょう。

 func testAttemptRegistration() { let exp = expectation(description: "Check registration attempt") registrationVM.emailAddress = "[email protected]" registrationVM.password = "123456" registrationVM.passwordConfirmation = "123456" registrationVM.attemptUserRegistration() DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { XCTAssert(self.registrationVM.loginSuccessful.value, "Login must be successful") exp.fulfill() } waitForExpectations(timeout: 4.0) { error in if let _ = error { XCTAssert(false, "Timeout while attempting a registration") } } }

RegistrationViewModelをカバーする単体テストにより、新しい機能を追加したり、既存の機能を更新したりしても、何も壊れないという確信が持てるようになりました。

重要な注意:単体テストは、対象となるメソッドの機能が変更されたときに更新されない場合、その価値を失います。 単体テストの作成は、アプリケーションの残りの部分に遅れずについていく必要があるプロセスです。

ヒント:ライティングテストを最後まで延期しないでください。 開発中にテストを作成します。 このようにして、何をテストする必要があるのか​​、そして何が国境のケースであるのかをよりよく理解することができます。

UIテストの作成

すべての単体テストが完全に開発され、正常に実行された後、コードの各単体が正しく機能していることを確信できますが、それはアプリケーション全体が意図したとおりに機能していることを意味しますか?

そこで登場するのが統合テストであり、その中でUIテストは不可欠なコンポーネントです。

UIテストを開始する前に、テストするUI要素とインタラクション(またはユーザーストーリー)がいくつかある必要があります。 簡単なビューとそのビューコントローラを作成しましょう。

  1. Main.storyboardを開き、下の画像のような単純なMain.storyboardを作成します。

画像:単純なビューとそのビューコントローラーの作成。

メールテキストフィールドタグを100に、パスワードテキストフィールドタグを101に、パスワード確認タグを102に設定します。

  1. 新しいビューコントローラファイルRegistrationViewController.swiftを追加し、すべてのアウトレットをストーリーボードに接続します。
 import UIKit class RegistrationViewController: UIViewController, UITextFieldDelegate { @IBOutlet weak var emailTextField: UITextField! @IBOutlet weak var passwordTextField: UITextField! @IBOutlet weak var passwordConfirmationTextField: UITextField! @IBOutlet weak var registerButton: UIButton! private struct TextFieldTags { static let emailTextField = 100 static let passwordTextField = 101 static let confirmPasswordTextField = 102 } var viewModel: RegisterationViewModel? override func viewDidLoad() { super.viewDidLoad() emailTextField.delegate = self passwordTextField.delegate = self passwordConfirmationTextField.delegate = self bindViewModel() } }

ここでは、 IBOutletsTextFieldTags構造体をクラスに追加しています。

これにより、編集中のテキストフィールドを特定できるようになります。 ビューモデルの動的プロパティを利用するには、ビューコントローラで動的プロパティを「バインド」する必要があります。 これは、 bindViewModelメソッドで実行できます。

 fileprivate func bindViewModel() { if let viewModel = viewModel { viewModel.registrationEnabled.bindAndFire { self.registerButton.isEnabled = $0 } } }

次に、テキストフィールドのデリゲートメソッドを追加して、テキストフィールドのいずれかがいつ更新されているかを追跡します。

 func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { guard let viewModel = viewModel else { return true } let newString = (textField.text! as NSString).replacingCharacters(in: range, with: string) switch textField.tag { case TextFieldTags.emailTextField: viewModel.emailAddress = newString case TextFieldTags.passwordTextField: viewModel.password = newString case TextFieldTags.confirmPasswordTextField: viewModel.passwordConfirmation = newString default: break } return true }
  1. AppDelegateを更新して、ビューコントローラーを適切なビューモデルにバインドします(この手順はMVVMアーキテクチャの要件であることに注意してください)。 更新されたAppDelegateコードは次のようになります。
 func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { initializeStartingView() return true } fileprivate func initializeStartingView() { if let rootViewController = window?.rootViewController as? RegistrationViewController { let networkService = NetworkServiceImpl() let viewModel = RegisterationViewModel(networkService: networkService) rootViewController.viewModel = viewModel } }

ストーリーボードファイルとRegistrationViewControllerは非常に単純ですが、自動UIテストがどのように機能するかを示すには十分です。

すべてが正しく設定されている場合は、アプリの起動時に登録ボタンを無効にする必要があります。 すべてのフィールドが入力されて有効な場合にのみ、登録ボタンを有効にする必要があります。

これを設定したら、最初のUIテストを作成できます。

UIテストでは、有効な電子メールアドレス、有効なパスワード、および有効なパスワードの確認がすべて入力された場合にのみ、[登録]ボタンが有効になるかどうかを確認する必要があります。 これを設定する方法は次のとおりです。

  1. TestingIOSUITests.swiftファイルを開きます。
  2. testExample()メソッドを削除し、 testRegistrationButtonEnabled() testExample()メソッドを追加します。
  3. そこに何かを書くように、 testRegistrationButtonEnabledメソッドにカーソルを置きます。
  4. [UIの記録]テストボタン(画面下部の赤い円)を押します。

画像:[UIの記録]テストボタンを示すスクリーンショット。

  1. 録音ボタンを押すと、アプリケーションが起動します
  2. アプリケーションが起動したら、電子メールのテキストフィールドをタップして、「[email protected]」と入力します。 コードがテストメソッド本体の中に自動的に表示されていることに気付くでしょう。

この機能を使用してすべてのUI命令を記録できますが、簡単な命令を手動で作成する方がはるかに高速である場合があります。

これは、パスワードテキストフィールドをタップして電子メールアドレス「[email protected]」を入力するためのレコーダー命令の例です。

 let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element emailTextField.tap() emailTextField.typeText("[email protected]")
  1. テストするUIインタラクションが記録されたら、停止ボタンをもう一度押して(記録を開始すると、記録ボタンのラベルが停止に変更されます)、記録を停止します。
  2. UIインタラクションレコーダーを入手したら、さまざまなXCTAssertsを追加して、アプリケーションまたはUI要素のさまざまな状態をテストできます。

画像:パスワードフィールドをタップするためのレコーダーの指示を示すアニメーション。

記録された指示は必ずしも自明ではなく、テスト方法全体を読みにくく、理解しにくくすることさえあります。 幸い、UI命令を手動で入力できます。

Let's create the following UI instructions manually:

  1. User taps on the password text field.
  2. User enters a 'password'.

To reference a UI element, you can use a placeholder identifier. A placeholder identifier can be set in the storyboard in the Identity Inspector pane under Accessibility. Set the password text field's accessibility identifier to 'passwordTextField'.

The password UI interaction can now be written as:

 let passwordTextField = XCUIApplication().secureTextFields["passwordTextField"] passwordTextField.tap() passwordTextField.typeText("password")

There is one more UI interaction left: the confirm password input interaction. This time, you'll reference the confirm password text field by its placeholder. Go to storyboard and add the 'Confirm Password' placeholder for the confirm password text field. The user interaction can now be written like this:

 let confirmPasswordTextField = XCUIApplication().secureTextFields["Confirm Password"] confirmPasswordTextField.tap() confirmPasswordTextField.typeText("password")

Now, when you have all required UI interactions, all that is left is to write a simple XCTAssert (the same as you did in unit testing) to verify if the Register button's isEnabled state is set to true. The register button can be referenced using its title. Assert to check a button's isEnabled property looks like this:

 let registerButton = XCUIApplication().buttons["REGISTER"] XCTAssert(registerButton.isEnabled == true, "Registration button should be enabled")

The whole UI test should now look like this:

 func testRegistrationButtonEnabled() { // Recorded by Xcode let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element emailTextField.tap() emailTextField.typeText("[email protected]") // Queried by accessibility identifier let passwordTextField = XCUIApplication().secureTextFields["passwordTextField"] passwordTextField.tap() passwordTextField.typeText("password") // Queried by placeholder text let confirmPasswordTextField = XCUIApplication().secureTextFields["Confirm Password"] confirmPasswordTextField.tap() confirmPasswordTextField.typeText("password") let registerButton = XCUIApplication().buttons["REGISTER"] XCTAssert(registerButton.isEnabled == true, "Registration button should be enabled") }

If the test is run, Xcode will start the simulator and launch our test application. After the application is launched, our UI interaction instructions will be run one by one and at the end the assert will be successfully asserted.

To improve the test, let's also test that the isEnabled property of the register button is false whenever any of the required fields have not been not entered correctly.

The complete test method should now look like this:

 func testRegistrationButtonEnabled() { let registerButton = XCUIApplication().buttons["REGISTER"] XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") // Recorded by Xcode let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element emailTextField.tap() emailTextField.typeText("[email protected]") XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") // Queried by accessibility identifier let passwordTextField = XCUIApplication().secureTextFields["passwordTextField"] passwordTextField.tap() passwordTextField.typeText("password") XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") // Queried by placeholder text let confirmPasswordTextField = XCUIApplication().secureTextFields["Confirm Password"] confirmPasswordTextField.tap() confirmPasswordTextField.typeText("pass") XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") confirmPasswordTextField.typeText("word") // the whole confirm password word will now be "password" XCTAssert(registerButton.isEnabled == true, "Registration button should be enabled") }

Tip: The preferred way to identify UI elements is by using accessibility identifiers. If names, placeholders, or some other property that can be localized is used, the element won't be found if a different language is used in which case the test would fail.

The example UI test is very simple, but it demonstrates the power of automated UI testing.

The best way to discover all possibilities (and there are many) of the UI testing framework included in Xcode is to start writing UI tests in your projects. Start with simple user stories, like the one shown, and slowly move to more complex stories and tests.

Become a Better Developer by Writing Good Tests

From my experience, learning and trying to write good tests will make you think about other aspects of development. It will help you become a better iOS developer altogether.

To write good tests, you will have to learn how to better organize your code.

Organized, modular, well-written code is the main requirement for successful and stress-free unit and UI testing.

In some cases, it is even impossible to write tests when code is not organized well.

When thinking about application structure and code organization, you'll realize that by using MVVM, MVP, VIPER, or other such patterns, your code will be better structured, modular, and easy to test (you will also avoid Massive View Controller issues).

When writing tests, you will undoubtedly, at some point, have to create a mocked class. It will make you think and learn about the dependency injection principle and protocol-oriented coding practices. Knowing and using those principles will notably increase your future projects' code quality.

Once you begin writing tests, you will probably notice yourself thinking more about corner cases and edge conditions as you write your code. This will help you eliminate possible bugs before they become bugs. Thinking about possible issues and negative outcomes of methods, you won't only test positive outcomes, but you will also start to test negative outcomes too.

As you can see, unit tests can have impact on different development aspects, and by writing good unit and UI tests, you will likely become a better and happier developer (and you won't have to spend as much time fixing bugs).

Start writing automated tests, and eventually you'll see the benefits of automated testing. When you see it for yourself, you'll become its strongest advocate.