見出し画像

Write tests to failをみた #WWDC20

setUp

Xcode 11.4からsetUpWithErrorという新しいsetUp関数が導入されエラーをthrowすることができるようになった。これはテストを実行する前の初期設定で問題が見つかった時に気付けるようにする。

class RecipesTests: XCTestCase {
   let app = FrutaApp()
   override func setUpWithError() throws {
       continueAfterFailure = false
       app.launchArguments.append("-recipes-tests")
       app.launch()
   }
}

こちらのコードでは問題が見つかった時にすぐ失敗するようにcontinueAfterFailureをfalseに設定している。これで複数のエラーを探し回るのではなく最初のエラーを素早く発見することができる。

画像1

このテストでは複数のメニューを経由して到達する画面をテストする際にlaunchArcumentsに--recipes-testsを追加して実行している。これはテストの速度を向上させるだけではなく、テストする画面以外の問題を避けることができる。

Action

それぞれのテストには目標があるべき。その目標をテスト名に反映させるべき。テスト名が明確なおかげで結果から何を検証しているのか簡単にみることができる。ラベルのテキストはよく変更される。そこでラベルの文字列にenumを利用している。そうすればUIが変更された際にテストも簡単に更新することができる。

画像2

もう一つのミスを最小限に抑える方法として複数のテストが同じコードパスを使用できるように共通のコードをヘルパー関数に組み込むこと。

let recipe = try app.smoothieList().selectRecipe(smoothie: .berryBlue)
public class FrutaApp : XCUIApplication {
  public func smoothieList() throws -> SmoothieList {
       let element = tables["Smoothie List"]
       if !element.waitForExistence(timeout: 5) {
           throw FrutaError.elementDoesNotExist("Smoothie List table")
       }
       return SmoothieList(app: self, element: element)
   }
}  
public class SmoothieList : FrutaUIElement {
   public func selectRecipe(smoothie: SmoothieType) throws -> Recipe {
      element.buttons[smoothie.rawValue].tap()
      return try app.recipe()
  }
}

さらに採用したテクニックはアプリのドメインをモデル化して、そのドメインを中心にテストを設計すること。

public class FrutaApp : XCUIApplication {
  public func smoothieList() throws -> SmoothieList {  }
} 
public class SmoothieList : FrutaUIElement {
   public func selectRecipe(smoothie: SmoothieType) throws -> Recipe {  }
}
open class FrutaUIElement {
   let app: FrutaApp
   let element: XCUIElement
   init(app: FrutaApp, element: XCUIElement) {
       self.app = app
       self.element = element
   }
}

この例ではFrutaAppにスムジーリストをリクエストすることができ、レシピの選択するようなアクションをリスト上で行うことができ、レシピUIを返す。このように共有コードをオブジェクト指向的にした。こうすることでアプリの考え方にマッピングしたテストからの呼び出しを行うことができる。読みやすさのためにオブジェクト指向の環境をシミュレートすることができる。長年に渡って共有テストのコードは大きくなってきたので、共有フレームワークを作成した。特に複数アプリケーション間ででコードを共有している場合には、テストコードを共有するためにSwift Packageを利用することを検討することもできる。このセクションをようやくすると、テストしていることに焦点を当てるために、特定の目標のためにテストを設計する。

Assertion

画像3

上部のエラーメッセージは何が何だか分からないので、人間が読みやすいようなメッセージを用意する。

画像4

これらをのassertion関数を利用する

非同期のテストではすぐに要素が見つからないかもしれない。そこでwaitForExistingでタイムアウトを設定する。

public func selectRecipe(smoothie: SmoothieType) throws -> Recipe {
   element.buttons[smoothie.rawValue].tap()
   return try app.recipe()
}
public func recipe() throws -> Recipe {
   let element = scrollViews["Ingredients View"]
   if !element.waitForExistence(timeout: 5) {
       throw FrutaError.elementDoesNotExist(
                       "Ingredients View scroll view")
   }
   return Recipe(app: self, element: element)
}

画像5

こうすることで5秒待って要素を見つけたことがわかる

もう一つおすすめのテクニックはOptionalのunwrapに関して。

func countFavorites(favorites: [String]?) -> Int{
    let favs = favorites!
    return favs.count
}

このコードを実行するとこのようなエラーとなる。

画像6

このような状況を回避するにはいくつか方法がある。

if let favs = favorites {  }
guard let favs = favorites else { /* throw an error */ }
let favs = favorites ?? []
let favs = try XCTUnwrap(favorites, "favorites is nil, so there is nothing to count”)

4つ目のオプションはXCTest.frameworkに含まれているXCTUnwrapを使用すること。これはguard letを単純化した物でテストでnilに遭遇した場合にエラーを投げる。

画像7

XCTUnwrapを利用するとResultバンドルに自動生成されたメッセージに加えて、呼び出しからのコメントが表示される。これの良い点はクラッシュする代わりに潔く失敗することでtearDownメソッドが呼び出されること。

throws

共有コードは多くのテストから実行されているので共有コードからはassertではなくthrowすることにしている。これらのテストの中にはネガティブなテストケースもあった。例えば、非表示になっているはずのテストをしたり、テストの為にエラーダイアログを表示すると言ったこともある。このような確認のために、以前は余計な成分が表示されたテストをしていたかもしれない。ここでは成分が表示されなくなったことを確認している。

public func verify(ingredients: [String]) throws {
   try XCTContext.runActivity(named: "Verifying \(ingredients) exists in the Recipe screen.")
   { verifyingRecipe in
       for ingredient in ingredients {
           if !element.switches[ingredient].waitForExistence(timeout: 5) {
               let attachment = XCTAttachment(string: element.debugDescription)
               verifyingRecipe.add(attachment)
                throw RecipeError.ingredientDoesNotExist(ingredient)
           }
       }
   }
}

public enum RecipeError: Error, CustomStringConvertible {
    case ingredientDoesNotExist(String) 

    public var description: String {
        return "\(ingredient) does not exist in the Ingredients View."
    }
}

共通テストの中でassertを実行するのではなくCustomStringConvertibleを適合したErrorをthrowする。

画像8

そうすることでエラーが発生した時にメッセージが分かりやすい。このようにXCTContext.runActivityを使用して名前を提供するようにしている。更に、これを利用すると良いこととして、ファイル、画像、データなどをXCTContextのattachmentに追加すると結果にも表示され、CI上などで失敗の理由を探す時にはとても役に立つ。ローカルでテスト結果をトリアージする時には、XCTIssueを活用してバックトレースを参照すると良い。XCTIssueに関してはTriage Test Failure With XCTIssueという関連セッションを参考。

画像9

テストが全て実行される必要がないことがある。そういう時にはXCTSkip, XCTSkipUnless, XCTSkipIfを利用しメッセージを追加して実行していないことを明示することができる。主な用途はテストが実行されているプラットフォームに関係のないテストをスキップすること。

画像10

これを利用することでテストされていない箇所を明確にすることもできる。

まとめ

本当は昨日の早い段階で見てたんですがまとめに時間がかかってしまいました。テストについてのテクニックと最新機能の活用方法、関連ビデオの2つの内容にも触れられており良いテストに関するセッションだったなと思います。


この記事が気に入ったらサポートをしてみませんか?