不具合検出テストを書く – WWDC2020

https://developer.apple.com/videos/play/wwdc2020/10091/

Session概要

最も手強いバグさえも発見し、診断するのに役立つ有効なテストをデザインする。最良のコードにおいても隠れた問題を発見出来るように、XCTestを使った自動テストの改善方法について。
問題のトリアージを容易にする不具合検出テストの準備方法やインタフェースの問題を解決し、素早く修正する方法について。
Testレポートを用いてより楽にトリアージする方法について。

XCTestCaseの新しいAPI

setUpWithError

setUp() APIの中でthrowされたエラーをキャッチまたはパス出来るようにする
エラー管理に活用し、実行前にテストに必要な初期状態を設定し、過去のテストの副作用が起きないようにする。

下記の例では continueAfterFailureをfalseにし、問題があればただちにテストが失敗するようにしている
これにより複数のエラーを待たずに最初のエラーを早く見つけ出すことが出来る

class RecipesTests: XCTestCase {
    let app = FrutaApp()

    override func setUpWithError() throws {
        continueAfterFailure = false
     // 環境変数を設定してアプリ内での状態を迅速に設定
        app.launchArguments.append("-recipes-tests")
        app.launch()
    }
}

// 下記の例ではTabViewControllerのどのタブから開始するかを設定している
// これによりテスト対象にフォーカスしてテストをすることが出来る
@State private var selection: Tab = 
       CommandLine.arguments.contains("-recipes-tests") 
       ? .recipes : .menu

XCTestのTips

テストコードではStringでの比較は可能な限り行わず、enumで行う。
そうすることで、テストコードは変更せずプロダクトコードの変更のみで済む。
また、ヘルパー関数を定義することで複数のテストで同じコードバスが使える。
その他、Swift Packageやframeworkにすることにより複数のプロジェクトでコードを共有して利用することを推奨。
// XCUITestでの共通ヘルパー関数

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()
   }
}
// XCTUITest ElementのModel化

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
    }
}

Testアサーションについて

UI要素が何らかの理由により表示されるまでに時間がかかったりする場合がある際の対処方法として、 sleepを与えてテストを遅らせる方法がありますが、XCTestには内蔵のリトライ機能があるためwaitForExistence(timeout)の使用を推奨する。
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)
}
また、XCTUnwrapによるOptional型のunwrap機能があります
XCTUnwrapはguardのsyntax sugarであり、nilである場合にerrorをthrowする機能があります。さらなる利点としてはCrashさせないのでtearDownメソッドが呼び出されることで適切に失敗を通知する事ができる。
// 様々なunwrap方法

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”)

エラーのthrowについて

frameworkなどでtestコードを共有している場合のエラー通知はassertではなくthrowを使用する。
そうすることで、共有コードを使用している元のテストコードで失敗を示す事ができる。
public func verify(ingredients: [String]) throws {
    // runActivityを用いることでブロック内で実行されたアクションがレポートに反映される
    try XCTContext.runActivity(named: "Verifying \(ingredients) exists in the Recipe screen.")
    { verifyingRecipe in
        for ingredient in ingredients {
            if !element.switches[ingredient].waitForExistence(timeout: 5) {
                // XCTAttachmentを付与することでファイルや画像、データなどの添付物がXCTContextに追加され、レポートに反映される
                let attachment = XCTAttachment(string: element.debugDescription)
                verifyingRecipe.add(attachment)

                throw RecipeError.ingredientDoesNotExist(ingredient)
            }
        }
    }
}

// CustomStringConvertibleプロトコルに準拠することで
// より詳細なログを出すようにした実装
public enum RecipeError : Error, CustomStringConvertible {
    case ingredientDoesNotExist(String)

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

tearDownで行うべきこと

  • tearDownWithError APIでエラーをハンドリングすること
  • tearDown内でtest中発生したログを集めること
  • 環境や状態を初期化してリセットすること
最新情報をチェックしよう!