SwiftUIでUnit Testを行う

2021/07/20に公開

SwiftUIでのUnit Test

SwiftUIにはUnit Testのためのフレームワークが標準では(今のところ)用意されていませんが、サードパティ製のフレームワーク(ライブラリ)があります。

https://github.com/nalexn/ViewInspector

このViewInspectorは、主にSwiftUIでUnit Testするためのライブラリです。private APIを使用していないため、プロダクションに使うこともできます。
ここではUnit Testでの使い方を紹介します。

Swift Package Managerを使ってインストール

cocoapodscarthageでインストールする方法もありますが、今回はSwift Package Manager(SPM)でインストールします。

  • Xcodeプロジェクト設定>PROJECT>Swift Packages>"+"ボタンを押します
  • URL入力欄にhttps://github.com/nalexn/ViewInspectorを入力します
  • バージョンを選択しNextを押します
  • Add to TargetとしてUnit Testのターゲットを選択します

※もし、プロダクションやUI Testでも使う場合は、Link Binary with Librariesなどから適宜追加します

実装

ContentViewとUnit Testを書く

SwiftUIのViewを書きます。今回はテキストとカウントを+1していくボタンを追加します。

struct ContentView: View {
    
    @State var count: Int = 0
    
    var body: some View {
        VStack {
            Text("Hello, world!")
                .padding()
            Text("\(count)")
            Button.init("Increment") {
                self.count += 1
            }
        }
        
    }    
    
}

次にUnit Testを書きます。

import XCTest

import ViewInspector //1

@testable import TestSample

extension ContentView: Inspectable { } //2

class TestSampleTests: XCTestCase {
  .....
    func testView() throws {
                        
        try XCTContext.runActivity(named: "static string") { _ in
            let view = ContentView()
            let text = try view.inspect().vStack().text(0).string() //3
            XCTAssertEqual(text, "Hello, world!")
        }

        try XCTContext.runActivity(named: "dynamic string") { _ in
            let view = ContentView()
            var count = try view.inspect().vStack().text(1).string()
            XCTAssertEqual(count, "0")
            
            try view.inspect().vStack().button(2).tap()
            count = try view.inspect().vStack().text(1).string()
            XCTAssertEqual(count, "1")
                        
        }        
        
    }
}

ポイントは

  1. ViewInspectorをimportする
  2. カスタムView(今回はContentView)にInspectableを準拠する
  3. view.inspectを使う

です。あとはView構造に従ってTextを見つけます。

var count = try view.inspect().vStack().text(1).string()

上記では、ContentView > VStack > Textのように取得しています。最終的にTextの中の文字列を取り出し、XCTAssertEqualで評価しています。

ViewModelを追加する

上記のContentViewの実装では、構造がかなり簡単なため@Stateを使っていましたが、実際には実装の複雑さなどからViewModelなどを使うことが多いと思うので、ViewModelで書き直してみます。

class ViewModel: ObservableObject {
    
    @Published var count: Int
    
    init(count: Int) {
        self.count = count
    }
    
    /// カウントを+1
    func increment() {
        self.count += 1
    }
}

struct ContentView: View {
    
    @ObservedObject var viewModel: ViewModel
    
    var body: some View {
        VStack {
            Text("Hello, world!")
                .padding()
            Text("\(self.viewModel.count)")
                .tag(5) //ViewInspectorで見つけやすくするため
            Button.init("Increment") {
                self.viewModel.increment()
            }
        }
        
    }    
    
}

テストは以下のようになります。

import XCTest

import ViewInspector //1

@testable import TestSample

extension ContentView: Inspectable { } //2

class TestSampleTests: XCTestCase {
  .....
    func testViewModel() throws {        
                
        try XCTContext.runActivity(named: "static string") { _ in
            let view = ContentView(viewModel: .init(count: 0))
            let text = try view.inspect().vStack().text(0).string()
            XCTAssertEqual(text, "Hello, world!")
        }

        try XCTContext.runActivity(named: "dynamic string") { _ in
            let view = ContentView(viewModel: .init(count: 0))
            var count = try view.inspect().vStack().text(1).string()
            XCTAssertEqual(count, "0")
                        
            try view.inspect().vStack().button(2).tap()
            count = try view.inspect().vStack().text(1).string()
            XCTAssertEqual(count, "1")
            
            try view.inspect().find(button: "Increment").tap()
            count = try view.inspect().find(viewWithTag: 5).text().string()
            XCTAssertEqual(count, "2")
            
        }
        
        try XCTContext.runActivity(named: "dynamic string with initial value") { _ in
            let view = ContentView(viewModel: .init(count: 5))
            var count = try view.inspect().vStack().text(1).string()
            XCTAssertEqual(count, "5")
            
            try view.inspect().vStack().button(2).tap()
            count = try view.inspect().vStack().text(1).string()
            XCTAssertEqual(count, "6")
        }
        
    }  
}

今回はXCTContextを使い、テストを階層化しています。
ViewModelを初期化する時countを渡しており、異なる初期値のContentViewを生成しています。初期値ごとにテストをしています。
上記の例では、階層的にTextを見つける方法

var count = try view.inspect().vStack().text(1).string()

ボタンのラベル文字列から見つける方法

try view.inspect().find(button: "Increment").tap()

tagからTextを見つける方法

count = try view.inspect().find(viewWithTag: 5).text().string()

を紹介しました。他にも使用方法がいろいろあります。
https://github.com/nalexn/ViewInspector/blob/master/guide.md

サンプル

https://github.com/usk-sample/SwiftUITestSample

参考

https://qiita.com/turara/items/dd7bee391962f945256f

Discussion