[SwiftUI] 糸かけ曼荼羅アプリを作る(2: SnapshotTestingの導入)

SwiftUI

前回作った ビューを拡張しつつ SnapshotTesting を使ったテスト環境を作ります

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Big Sur 11.3
  • Xcode 12.5
  • iOS 14.5

概要

前回、円周上に等間隔で配置された釘を表示するビュー(MandaraBoard)を作りました。

釘の数を外部から指定できるようにしつつ、テスト環境を作ります。

今回は、表示されたビューの妥当性を確認するために SnapshotTeting を使っていきます。

SnapshotTesting を使ったテスト

機能拡張するまえに、テストできる環境を作ります。

SnapshotTesting とは

ユニットテストでは、関数の入力に対する出力をテストします。

SnapshotTesting では、特定の状態/タイミングでの表示をテストします。

どうやって テストを行うかというと、あらかじめ用意されていた画像と 表示される画像とを比較することでテストを行います。

便利な点としては、「画像そのものを比較することができる」があります。

SnapshotTesting を使わずにテストしようとすると、イメージ表示に使用されている名称をチェックする等を行う必要がありました。厳密には、その名称で表示されるイメージがどのようなものであるかの確認はできていなくて、「その名称を使用すると適切な表示が行われる」という前提を持ったテストとなっていました。
この方法では 例えば、何らかの理由で名称と対応づいたイメージが変更されていても、テストでは見つけることができないことになります。

SnapshotTesting であれば、実際に表示されているものを使用してのテストとなるために、画像そのものでの比較ができます。

少し不便な点としては、「画像そのものを比較するので、1ドットでも異なると Failed になる」です。

例えば テスト用に使用されるデータが変更されると、テスト用の画像データを再作成する必要があります。
データが変更されると表示が異なるので、それまでの画像との比較が一致しなくなるためです。従来のユニットテストであれば、テストコード上での修正で対応できましたが、画像再作成は少し手間です。

今回のように 表示することが主要機能となっているアプリであれば、SnapshotTesting を導入して、意図した表示になっているかをテストする ことは非常に役立ちます。

MEMO
SnapshotTesting のドキュメントを読むとわかりますが、画像だけではなくさまざまな "Snapshot" をテストすることができるように拡張されています。

SnapshotTesting の導入

SnapshotTesting は、Swift Package Manager に対応しているので、非常に簡単にプロジェクトに追加できます。

SnapshotTeting のドキュメントのこちらに 説明されています。

テストコード

SwiftUI 向け準備

残念ながら、SnapshotTesting は、SwiftUI を直接はサポートしていないようです。

UIViewController や NSViewController はサポートされているので、SwiftUI のビューをテストするときは、UIHostingController や NSHostingController を使うと良さそうです。

iOS 環境向けに、以下のように extension を作成して UIHostingController で wrap するようにしました。


#if os(iOS)
extension View {
    func asVC() -> UIViewController {
        let vc = UIHostingController(rootView: self)
        vc.view.frame = UIScreen.main.bounds
        return vc
    }
}
#endif

これで、SnapshotTeting を使ってテストする準備ができましたので、現在のコードに対するテストを追加してみます。

MandaraBoard 向けテストコード


final class Test_MandaraBoard: XCTestCase {
    func test_mandaraBoard_with48Div() throws {
        // (1)
        let sut = MandaraBoard().environmentObject(MandaraModel())
        // (2)
        assertSnapshot(matching: sut.asVC(), as: .image)
    }
}
コード解説
  1. sut として、MandaraBoard をインスタンス化します。(environmentObject を必要とするため、合わせて設定しています)
  2. assertSnapshot を使って、MandaraBoard として表示されるイメージをテストします。

実行してみると、failed と表示されます。表示されるエラー全文は、以下。
”failed - No reference was found on disk. Automatically recorded snapshot: …
open "/Volumes/PathToProject/ItokakeMandara/ItokakeMandaraTests/__Snapshots__/Test_MandaraBoard/test_mandaraBoard_with48Div.1.png"
Re-run "test_mandaraBoard_with48Div" to test against the newly-recorded snapshot.”

翻訳すると「テスト対象が存在しないので、Failed。Snapshot は、自動で以下のパスに記録されました。新しく記録された snapshot に対して再テストを実行してください。」です。

意訳すると「初回のテスト結果をテスト用画像として保存しました。」です。

実際、上記のパスにいくと以下のような画像の png ファイルが作成されています。

test_mandaraBoard_with48Div.1.png
test_mandaraBoard_with48Div.1.png

数えてみると、48個の点が表示されていることがわかります。

もう一度実行して、OK になることを確認して進みます。

MandaraBoard の拡張

現在は、釘が48本固定ですが、これを変更できるようにします。

MandaraModel の変更

釘の数は、MandaraModel で保持しているので、これを変更できるようにします。

まずは、initializer で指定できるようにします。


public class MandaraModel: ObservableObject {
    // .. snip ..
    var pinNum: Int
    // .. snip ..
    public init(_ pinNum: Int) {
        self.pinNum = pinNum
    }
    // .. snip ..
}

そのままです。デフォルト値を用意していないので、これまでに作成したコードも変更する必要があります。

ItokakeMandaraApp の変更

先ほどの変更で 釘の数を引数として与える必要があるようになりました。

モデルは、App でインスタンス化して保持しているので、App のコードを修正します。


struct ItokakeMandaraApp: App {
    @StateObject var model = MandaraModel(48)  // <- 引数 48 を追加
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(model)
        }
    }
}

テストコードの変更

先ほど書いたばかりのテストコードですが、さっそく修正が必要になります。


    func test_mandaraBoard_with48Div() throws {
        let sut = MandaraBoard().environmentObject(MandaraModel(48))
        assertSnapshot(matching: sut.asVC(), as: .image)
    }

App のコードと同様に、釘の数を引数として具体的に与えます。

テストを再度実行するとコンパイルも成功し、テストも Passed となります。

MandaraBoard の変更

MandaraBoard は、釘の数は、MandaraModel の値を使用していますし、位置計算も 釘の数から計算していますので、MandaraBoard 自体を変更する必要はありません。

テストの追加

48個指定以外でも正しく動作するかのテストを追加していきます。

16個のテスト


    func test_mandaraBoard_with16Div() throws {
        let sut = MandaraBoard().environmentObject(MandaraModel(16))
        assertSnapshot(matching: sut.asVC(), as: .image)
    }

48個のときと同様に、初回は Failed となり、画像イメージがフォルダに保存されます。この画像が正しいかどうかは、初回に関しては開発者がチェックしなければいけません。

以下のような画像が保存されていました。

test_mandaraBoard_with16Div.1.png
ALTtest_mandaraBoard_with16Div.1.png

数えてみると、16個の点が表示されていることがわかります。

同様に、64個のケースも以下のように作成し、うまく動作していることを確認しました。


    func test_mandaraBoard_with64Div() throws {
        let sut = MandaraBoard().environmentObject(MandaraModel(64))
        assertSnapshot(matching: sut.asVC(), as: .image)
    }

釘数を設定する UI

釘数を設定するための Picker を追加します。


public struct ContentView: View {
    @EnvironmentObject var model: MandaraModel

    public var body: some View {
        VStack {
            // (1)
            HStack {
                // (2)
                Text("Pin num")
                // (3)
                Picker(selection: $model.pinNum, label: Text("Pin #")) {
                    Text("16")
                        .tag(16)
                    Text("48")
                        .tag(48)
                    Text("64")
                        .tag(64)
                }
                .pickerStyle(SegmentedPickerStyle())
            }
            ZStack {
                Color.white
                MandaraBoard()
            }
            .frame(width: model.boardSize.width, height: model.boardSize.height)
        }
        .padding()
    }
}
コード解説
  1. 元々の VStack の先頭に(つまり一番上に)、HStack を使って、要素を横に並べるようにします
  2. SegmentedPickerStyle では、label が表示されないので、別途 Text を使って選択の目的を表示します。
  3. Picker を使って 16, 48, 64 から選択できるようにしました。

@Published と設定する

ここまでのコードはコンパイルできますが、動かしてみると "Pin num" を変更しても、表示が更新されません。

Model を確認してみると


public class MandaraModel: ObservableObject {
    var boardSize: CGSize = CGSize(width: 400, height: 400)
    var radius:CGFloat = 180.0
    var center = CGPoint(x: 200.0, y: 200.0)
    // (1)
    var pinNum: Int
    
    @Published var strings:[MandaraString] = []
    
    public init(_ pinNum: Int) {
        self.pinNum = pinNum
    }
    // .. snip ..
}

釘の数を保存する pinNum 変数に @Published 指定されていません。MandaraModel は ObservableObject に準拠しているのですが、どの変数が更新された時に通知して良いかはわかりません。

そのためには、どの変数が変更された時に通知すべきかを指定する必要があります。そのための property wrapper が @Published です。

ここでは、pinNum が更新された時に、Mandaramodel を参照しているビューを更新したいので、pinNum に @Published を追加します。


public class MandaraModel: ObservableObject {
    var boardSize: CGSize = CGSize(width: 400, height: 400)
    var radius:CGFloat = 180.0
    var center = CGPoint(x: 200.0, y: 200.0)
    @Published var pinNum: Int
    
    @Published var strings:[MandaraString] = []
    
    public init(_ pinNum: Int) {
        self.pinNum = pinNum
    }
    // .. snip ..
}

こうすることで、pinNum が変更された時に、MandaraModel に依存しているビューの更新が行われるようになります。

やったこと

  • SnapshotTesting を使ったテストの導入した
  • SnapshotTesting を使って、MandaraBoard のテストを作成した
  • Picker を使って、釘数を選択できるようにした
  • @Published を使って、釘数の変更をトリガーとして画面が更新されるようにした

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です