DeNA Testing Blog

Make Testing Fun, Smart, and Delighting End-Users

MOV Android版に対する「コード改善+テスト導入」の取り組みの紹介

こんにちは。SWETの瀬戸(@seto_hi)です。

2019年7月下旬からSWETにジョインし、テストが書きにくい設計を改善して自動テストの導入をサポートする取り組みを行っています。 その取り組みの一環として、MOV Androidアプリの設計の改善を進めてきました。

本記事では、2か月間の中で改善した4つの事例について紹介したいと思います。

MOVのAndroidアプリについて

DeNAでは MOV というタクシーの配車サービスを運営しています。
今回リファクタリングを行ったのは、アプリのユーザーが配車依頼から支払いまでを行う、アプリの中心となる画面です。

画面構成

画面の構造は下記のようになっています。

f:id:swet-blog:20190924175038p:plain

常時表示されるActivityと地図Fragmentはシンプルですが、OverlayFragmentの構造がとても複雑な作りとなっています。 OverlayFragmentは20種類以上の状態があり、その状態に従って10種類以上のFragmentが差し替わるようになっています。
それだけでなく、OverlayFragmentの変更を受けてActivityと地図Fragmentの状態も変更されるので、状態の伝搬や管理が複雑になっています。

実装上の問題点と解決策

画面構成の複雑さだけでなく、MOVのユーザーアプリには様々な歴史的経緯があり内部実装は更に複雑になっていました。 特に、ActivityとFragmentは見事なFatActivityとFatFragmentとして育っており、修正コストの増加やテストコードの書きづらさといった課題につながっていました。

原因を分解し、それぞれについて解決策を考えました。

課題1:BaseFragmentと多数のopenメソッド

BaseFragmentに空実装のopenメソッドが大量に定義され、各Fragmentはそのメソッドを必要に応じてoverrideしており見通しが悪くなっていました。
多くのメソッドはイベントをトリガーとして呼ばれるものだったため、ViewModelにイベント用のLiveDataを用意し、各FragmentでobserveすることによりBaseFragmentへの依存を減らしました。

課題2:Activity、地図Fragment、OverlayFragment間の不必要な依存

地図の操作イベントはCallback経由で一旦Activityに集約され、再度地図FragmentやOverlayFragmentに分配されていました。
Activityに処理を集約する必要は全くなかったので、ViewModelのLiveData経由で地図Fragmentに処理をさせるように変更しました。
OverlayFragmentの画面遷移を知らせるイベントもCallback経由で行っていたのですが、NavController.OnDestinationChangedListenerを使い、依存と実装量を減らすことができました。

これらの修正によってActivityがFragmentのインスタンスを持つ必要がなくなり、今後不必要な依存を作ってしまう可能性が減りました。

課題3:APIコールとCallbacksの実装がActivity/Fragmentにべた書きされている

APIコールとCallbacksの実装がActivity/Fragmentに書かれていたため、Activity/Fragmentの行数が増えるだけでなく、Callback内の処理のテストを書くことが難しくなっていました。

これを解消するため、Googleの提唱する Recommended app architecture に従い、設計を変更しました。
ViewModelでAPIコールをし、Coroutine化したことでcallbackも少ない行数で実装ができています。

f:id:swet-blog:20190924164641p:plain

ActivityやFragmentが状態を持っていた設計をRecommended app architectureに適合するにあたり、今回は3段階のステップを踏みました。

  1. Repositoryを作成することでViewModelへの移植をしやすくする
  2. Coroutine化でCallback処理の整理をする
  3. 状態をLiveData化し、ViewModelへ移植する

依存を減らしつつ移植することにより、安全な設計変更を行うことができました。

改善後のプロダクトコードとテストコードは以下のようになりました。

サンプルコード

CarRequestRepositoryにはCarRequestを返すgetCarRequest()というメソッドがあります。
(ここでは簡易的な例として、localにデータをキャッシュしないものとします)

class CarRequestRepository(
  private val remoteSource: CarRequestRemoteDataSource
) {
  suspend fun getCarRequest(): CarRequest = 
      remoteSource.getCarRequest()
}

ViewModelではこれを読み込み、LiveDataに保存します。 必要であればActivityやFragmentでLiveDataをObserveして値を使うことができます。

class CarRequestViewModel(
  private val repository: CarRequestRespository
): ViewModel {

  private val _carRequest = MutableLiveData<CarRequest>()
  val carRequest: LiveData<CarRequest> = _carRequest
  
  fun loadCarRequest() {
    viewModelScope.launch {
      _carRequest.value = repository.getCarRequest()
    }
  }
}

テスト

このような設計だとテストも書きやすくなります。
CarRequestViewModelのコンストラクタ引数としてCarRequestRespositoryを渡しているため(コンストラクタインジェクション)、モックオブジェクトに差し替えることが容易になります。

CarRequestViewModel#loadCarRequest呼んだ際、取得結果をobserverで検知できることを確認するテストは以下のようになります。

class CarRequestViewModelTest {
  
  @Test
  fun testLoadCarRequest_success() {
    val mockRepository = mockk<CarRequestRepository>()
    val target = CarRequestViewModel(mockRepository)
    val result = CarRequest()

    // coEveryはsuspend fun向けのevery
    // mockRepository.getCarRequest()が呼ばれたらresultを返すように設定する
    coEvery { mockRepository.getCarRequest() } returns result

    // ViewModelのcarRequestが変更されたことを確認したいのでobserverをmockする
    val mockObserver = spyk<Observer<CarRequest>>()
    target.carRequest.observeForever(mockObserver)
    // テスト対象のメソッドの呼び出し
    target.loadCarRequest()

    // mockObserverがmockRepository.getCarRequest()の結果で呼ばれたことを確認する
    verify(exactly = 1) {
      mockObserver.onChanged(result)
    }
  }
}

課題4:DialogやDrawerMenuのCallback実装もActivity/Fragmentで行っている

こちらも課題3と同様にCallbacksの実装がActivity/Fragmentに書かれていたため、Activity/Fragmentの行数が増えるだけでなく、Callback内の処理のテストを書くことが難しくなっていました。

こちらはEnumを利用することで課題を解決しています。

サンプルコード

まず、メニューの各項目をEnumとして定義します。
MOVのアプリではメニューの選択時に新しいActivityを開くようになっているため、Intentの生成メソッドも定義しました。

enum class DrawerMenuItem(
  @IdRes private val menuId: Int
) {
  ACCOUNT(R.id.menu_account) {
    override fun createIntent(context: Context): Intent =
        AccountActivity.createIntent(context)
    },
...

  abstract fun createIntent(context: Context): Intent
}

取り回しを便利にするため、idを元にEnumの要素を取得できるメソッドを定義します。

  companion object {
    fun valueOf(@IdRes id: Int): DrawerMenuItem =
        values().firstOrNull { it.menuId == id }
            ?: throw IllegalArgumentException()
  }

こういった実装をすることにより、Activity側では以下のように記述するだけで各メニューの選択時の処理ができるようになります。
将来的にメニュー項目が増えても、Activity側に修正を入れる必要はありません。

  navigation.setNavigationItemSelectedListener { menuItem ->
    val intent = DrawerMenuItem.valueOf(menuItem.itemId)
           .createIntent(this)
    startActivity(intent)
    false
  }

ちなみに、このようにEnumを使った手法はActionMenuやActivityResultにも応用できます。

テスト

enum化することによりテストも書きやすくなります。
DrawerMenuItem.ACCOUNT.createIntent のテストは以下のようになります。

  @Test
  fun createIntent_account() {
    val intent = DrawerMenuItem.ACCOUNT.createIntent(context)

    assert(intent.component).isNotNull()
    assert(intent.component!!.className).isEqualTo(AccountActivity::class.java.name)
  }

最後に

上記のような修正によって、アプリの設計は改善の方向に進み、テストコードも書きやすい状態に変化していきました。 このようなアプリの設計改善に限らず、SWETチームでは引き続き自動テストを導入するための取り組みを進めていきます。

このような取り組みに興味を持たれた方は、下記URLからのご連絡をお待ちしております!

募集職種: SWET (Software Engineer in Test) / テスト自動化エンジニア(Android Test)

Xcode 11でのテスト周りの新機能を紹介します!

SWETグループ、iOS自動テスト領域チームの細沼(tobi462)です。

今回はWWDC19で発表された内容の中から、 Xcode 11におけるテストまわりの新機能について紹介します!

新しく追加された機能は大きく以下の3つです。

  • Test Plans(テストプラン)
    • テスト実行の設定(実行対象、言語・ロケール、他)を管理できる仕組み
  • Result Bundle
    • テストの成果物(ビルドログ、テストレポート、他)をまとめる仕組み
  • XCTest - XCTUnwrap()
    • XCTAssertNotNil + guard letに相当する関数

なお、メトリクスまわりも大きく進化していますが、本記事では割愛します。

関連するWWDC19のセッション

それぞれの機能について解説する前に、 関連するWWDC19のセッションを紹介したいと思います。 本記事で解説する自動テストに関するセッションに加え、 デバッグやメトリクスが解説されているセッションもあわせて紹介します。

なお、現在では日本語字幕にも対応しています。 どれも素晴らしいセッションとなっているので、 まだ見られていない方は是非ご覧ください。

Testing in Xcode(#413)

今回のWWDC19において、自動テストをテーマとした最も大きなセッションです。

セッションは自動テストのバランスを考える上で大切な「テストピラミッド」の紹介から始まり、 XCTestやカバレッジなどの基本的な利用方法についての説明を経て、 新機能である「Test Plans」や「Result Bundles」の解説がされます。

またセッションの最後では、 自分でCI環境を構築する方法についても解説され、 とても盛り沢山なセッションとなっています。

セッション中のデモがとても分かりやすく、 テストをまだ書いたことがない人にも是非見ていただきたい内容です。

Debugging in Xcode 11(#412)

Xcode 11で追加されたデバッグまわりの機能について、解説されたセッションです。

SwiftUIまわりのデバッグ機能についての紹介が中心となっていますが、 通信環境や端末温度といったデバイス状態について、 上書きしてエミュレートする機能なども紹介されています。

デバッグは自動テストと直接関係するわけではありませんが、 アプリを効率的に開発すするための必須テクニックとして、 iOSアプリ開発者であれば是非とも抑えておきたい機能です。

What's New in Xcode 11(#401)

Xcode 11の新機能について、解説されたセッションです。

自動テストとは直接関係ないセッションですが、 「Test Plan」や「デバイス状態の上書き」といったデバッグ機能について紹介されています。 Testing in Xcode(#413)Debugging in Xcode 11(#412) といったセッションとあわせて見るとより理解が深められる内容かと思います。

Xcode 11では「エディタ分割の強化」や 「Swift Package Managerのサポート」など、 テスト以外についても多くの機能が追加されています。

iOSアプリ開発者であれば必見のセッションといえます。

Improving Battery Life and Performance(#417)

アプリのメトリクス取得について解説されたセッションです。

メトリクス取得についての基本的な考え方から、 XCTestに追加されたAPI、 新しく追加されたMetricKitなどについて解説されています。

冒頭で記載したように、 本記事ではメトリクス取得については触れませんが、 興味のある方は是非このセッションをご覧ください。

Xcode 11におけるテスト周りの新機能

それではXcode 11で追加されたテスト周りの新機能について、次の順で解説していきたいと思います。

  • Test Plans(テストプラン)
  • Result Bundle
  • XCTest - XCTUnwrap()

Test Plans(テストプラン)

Test Plansはその名のとおり、 テスト実行における計画(テスト対象、コンフィギュレーション)を管理できる機能となっています。 WWDC19のセッションでは具体的な利用例として、 多言語(日本語、英語、など)のテストなどが紹介されていました。

従来はスキームを利用して実現していたものですが、 スキーム設定ではビルドの管理といった別の用途に利用されるため、 こうしたテスト実行の管理をするための仕組みとしては煩雑でした。

今回それらが「Test Plans」という形でスキームから独立し、 テスト実行の設定を直感的に分かりやすく管理できるようになりました。

まさに、「Test Plans(テスト計画)」という名に恥じない機能となっています。

Test Plansを導入する

既存プロジェクトにTest Plans導入するには、 既存のスキーム設定から変換する方法が簡単です。

変換はスキーム設定>Test>Infoタブにある「Convert to use Test Plans...」ボタンから行います。

ダイアログが表示されるので、スキーム設定から変換する場合は一番上を選択します。

任意のファイル名(今回は AllLanguage.xctestplan)で保存すると、Test Planに切り替わります。

Test Plansの設定

プロジェクトナビゲータに追加された.xctestplanを選択すると、 「Tests」と「Configurations」タブから構成される設定画面が表示されます。

「Tests」タブはスキーム設定画面におけるテスト一覧と同じような見た目をしており、 同様に「実行するテスト対象」を選択する画面となっています。

「Configurations」タブは、 テスト実行時における設定(以降「コンフィギュレーション」と表記)を設定する画面となっており、 前述した言語設定(日本語、英語など)にくわえカバレッジ取得の有無なども設定可能です。

コンフィギュレーションは1つの.xctestplanで複数持てるようになっており、 左側のサイドバーにその一覧が表示されるようになっています。

「Shared Settings」と表示されているのは共通設定、 その下に表示されたものが個別のコンフィギュレーション(スクリーンショット中では「Configuration 1」)となっており、 個別のコンフィギュレーションは「Shared Settings」の設定値を継承する仕組みとなっています。

複数の設定を用意して実行する

例として「日本語」と「英語」の2つのコンフィギュレーションを用意してテスト実行する例を紹介します。

まず左下の「+」ボタンから「Japanese」と「English」というコンフィギュレーションを作成します。 そして、それぞれの「Application Language」の項目を「Japanese」と「English」に設定します。

ここまで設定しておくと、 テストナビゲータのコンテキストメニューから実行するコンフィギュレーションを選べるようになります。 ここでは「All Configurations」を選択して、 すべてのコンフィギュレーションでテストを実行してみます。

レポートナビゲータでテストの実行結果を確認すると、 「Japanese」と「English」の2つのコンフィギュレーションで実行されていることが分かります。

当然ながら各コンフィギュレーションごとにテスト結果がわかるようになっており、 以下のアイコンからコンフィギュレーションで絞り込むことも出来ます。

コマンドラインから利用する

xcodebuildコマンドにもTest Plans用のオプションが追加されたので、ここで紹介したいと思います。

プロジェクトに用意されたTest Plansの一覧を表示するには-showTestPlansオプションが利用できます。

$ xcodebuild \
    -project ./TestPlanSample.xcodeproj \
    -scheme 'TestPlanSample' -showTestPlans

### 出力内容:
Test plans associated with the scheme "TestPlanSample":
        AllLanguage
        UITestJapanese

テスト実行時に利用するTest Plansを指定する場合は-testPlanを利用します。

$ xcodebuild test \
    -project ./TestPlanSample.xcodeproj \
    -scheme 'TestPlanSample' \
    -destination 'platform=iOS Simulator,name=iPhone Xs' \
#    -testPlan 'AllLanguage' # テストプラン「AllLanguage」を実行

なお、-testPlanを指定しないで実行した場合はスキーム設定でデフォルトになっているものが利用されます。

ちなみにデフォルトではすべてのコンフィギュレーションが実行対象となりますが、 次のオプションを利用することで限定することもできます。

  • -only-test-configuration:指定したものだけを実行
  • -skip-test-configuration:指定したものを除外して実行
$ xcodebuild test \
...
    -testPlan 'AllLanguage' \
#    -only-test-configuration 'English' # コンフィギュレーション「English」のみを実行

Result Bundles

Result Bundles(.xcresult)は、 今まで独立していた以下のようなテスト結果を1つにパッケージするものです。

  • ビルドログ
  • テストレポート
  • コードカバレッジ
  • テスト添付ファイルなど

またxcresulttoolというコマンドも追加され、 コマンドラインからも.xcresultファイルから情報を取得でき、 JSONスキーマで仕様が明確化されたJSONでの取得も可能になっています。

こうした変更により、テスト結果リソースの扱いがシンプルに行えるようになりました。

どのように変化したか?

Xcode 10.2までは以下のスクリーンショットのように、 それぞれの結果リソースが独立したファイルとして出力されていました。

それがXcode 11からは以下のキャプチャのように、 1つの.xcresultファイルとして集約されるようになりました。

単に集約されただけではなくデータサイズについても考慮されており、 WWDC19のセションの発表によると、 これまでに比べて「最大で4倍小さいサイズ」のフォーマットとなっているようです。

この.xcresultファイルはXcodeで開いて確認できるのに加え、 前述したようにxcresulttoolという専用のコマンドを利用して、 中身の情報を取得できるようになっています。

コマンドラインで出力先を指定する

xcodebuildコマンドで-resultBundlePathオプションを利用すると、 指定したパスに.xcresultファイルを出力することが出来ます。

$ xcodebuild test -project UnitTest.xcodeproj \
    -scheme UnitTest \
    -destination 'platform=iOS Simulator,name=iPhone Xs' \
    -resultBundlePath test_output/ResultBundle.xcresult

なお、このオプションを指定しなかった場合、 プロジェクトで設定されたDerivedDataフォルダ以下に出力されます。

DerivedData/{schemeName}/Logs/Test/Test-{schemeName}-{buildNo}.xcresult

Xcodeで開いて中身を確認する

Finderからダブルクリックするか、 「Xcodeメニュー>File>Open」から.xcresultファイルを選択して開くことが出来ます。

すると以下のキャプチャのように、テスト結果をXcode上から閲覧できます。

xcresulttoolの使い方

前述したようにxcresulttoolを利用すると、中身のデータを取得することが出来ます。

以下は、JSONフォーマットとして情報を取得する例です。

$ xcrun xcresulttool get \
  --path ResultBundle.xcresult \
  --format json

出力されるJSONを抜粋すると以下のようになっています。

  "issues" : {
    "_type" : {
      "_name" : "ResultIssueSummaries"
    },
    "testFailureSummaries" : {
      "_type" : {
        "_name" : "Array"
      },
      "_values" : [
        {
          "_type" : {
            "_name" : "TestFailureIssueSummary",
            "_supertype" : {
              "_name" : "IssueSummary"
            }
          },
...
          "issueType" : {
            "_type" : {
              "_name" : "String"
            },
            "_value" : "Uncategorized"
          },
          "message" : {
            "_type" : {
              "_name" : "String"
            },
            "_value" : "XCTAssertTrue failed - \"hello\" is not empty"
          },
          "producingTarget" : {
            "_type" : {
              "_name" : "String"
            },
            "_value" : "UnitTestTests"
          },
          "testCaseName" : {
            "_type" : {
              "_name" : "String"
            },
            "_value" : "OriginalAssertionGoodTests.testAssertEmpty()"
          }
...

各項目ついての説明は割愛しますが、 かなり詳細にデータを取得できるのが分かります。

なお、JSONスキーマの取得は以下のコマンドで行えます。

$ xcrun xcresulttool formatDescription get

同様に出力結果を抜粋すると以下のようになります。

Name: Xcode Result Types
Version: 3.21
Signature: PVlziQ9KewI=
Types:
  - ActionAbstractTestSummary
    * Kind: object
    * Properties:
      + name: String?
  - ActionDeviceRecord
    * Kind: object
    * Properties:
      + name: String
      + isConcreteDevice: Bool
      + operatingSystemVersion: String
      + operatingSystemVersionWithBuildNumber: String
      + nativeArchitecture: String
      + modelName: String
      + modelCode: String
...

その他コマンドの詳細についてはman xcresulttoolから確認できます。

コードカバレッジ結果の取得

以前からxccovコマンドによってカバレッジレポートの中身を参照できました。 ある意味当然といえますが、 今回から.xcresultファイル形式もサポートされました。

.xcresultファイルからカバレッジ確認する際は、以下のコマンドで行えます。

$ xcrun xccov view --report ResultBundle.xcresult

参考まで出力結果の画面キャプチャを貼っておきます。

XCTest - XCTUnwrap()

本記事の最後として、 XCTestに追加されたXCTUnwrap()というAPIについて解説します。

XCTUnwrap()は、 オプショナル型に対してXCTAssertNotNilのアサーションと、 アンラップ処理を同時に行うAPIです。

具体的なコード例を挙げると、 これまでは以下のようなコードを書く必要がありました。

func testExample() {
    
    let string: String? = "Hello"
    
    // `nil`でないことを検証+アンラップ
    guard let s = string else { XCTAssertNotNil(string); return }
    
    // 検証
    XCTAssertEqual(s, "Hello")
}

それがXCTUnwrap()を利用すると以下のように書けるようになります。

func testExample() throws { // `throws`キーワードが必要な点に注意
    
    let string: String? = "Hello"
    
    // `nil`でないことを検証+アンラップ
    let s = try XCTUnwrap(string)
    
    // 検証
    XCTAssertEqual(s, "Hello")
}

なお、guard letではなく強制アンラップ(!)を利用すれば、 XCTUnwrap()を利用せずともシンプルに書けるのではないか、 と感じた方もいるかもしれません。

強制アンラップは記述こそ簡単ですが、 アンラップの失敗によりテストケースが失敗した場合、 「どの箇所で失敗したのか」という情報が失われ原因調査にコストがかかるという欠点があります。

なので、オプショナル型の中身を取り出して検証したい場合は、 今回追加されたXCTUnwrap()を利用していくとよいでしょう。

なお、T型とOptional<T>型はXCTAssertEqual()などの関数で比較可能なので、 中身を取り出す必要がない場合には以下のように記述できます。

let string: String? = nil
XCTAssertEqual(string, "Hello")

おわりに

本記事では、 WWDC19で発表されたXcode 11における自動テスト周りの変更点として、 「Test Plans」「Result Bundle」「XCTUnwrap()」について紹介しました。

今年のWWDC19では「SwiftUI」や「Catalyst」が大きく注目を集めていましたが、 開発における基盤を支える機能もしっかり進化していたことが分かります。 特に本記事で取り上げたような自動テスト周りの機能は、 ここ数年でかなり成熟してきたようにも感じます。

本記事で主要な機能については解説しましたが、 より詳細なテクニック・組み合わせ方法については、 まだ十分にナレッジを得られていない部分もあると感じています。

より多くの方がiOSアプリ開発コミュニティに対して、 様々な知見を公開してくださると嬉しい限りです。

仲間を募集中

お約束ではありますが、 SWETチームでは自動テストやCI/CDを活用する仕事に興味を持った方を募集しています。

以下のエントリフォームから応募できるほか、 私や他のSWETメンバーに声をかけて頂くかたちでも大丈夫です。

是非ともお気軽にご連絡ください!

Unite Tokyo 2019でゲーム開発におけるユニットテストについて発表しました #UniteTokyo

SWETグループの長谷川(@nowsprinting)です。

国内最大のUnityイベントであるUnite Tokyo 2019にて『Unity Test Runnerを活用して内部品質を向上しよう』と題して、Unityでのゲーム開発におけるユニットテストについての発表を行ないました。

f:id:swet-blog:20191001141623j:plain

スライドはUnity Learning Materialsで公開されています。動画も近日中に公開予定です。

learning.unity3d.jp

セッションについて

Uniteでテストを扱うセッションは稀で、どのくらい来ていただけるか不安でしたが、400人部屋の8割ほどが埋まっていたように見えました。 聴講に来ていただいた方々、ありがとうございました。

Unity開発者の皆さんのテストに対する関心の高さを実感でき、今後とも社内外に向けてテストに関する情報発信をしていくモチベーションとさせていただきます。

またセッション後の質疑応答も、10名ほどの方々と都合30分ほどお話させていただきました。 疑問に回答させていただくだけでなく、皆さんそれぞれのテスト観や現場で困っていることなど私のほうでも多くの知見を得ることができ、大変有意義な時間となりました。

技術的トピックについての補足および、セッション後の質疑応答の内容については下記個人ブログに書いていますので、ぜひ併せてご覧ください。

www.nowsprinting.com

"テストを書く文化を根付かせる"試みについて

セッションの終盤に「"テストを書く文化を根付かせる"1試み」と題して、DeNA社内のUnityプロジェクトにユニットテストを導入していった経緯をお話しました。

SWETグループは、いわゆるQAとは別のアプローチで品質向上を目指す横断的組織であり、各ゲームタイトルの開発チームをサポートする立場です。 ユニットテストに関しては、まずは開発チームに現状をヒアリングしつつ「ゲーム開発でテストを書く意味・価値」を考え直すところからスタートし、ようやく一歩目を踏み出せた段階です。

私たちの事例は、セッション中にお話したように「タイミングがよかった」に尽きます。 しかしこの事例・アプローチを弊社だけの特殊事例では終わらせず、業界全体によりよい文化が広がるよう、今後とも社内外への普及活動・情報発信を続けていくつもりです。

以上のような試みに共感していただけた方、興味を持たれた方、一緒に働いてみようと思ってくれた方。 下記職種で採用しておりますので、ぜひご応募ください。お待ちしております。

テスト自動化エンジニア(Unity Test)

テストエンジニア (ゲームアーキテクチャ)

また、その他の分野へのご応募もお待ちしております。 募集職種に関してましては、本ブログサイドバーの「採用情報」の項目をご覧ください。


  1. これはセッション冒頭で紹介した、CEDEC 2019における@t_wada氏のセッションタイトルの引用です。資料はCEDiLからダウンロード可能です

テスト社内普及プロジェクト第2弾! Android UIテストハンズオンを実施しました

こんにちは。SWETグループの外山(@sumio_tym)です。

先日、社内のAndroidエンジニア向けにUIテストのハンズオンを開催しました。 本記事では、ハンズオンを開催するに至った経緯と、その内容を紹介します。

UIテストハンズオン開催の経緯

SWETでは、社内のエンジニアに自動テストのナレッジを普及させるための取り組みを継続しています。 2019年4月に開催したAndroidユニットテストのハンズオンもその一環でした。

当時のAndroidユニットテストのハンズオン参加者を対象としたアンケートで「次に開催してほしいハンズオン」について尋ねていました。 その結果、90%以上の参加者がUIテストハンズオンの開催を希望していたため、Android UIテスト1のハンズオンを開催することにしました。

ところで、Androidの公式ドキュメントで紹介されているテストピラミッドによると、UIテストの推奨割合はテスト全体の10%程度とされています。 本記事の読者の中には、10%のUIテストのためにハンズオンを開催する価値があるのか疑問に思う方もいらっしゃるかも知れません。

しかし私達は、エンジニアが自動テストを活用できるようになるには、 UIテストの特徴や使い方の理解は避けて通れないと考えています。 それらが理解できてはじめて、テストをどのアプローチで自動化するか判断できるようになるからです。

  • テストしようとしている内容はユニットテストとして実装できないか?
  • ユニットテストとして実装できない場合、自動化されたUIテストとして実装すべきか? 手動でテストした方が良いのではないか?

このような判断ができるようになることも念頭に置いて、UIテストハンズオンのカリキュラムを検討しました。

UIテストハンズオンの構成

昨年出版された「Androidテスト全書」は、 Android UIテストハンズオンの用途にはぴったりです。 とはいえ、出版されてからの1年間でAndroidのテストを取り巻く状況も変化してきています。

そこで今回のハンズオンでは「Androidテスト全書」をベースにしつつ、 Androidのテストを取り巻く最新トレンドを踏まえて、次の内容も盛り込むことにしました。

  • AndroidX Testに新しく導入されたActivityScenarioFragmentScenarioの使い方
  • Kotlinコルーチンによる非同期処理に対応する方法

私達SWETはテストの専門家集団として、 Androidテスト全書のUIテスト部分を執筆し、 それ以降もAndroid UIテストのナレッジを常にアップデートしてきました。 そのため、より現在のAndroid開発に適したものとなるよう、柔軟にカリキュラムをアレンジできました。

なお「Androidテスト全書」のUIテスト部分は4章から6章で構成されていますが、今回は次のような理由により4・5章のみをベースにしています。

  • ユニットテスト・UIテストのどちらで実装するかを含めた判断をするためには、UIテストの特徴や導入前の検討事項がまとまっている4章「UIテスト(概要編)」の理解が不可欠である
  • Androidエンジニアがテストを書くケースを考えると、Appium(6章)よりもEspresso(5章)の方が取り組みやすい

これらの内容を「基礎編」と「応用編」に分け、それぞれ2時間(合計4時間)で実施することにしました。

「基礎編」のカリキュラム

基礎編のカリキュラムは次の通りで、外山と田熊(@fgfgtkm)が作成しました。 前半の1時間が座学で、後半の1時間がハンズオンです。座学はほぼAndroidテスト全書の4章に沿った内容です。

セクション 参考にした資料
座学:UIテストの自動化を始める前に Androidテスト全書4.1章
座学:テストツール選択のポイント Androidテスト全書4.2章
座学:長くテストツールを利用し続けるには Androidテスト全書4.3章
ハンズオン:Page Objectデザインパターンを使ってテストを書いてみよう 外山のDroidKaigi 2019発表

基礎編では思い切って、EspressoのAPI説明を全て割愛することにしました。 UIテストを作るだけであれば、Android StudioのEspresso Test Recorderを使えば簡単に実現できるからです。

その点を踏まえると、Androidエンジニアにとっての「UIテストを書くための基礎」 としてより優先度が高いのは「EspressoのAPIを理解してテストを書けること」ではなく 「Page Objectデザインパターンを適用できること」であると考えました。

その考えを元に、ハンズオン部分はEspressoのAPIを知らなくても進められるような構成にしています。

  • Espresso Test Recorderでテストシナリオを記録する
  • 生成されたテストコードをActivityScenarioを使うように書き換える
  • Android Studioのリファクタ機能を使って、安全にPage Object化する

この構成にすることで、2時間という短時間で、 UIテスト未経験のAndroidエンジニアでも保守性の高いUIテストの書き方を習得できる内容になりました。

また、ハンズオン部分はスライドではなくGoogle Codelab形式で作成しました。

f:id:swet-blog:20190927124405p:plain
Google Codelab形式で作成した研修コンテンツのスクリーンショット

Google Codelab形式で作成することにより、今回参加できなかった方も独学でハンズオンを進められるようにしました。

「応用編」のカリキュラム

応用編のカリキュラムは次の通りで、田熊と鈴木穂高(@hoddy3190)が作成しました。 こちらは全てGoogle Codelab形式で作成しました。

セクション 参考にした資料
座学:ActivityScenarioFragmentScenarioを使ったActivity/Fragmentの起動 公式ドキュメント
Test your app's activities
Test your app's fragments
座学&ハンズオン:Espresso APIの基本 Androidテスト全書5.4章
座学:RecyclerViewを操作する Androidテスト全書5.7.1章
座学:カスタムViewActionの作成 Androidテスト全書5.7.1章・5.7.5章
座学:カスタムViewMatcherの作成 Androidテスト全書5.7.1章・5.7.5章
ハンズオン:RecyclerViewの操作する Androidテスト全書5.7.1章・5.7.5章
座学:画面更新の待ち合わせ Androidテスト全書5.5章
座学&ハンズオン:UI Automatorを使って明示的な待ち合わせ処理を行う Androidテスト全書5.5章
座学:IdlingResource Androidテスト全書5.5章
ハンズオン:IdlingResourceを使ったKotlinコルーチンの待ち合わせ Android Testing Codelab §7kotlinx.coroutines Issue 242
座学:その他の方法でコルーチンの待ち合わせをする 外山のDroidKaigi 2019発表DroidKaigi 2019アプリのCoroutinePlugin.kt

応用編では、受講者が次の2点を習得できるようにカリキュラムを検討しました。

  • ActivityScenarioFragmentScenarioを使ってActivityやFragmentをテストする方法
  • 基礎編で習得したEspresso Test RecorderではカバーできないUIテストを自動化する方法

前者のActivityScenarioFragmentScenarioは、AndroidX Testに新しく導入された、 ActivityやFragment単体をテストできる仕組みです。

特にFragmentScenarioFragment単体を起動してテストできる点が画期的です。 これを使えば特定のFragmentのUIテストを書きたいときの煩雑さが大幅に減りますので、 是非ともカリキュラムに組み込みたいと考えました。

後者は、Espresso Test Recorderを使わずにテストを書く場合に必要なEspresso APIの基本を説明しつつ、 ほとんどのアプリで必要になる次の2点を重点的に学ぶ内容にしました。

  • RecyclerViewの操作方法
  • 画面更新の完了を待ち合わせる方法

特に画面更新の待ち合わせについては、採用が増えてきているKotlinコルーチンを題材としており、 より実践的な内容になっています。

この構成にすることで、2時間という枠の中でEspresso APIの基本を押さえつつ、 Espresso UIテスト実装時に直面しがちな問題への対処法を学べるカリキュラムを実現できました。

ハンズオンの振り返りとその先

ハンズオン実施後に参加者アンケートを取った結果、各ハンズオンの総評は「基礎編」が平均4.8、 「応用編」が平均4.71と良いフィードバックをいただくことができました(5段階評価で5が良い・1が悪い)。

ハンズオンでスキルを身に付けたら次は実践です! 参加者を中心に各プロジェクトが自律的に自動テストを書くようになって、はじめてこの取り組みは成功したと言えます。

SWETでは、これからも各プロジェクトが自律的にテストを書けるようになるための取り組みを継続していきます。

最後に、SWETでは一緒に自動テストの普及に取り組んでくれるエンジニアを募集しています。 今回の取り組みに興味を持たれた方は、下記URLからのご連絡をお待ちしております!

募集職種: SWET (Software Engineer in Test) / テスト自動化エンジニア(Android Test)


  1. ここではアプリのUIを操作が発生するテスト全般のことを指しています。UI操作を伴うものであればend-to-endテストに限りません。

iOSDC Japan 2019でリジェクトリスクを低減する取り組みについて発表しました

SWETの加瀬(@Kesin11)です。

先日開催されたiOSDC 2019にて登壇する機会を頂き、「iOSアプリのリジェクトリスクを早期に発見するための取り組み」という発表をしました。

当日は時間の都合上、紹介したツール(以降、AppChecker)がipaをどのように解析し、どのようにチェックを行っているかというロジックの要点だけの紹介しかできず、コードを示した解説まではできませんでした。

AppCheckerは社内の要件やフローに特化した作りとなっているために、残念ながら今のところOSSにする予定はありません。ですが、自分たちと同様のチェッカーを実装したい方が参考にできるように、どのような実装しているのか簡単なサンプルコードで紹介したいと思います。

実装コードの紹介

以下のコードではipa内のInfo.plistからXcodeとiOS SDKのバージョンのチェックを行っています。

#!/usr/bin/env ruby

# app_checker_light.rb
require 'fastlane_core/ipa_file_analyser'
require 'fastlane_core/ui/ui'

# 各チェッククラスのベースクラス
class Checker
  class << self
    def check(info_plist)
      raise 'Not inplmeneted error'
    end

    # 要求されている下限バージョンの定義
    def config
      {
        'DTXcode' => '10.1.0',
        'DTPlatformVersion' => '12.1.0',
      }
    end
  end
end

# Xcodeの必須バージョンをチェックするクラス
class XcodeVersionChecker < Checker
  class << self
    def check(info_plist)
      version = info_plist['DTXcode'].to_i
      # Xcodeのバージョンが10.1の場合はDTXcode: '1010'となる
      # これをmajor, minor, patchに分解する
      major = (version / 100)
      minor = ((version % 100) / 10)
      patch = (version - (major * 100) - (minor * 10))

      # バージョンとして比較できるようにGem::Versionのインスタンス作成
      actual_version = Gem::Version.create([major, minor, patch].join('.'))
      expect_version = Gem::Version.create(config['DTXcode'])

      if actual_version < expect_version
        FastlaneCore::UI.error("必要なXcodeバージョン: #{expect_version}, 実際のバージョン: #{actual_version}")
      end
    end
  end
end

# iOS SDKの必須バージョンをチェックするクラス
class PlatformVersionChecker < Checker
  class << self
    def check(info_plist)
      version = info_plist['DTPlatformVersion'].to_i

      actual_version = Gem::Version.create(version)
      expect_version = Gem::Version.create(config['DTPlatformVersion'])

      if actual_version < expect_version
        FastlaneCore::UI.error("必要なiOS SDKバージョン: #{expect_version}, 実際のバージョン: #{actual_version}")
      end
    end
  end
end

ipa_path = ARGV[0]
info_plist = FastlaneCore::IpaFileAnalyser.fetch_info_plist_file(ipa_path)

# 新しい種類のCheckerを追加したときはこの配列に追加する
checker_classes = [
  XcodeVersionChecker,
  PlatformVersionChecker
]

# 各クラスのチェックを順番に実行
checker_classes.each { |klass| klass.check(info_plist) }

実行方法

$ bundle exec app_checker_light.rb YOUR_APP.ipa

上記のコードは非常に簡易的なクラス設計となっていますが、実際のAppCheckerは多くのチェック項目を扱え、今後の拡張性を考慮した重厚なクラス設計となっています。

実際のAppCheckerは、このコア部分を呼び出すラッパーとしてThorでコマンドラインから呼び出せるようにした"スタンドアローン版"と、Fastlaneから呼び出せるようにした"Fastlaneプラグイン版"の2種類を提供しています。

Fastlaneのプラグインを作成するのは初めてでしたが、 fastlane new_plugin [plugin_name] というコマンドでひな形となるファイル一式を生成できるため、意外に予想していたよりも簡単でした。もし既に何らかのRubyの便利スクリプトをお持ちであれば、こちらのドキュメントを参考にしてFastlaneプラグインにしてみるのも良いかもしれません。

テスト、CI/CD

AppCheckerは社内の多くのチームのビルドパイプラインに組み込んでもらうことになります。そのため社内ツールといえども非常に高い品質が求められ、それぞれのチェック処理は必ずユニットテストを書きながら開発しました。

さらに、pull-requestによって走るCIではそれらのユニットテストに加えて、Bitriseで実際にサンプルアプリからビルドしたipaに対してAppCheckerを実行してエラーが発生しないことを確認するテストも実行しています。

この確認をする理由は、AppCheckerがxcrunなどXcodeと関係する外部ツールを内部的に使用しているためです。Xcodeのバージョンが上がった際にそれらのツールが期待どおりに動作しなくなる可能性が存在するため、最新のXcode環境を使用できるBitriseを活用して常に最新のXcodeの環境でエラーなく動作することを確認しながら開発しています。

このあたりのAppChecker本体のCI/CDについてはiOSDCでは残念ながら発表時間の都合上お話しできませんでしたが、実はその前の9/3に開催されたBitrise User Group Meetup #2にて発表していました。そのときのスライドはこちらになります。

ガイドラインを追うために

iOSDCの発表中でもお伝えしましたが、App Storeのガイドラインは今後もアップデートされ続けていきます。こちらのAppleのデベロッパー向けニュースは、朝刊を読むのと同じように毎日チェックしましょう。

日本語版は本家の英語よりも数日遅れて発信されるため、できれば英語版のRSSをチームのSlackに流してチーム全員でチェックするのが良いでしょう。

App Store Review Guidelinesのurlはこちらです。

現在のところ1ページに全て収まっています。現行のガイドライン本文を保存しておき、次回の更新時にdiffを取ることでどの部分が更新されたのかハッキリと分かるでしょう。

終わりに

iOSDCで発表したAppCheckerについて補足をさせて頂きました。

当初、SWETで開発がはじまったAppCheckerですが、現在はQAグループ内の自動化を推進しているチームに開発・運用を移管し、アプリがよりリリースしやすい形となるような体制にしています。

SWETではテスト自動化の普及に加え、こうした全社的にコストを下げる仕組みの提案・開発なども行っています。iOSに限らず、複数の技術領域でエンジニアを募集しています。

ご興味を持たれた方はぜひご応募ください。

SWETの2名が執筆した「iOSアプリ開発自動テストの教科書」が発売されました

こんにちは、SWETの細沼(@tobi462)です。

iOSアプリ開発における自動テストをテーマとしたiOSアプリ開発自動テストの教科書が6/27(木)に発売されました! 私と同じくSWETの平田(@tarappo)の2名で共著しています。

gihyo.jp

iOSアプリ開発自動テストの教科書〜XCTestによる単体テスト・UIテストから、CI/CD、デバッグ技術まで

iOSアプリ開発自動テストの教科書〜XCTestによる単体テスト・UIテストから、CI/CD、デバッグ技術まで

発売を記念して、本書が生まれた背景や各章の見どころについて、かんたんに紹介させていただきます。 f:id:swet-blog:20190718173518j:plain

企画の立ち上がり

本書の企画が持ち上がったのは2017年6月と、今からおよそ2年前のことになります。

SWETでは「iOS Test Night」という勉強会を以前から開催していましたが、 それに参加された技術評論社の方から「iOSテスト本を執筆しませんか?」という提案を受けたのがきっかけでした。

その当時(および現在でも)、iOSアプリ開発についての入門書は多く出版されていたものの、 テストやCI/CD・デバッグテクニックといった少し進んだテーマを扱った書籍はありませんでした。

iOS Test Nightでは、テストについてのナレッジ共有を主目的としており、それは一定数の成果を収めていたと思います。 しかし、あくまで断片的な情報にとどまっており、体系化されたナレッジとしては整っていないという課題感を持ち続けていました。

それが書籍という形で体系化され世の多くのエンジニアに知ってもらえることには十分な価値があると考え、本書の執筆を決めました。

本書の構成

本書は、5パートで構成されています。

  • 第1部:自動テストについて
  • 第2部:単体テスト
  • 第3部:UIテスト
  • 第4部:CI/CD
  • 第5部:デバッギング

執筆の開始当初は、テストにまつわるTIPSをまとめた、いわゆる「TIPS本」のようなものを考えていました。 しかし執筆作業が進むにつれ、それでは体系化された知識を提供するのは難しいという判断に至り、 一から体系的に学べるような構成に大幅に変更となった経緯があります。

執筆途中での構成変更はとても大変な作業でしたが、 結果としてよりよい書籍に仕上がったと感じています。

本書の対象読者

本書籍では、自動テストについて初歩から体系的に学べるような内容となっており、主に以下のような読者を対象としています。

  • 自動テストをほとんど(あるいは全く)書いたことがない方
  • CI/CDサービスをこれから導入したいと思っている方

一言でいえば「初心者」を主な対象読者としていることになりますが、 一部では進んだテクニックについても解説を試みており、 「中級者」以上の方でも何かしら得られる内容に仕上がっているかと思います。

実際に購入すべきかの判断については、ぜひ書店などで立ち読みして判断いただければ幸いです!

各パートの見どころ

ここからは各パートの見どころについて紹介していきます。

第1部:自動テストについて

第1部では、自動テストを実装・運用するにあたり知っておいたほうが良いことについて記載しています。

  • 1章:自動テストをはじめる前に
  • 2章:自動テストにおける落とし穴を避ける

1章では、自動テストをはじめる前に知っておくと良い「自動テストを実装する目的」や「自動テストを実装する際に意識するべきこと」などについて説明をしています。

また、2章では「テストケース名の話」から、「実行時間をどうするかといった話」といった自動テストを利用していく上で落とし穴になりえるであろうことについて説明をしています。

事前にこれらのことを知っておくことで、より自動テストを活用できると思います。

第2部:単体テスト

第2部では、XCTestの基本から実践的なテクニック、およびOSS活用について記載しています。

  • 3章:XCTestを利用した単体テスト
  • 4章:単体テストに役立つOSSを活用する

3章のXCTestでは基本的な利用方法から始まり、標準のAPIをほぼ網羅するように解説しています。 その上で、独自アサーションの書き方や非同期APIのテストについて、初心者でもわかりやすいように丁寧な説明を心がけました。 また、テストが書きづらい場合の対処など、より実践的なケースについても軽く触れています。

4章では、以下の4つのOSSについて解説を行っています。

考え方が異なるOSSの解説を通じて、様々なテストテクニックを学んでもらえることを狙いとしました。 これらの軸を知ることで、流行などに振り回されないOSS選定ができるようになれば幸いです。

第3部:UIテスト

第3部では、XCUITestを利用したUIテストについて記載しています。

  • 5章:XCTestを利用したUIテストの基本
  • 6章:XCUITestのAPIを理解する
  • 7章:UIテストの一歩進んだテクニック

XCUITestを用いたUIテストの基本から、提供されているAPIの使い方についてある程度網羅的に解説しています。 また、より実践的なテストコードの書き方についても軽く触れています。

これらの章を通じて、XCUITestでどのようなことができるのか分かって貰えればと思います。

第4部:CI/CD

第4部では、CI/CDについて記載しています。

  • 8章:CI/CDの基本を押さえる
  • 9章:fastlaneを利用したタスクの自動化
  • 10章:アプリ配信サービスとデバイスファームの活用
  • 11章:BitriseとCircleCIによるパイプラインの自動化

本パートでは、CI/CDとはという説明にはじまり、実際のサービスの利用例を中心に紹介しています。

デバイスファームについてはまだ利用されてない方も多いと思います。 本パートの情報により、実際に触ってみる一助になれば幸いです。

第5部:デバッギング

第5部では、iOSアプリ開発における様々なデバッグテクニックを紹介しています。

  • 12章:デバッグのテクニック

デバッグツールは使いこなせば強力なものですが、なかなか学ぶ機会がありません。 本書ではブレークポイントやLLDB、Xcodeに用意されたデバッグ機能などについて、初心者でも分かりやすいような解説を心がけました。

明日から使えるテクニックばかりなので、日々の生産性向上につながればとても幸いです。

おわりに

今回は、SWETにおける「テスト自動領域」のiOSを担当する「細沼」と「平田」で共著した iOSアプリ開発自動テストの教科書について、 執筆することになったきっかけや見どころについて解説させていただきました。

今後、社内でiOSのテストに関するハンズオンなどを開催していく予定ですが、それの教材や題材作成の元ネタとして、我々自身もこの書籍を活用していく予定です!

iOSアプリ開発は、Androidアプリ開発と違い、公式ドキュメント・サンプルコードが少なく苦労することも多いと思います。 とくに自動テストについてのドキュメントは少なく、簡素なAPIドキュメントをもとに試行錯誤する現状も多いはずです。

本書がそうした現状を改善する、ひとつのきっかけとなれば幸いです!

SWETグループが考える形式手法の現在とこれからの可能性

こんにちは、SWETの鈴木穂高(@hoddy3190)です。

私は、こちらの記事で紹介されているようなAndroidテストの教育活動をする傍ら、形式手法という技術の可能性を模索しています。 今回は、形式手法についての簡単な説明や、調べていくにつれてわかってきた実用可能性等をご紹介できればと思います。

動機

まず、なぜ私が形式手法について調べようと思ったのかをご説明します。

SWETに所属する前、私は、別の部署で4年ほどゲーム開発に携わっていました。
そこでよく課題に感じていたのは、日本語で書かれる仕様の不備(考慮漏れ、記載漏れ、矛盾など)により、大きな手戻りにつながることが多いということでした。
開発プロセス上でそのような問題が発生すると、当然再発防止策MTGが開かれます。 有識者のレビューを開発フェーズのより早い段階で組み込むようにするフロー改善や、 考慮漏れを防ぐためのチェックリストなどがよく再発防止策として施行されます。

しかし、プロダクト特有の属人性の高い知識が求められるレビューやチェックリストの目視チェックは、段々と運用の綻びが出てきます。 再発防止策として全く機能しないとまでは言いませんが、策の運用が崩れる様子を何度も目にしてきた中で、 技術的なアプローチでこれらを解決できないかを考えるようになりました。

そこで出会ったのが、形式手法という技術です。

形式手法とは

形式手法とは、仕様を明確に記述したり、記述された設計の性質を機械的に検証する手法の総称です。 形式手法にもいくつか種類がありますが、いずれも数学に基づく科学的な裏付けを持ちます。

種類 説明 代表的な記述言語・ツール
形式仕様記述 矛盾がなく論理的に正しい仕様を作成する VDM++/Event-B/Z/Alloy etc.
モデル検査 プログラムの状態をモデル化することで、プログラムが期待される性質を満たすことを検証する Promela/TLA+ etc.
定理証明 法則や説明に基づき、理論的に性質が成り立つことを示していく Coq/Isabelle etc.

形式手法という言葉を聞いたことがないという方も多くおられると思いますが、 実は形式手法自体の研究は1970年ごろから始まっており、その歴史は深いです。

日本ではまだあまり多くの導入事例は公開されていはいませんが、 形式手法を適用して品質向上につながったという事例は、欧米を中心に多く報告されています。

導入事例としてどのようなものがあるのかは、形式手法の実践ポータルがよくまとまっていると思います。 航空宇宙や鉄道など、高い信頼性が必要とされる分野での事例が多いです。 IT業界でも、AmazonのAWSに導入されているという事例はありますが、 公開されている事例は多くありません。

私はまず、形式仕様記述とモデル検査について、調べてみたり、実際に触ってみたりして、前述した仕様書の問題への解決に活用できそうかを検討しました。 その結果、形式仕様記述、モデル検査ともに、開発へ適用する際のコストの高さや、仕様書問題への解決策として本当に効果的かという点に懸念が出ました。

しかし、モデル検査においては、設計や実装の考慮漏れを防げる可能性がありそうだということがわかってきました。 早くも動機の部分とずれが生じてしまいましたが、形式手法の可能性をさらに深掘る投資の価値はありそうだということで、 モデル検査についてさらに詳しく調べてみることにしました。 なお、仕様書問題に対しても、現在、形式仕様記述とはまた別のアプローチの解決策を模索していますが、今回は触れません。

モデル検査について

モデル検査とは、システムを有限個の状態を持つモデルで表現し、モデルが取りうるすべての状態を機械的かつ網羅的に検査することで、システムが仕様を満たすことを確認する手法です。

例えば、1から3の整数値を取りうる変数a, bがあったとし、「aとbの積が常に9以下である」が成り立つのかを確認したいとしましょう。 モデル検査の手順としては、次のようになります。

  1. 検査したいもの(仕様書、ソースコードなど)から専用のモデリング言語でモデルを作成する
  2. 検査対象が満たすべき性質から検査式を作成する
  3. モデル検査ツールにかける

モデルを作成すると下のようになります。検査式は、assertから始まる部分になります。 モデル検査のモデルを書くための言語にはいくつか種類があるのですが、ここではPromelaという言語で記述しています。

sample.pml

inline Choose(n) {
    // 他のプログラミング言語でよく見るif文とは意味が異なり、
    // "::"で始まる3つの文のうち、非決定的に(ランダムに)実行される
    // 網羅的な検査をするときは、ここで取りうるすべてのパターンが探索される
    if
    :: n = 1
    :: n = 2
    :: n = 3
    fi
}

active proctype P() {
    int a = 0, b = 0
    Choose(a) // 上で定義しているChooseがインライン展開され、aには1から3のいずれかの値が代入される。
    Choose(b)
    assert(a * b <= 9)
}

モデル検査ツールにはSpinというツールを使います。 コマンドラインで検査にかけることができます。

$ spin -a -o2 ./sample.pml # spinはHomebrewでインストール可能
$ gcc -o pan pan.c
$ ./pan -c0 -e
$ spin -pglsr -t1 sample.pml   # 結果出力

検査のロジックとしては、 (a, b) = (1, 1), (1, 2), ...,(3, 2), (3, 3)と 取りうるすべての組み合わせを網羅的に探索した上で、a * b <= 9を満たすことを確認します。 モデルに一切そういうロジックを書かずに、このような網羅的な探索ができるのも、モデル検査の良いところです。

では、検査式をassert(a * b < 9)に置き換えるとどうなるでしょう。 その場合、a * b < 9を満たさなかったaとbのすべての組を反例として示してくれます。

using statement merging
  1:    proc  0 (P:1) a.pml:5 (state 3) [a = 3]
  2:    proc  0 (P:1) a.pml:5 (state 9) [b = 3]
spin: a.pml:13, Error: assertion violated
spin: text of failed assertion: assert(((a*b)<9))
  2:    proc  0 (P:1) a.pml:13 (state 13)   [assert(((a*b)<9))]
spin: trail ends after 2 steps
#processes: 1
  2:    proc  0 (P:1) a.pml:14 (state 14) <valid end state>
1 process created

(a, b) = (3, 3)のときに、検査式を満たさなかったことがわかります。

このように、モデル検査は、自動的に網羅的な検査をしてくれます。
そのため、並列システムの不整合のような再現させづらいタイミングで発生する不具合や、場合分け不足など設計の考慮不足が起因の不具合などを効果的に見つけてくれます。 品質の高いソフトウェアを開発するための有効な手段の1つになるだろうと考えています。

一方、モデル作成の難易度が高いというデメリットもあります。 プログラミング言語とは少し違うモデリング言語独特の文法で記述しないといけないことに加え、 取りうる状態が膨大になればなるほど検査にかかる時間が増える(状態爆発)こともよくあり、 実際のシステムをうまく抽象化してモデルを作成するスキルも求められます。

ここまでの内容に関して、詳しくは、 私が2019/03/07のAndroid Test Night#6で発表した、「形式手法について調べてみた」に掲載しております。お時間があれば是非ご覧になってください。

PoCづくり

モデル検査の仕組みがわかったところで、プロダクトに適用できるのかどうかを確かめるために、いくつかの簡単なPoCを作ってみることにしました。 PoCとは、Proof of Conceptの略で、ここではモデル検査の実用性を確かめるために、簡単なデモを作ることを指しています。 PoCの題材決めに際しては、プロダクトに適用する可能性も加味し、プロダクト開発でよく起きる設計・実装上の問題にフォーカスすることを心がけました。

そこで、テーマとして挙げたのは、MySQLです。 MySQLは、トランザクション分離レベルごとの挙動の把握が難しく、障害につながることがよくあると思います。 今回はMySQLにおけるdeadlockの検知をするモデルのご紹介をしたいと思います。

MySQLのlockの仕組みをかなり簡略化したモデルを作成し、 複数のプロセスから、CRUDのクエリをランダムにそのモデルに投げ、異常が起きるかどうかを検査します。

/**
 *  MySQL 5.6
 *  REPEATABLE READ
 */

mtype {P1, P2}         // MySQLにクエリを発行するトランザクションの識別子
bool gap_lock_by_P1[6] // 共有ロック
bool gap_lock_by_P2[6] // 共有ロック

/**
 *
 *    negative infinity
 *  ---------------------
 *           |
 *           | gap0
 *           |
 *    +--------------+
 *    |    record    |
 *    +--------------+
 *           |
 *           | gap1
 *           |
 *    +--------------+
 *    |    record    |
 *    +--------------+
 *           |
 *           | gap2
 *           |
 *    +--------------+
 *    |    record    |
 *    +--------------+
 *           |
 *           | gap3
 *           |
 *  ---------------------
 *    positive infinity
 *    
 */

// row_num は select or update or deleteの検索対象(スキャン対象ではない)
inline lock_gap(proc_id, row_num) {
    if
    :: proc_id == P1 -> gap_lock_by_P1[row_num] = true
    :: proc_id == P2 -> gap_lock_by_P2[row_num] = true
    fi
}

// gap lockをかける処理であれば、実際はupdateでもdeleteでも良い
inline select_for_update(proc_id, row_num) {
    lock_gap(proc_id, row_num) // gap lockを起こす
}

inline insert(row_num) {
    // gap_lock_by_P1[row_num]とgap_lock_by_P2[row_num]が共にfalseになるまで待つ
    !gap_lock_by_P1[row_num] && !gap_lock_by_P2[row_num]

    // データ挿入は今回検査したい対象ではない
    // 書いても状態数を増やすことにつながるため書かない
}

inline commit(proc_id, row_num) {
    if
    :: proc_id == P1 -> gap_lock_by_P1[row_num] = false
    :: proc_id == P2 -> gap_lock_by_P2[row_num] = false
    fi
}

// トランザクション内の処理
inline exec(proc_id, row_num) {
    select_for_update(proc_id, row_num); insert(row_num); commit(proc_id, row_num)
}

proctype P(mtype proc_id) {
    // P1, P2はMySQLへの操作を、非決定的に繰り返し行う
    do
    :: exec(proc_id, 0)
    :: exec(proc_id, 1)
    :: exec(proc_id, 2)
    :: exec(proc_id, 3)
    od
}

// 一番最初に呼ばれる処理
init {
    atomic { // atomicで囲まれた部分は不可分処理となる(`run P(P1)`と`run P(P2)`の間に、他の処理がinterruptしないことが保証される)
        // 同じ処理を行うプロセスP1、P2を起動する
        run P(P1)
        run P(P2)
    }
}

では、モデル検査ツールにかけてみましょう。 そうすると、invalid end stateというメッセージが出力されます。 これは、網羅的な検査をしていく中で、実行可能な文がなくなったことを意味します。

spin -a -o2 ./deadlock.pml
gcc -DREACH -o ./pan ./pan.c
./pan -c0 -e
pan:1: invalid end state (at depth 5)
(中略)
spin -k deadlock.pml1.trail -t ./deadlock.pml
spin: trail ends after 6 steps
#processes: 3
        gap_lock_by_P1[0] = 1
        gap_lock_by_P1[1] = 0
        gap_lock_by_P1[2] = 0
        gap_lock_by_P1[3] = 0
        gap_lock_by_P1[4] = 0
        gap_lock_by_P1[5] = 0
        gap_lock_by_P2[0] = 1
        gap_lock_by_P2[1] = 0
        gap_lock_by_P2[2] = 0
        gap_lock_by_P2[3] = 0
        gap_lock_by_P2[4] = 0
        gap_lock_by_P2[5] = 0
  6:    proc  2 (P:1) ./deadlock.pml:53 (state 10)
  6:    proc  1 (P:1) ./deadlock.pml:53 (state 10)
  6:    proc  0 (:init::1) ./deadlock.pml:90 (state 4) <valid end state>
3 processes created

出力された結果を見ると、53行目のinsertの中で処理が先に進めなくなったことがわかります。 2つのプロセスP1、P2がgap0に対しselect for updateで共有ロックをとったものの、お互いinsertに進行ができなくなったようです。 これがMySQLでいうところのdeadlockに相当します。

また、今回トランザクション内の処理(execの中身)は、select for update -> insert -> commitとしていますが、 実際の運用では、このトランザクション内の処理を、プロダクトコード内の処理にあわせてCRUDの組み合わせや順番を変更することを想定しています。 先に書いたとおり、モデル検査には「モデル作成の難易度が高い」というハードルがあります。 それに対しては、例えば、MySQLのlockの機構のモデルをライブラリという形で提供し、query logからexecの中身を自動生成するようなことができれば、 より低コストでモデル検査を行える可能性があると考えています。

設計確認としてのモデル検査

UIのようなイベント駆動での非同期処理の設計確認にもモデル検査が使えます。 少し簡略化して書きますが、APIから取得した要素をリストビューに追加し、追加要素の中心となる位置までスクロールするという処理を実装した際、以下のような設計ミスに気づいた事例がありました。

  • 意図していた挙動
    • 追加するリスト要素を取得
    • 要素を追加表示
    • 中心となる要素の位置を計算
    • その位置までスクロールする
  • 起きてしまった意図しない挙動
    • 追加するリスト要素を取得
    • 中心となる要素の位置を計算
    • その位置までスクロールする(追加表示が完了していないので、実際はスクロールは行われなかった)
    • 要素を追加表示

非同期処理のように考慮漏れが起きやすい処理の設計の検証にもモデル検査が有効です。

/**
 *
 * 想定する仕様:
 * もともと、要素数が5のリストに、4つの要素を追加して、全体として9つの要素にする
 * 9つの要素が表示されたら、7番目の要素の位置までスクロールをする
 *
 */

int item_num = 5 // 初期の要素数
int position = 3 // 初期のスクロール位置

chan position_request = [0] of { bool } // スクロール位置更新リクエスト用の変数
chan item_num_request = [0] of { bool } // リスト更新リクエスト用の変数

chan position_reply = [0] of { bool } // スクロール位置更新リクエストの返信用の変数
chan item_num_reply = [0] of { bool } // リスト更新リクエストの返信用の変数

active proctype Items() {
    int new_item_num = 9
end:
    do
    :: item_num_request ? _ -> // リスト更新のリクエスト受信待ち
        // リスト更新のリクエストを受け取ったら、リストの要素数を9にする
        // 本来のプロダクトなら、"9"を導き出すロジックがあるはずだが、
        // モデル検査においてはそのロジックは関係ないので割愛
        item_num = new_item_num
        item_num_reply ! true
    od
}


active proctype Position() {
    int new_position = 7
end:
    do
    :: position_request ? _ -> // position更新のリクエスト受信待ち
        // スクロール位置更新のリクエストを受け取ったら、スクロール位置を更新する
        // ただし、要求されたスクロール位置が、リストに存在しない場合は更新処理をskipする
        if
        :: item_num >= new_position ->
            position = new_position
        :: else -> skip
        fi
        position_reply ! true
    od
}

proctype ClientA() {
    item_num_request ! true -> item_num_reply ? _ // リスト更新のリクエストを投げ、返信として新しい要素数が返るのを待つ
}

proctype ClientB() {
    position_request ! true -> position_reply ? _ // スクロール位置更新のリクエストを投げ、返信として新しいスクロール位置が返るのを待つ
}

init {
    atomic {
        run ClientA()
        run ClientB()
    }
    // 必ず、「9つの要素が表示されたら、7番目の要素の位置までスクロールをする」が満たされることを検査する
    (_nr_pr <= 3) -> assert(item_num == 9 && position == 7)
}

モデル検査にかけると、反例として、「要素数は9だが、スクロール位置は3」が経路と合わせて出力されます。

spin -pglsr -k rx.pml1.trail -t ./rx.pml
using statement merging
Starting ClientA with pid 3
  1:    proc  2 (:init::1) ./rx.pml:58 (state 1)    [(run ClientA())]
Starting ClientB with pid 4
  2:    proc  2 (:init::1) ./rx.pml:59 (state 2)    [(run ClientB())]
  3:    proc  4 (ClientB:1) ./rx.pml:53 (state 1)   [position_request!1]
  4:    proc  1 (Position:1) ./rx.pml:36 (state 1)  [position_request?_]
  5:    proc  3 (ClientA:1) ./rx.pml:49 (state 1)   [item_num_request!1]
  6:    proc  0 (Items:1) ./rx.pml:22 (state 1) [item_num_request?_]
  7:    proc  1 (Position:1) ./rx.pml:42 (state 4)  [else]
  8:    proc  1 (Position:1) ./rx.pml:42 (state 5)  [(1)]
  9:    proc  1 (Position:1) ./rx.pml:44 (state 8)  [position_reply!1]
 10:    proc  4 (ClientB:1) ./rx.pml:53 (state 2)   [position_reply?_]
 11: proc 4 terminates
 12:    proc  0 (Items:1) ./rx.pml:26 (state 2) [item_num = new_item_num]
 13:    proc  0 (Items:1) ./rx.pml:27 (state 3) [item_num_reply!1]
 14:    proc  3 (ClientA:1) ./rx.pml:49 (state 2)   [item_num_reply?_]
 15: proc 3 terminates
 16:    proc  2 (:init::1) ./rx.pml:62 (state 4)    [((_nr_pr<=3))]
spin: ./rx.pml:62, Error: assertion violated
spin: text of failed assertion: assert(((item_num==9)&&(position==7)))
 17:    proc  2 (:init::1) ./rx.pml:62 (state 5)    [assert(((item_num==9)&&(position==7)))]
spin: trail ends after 17 steps
#processes: 3
        item_num = 9
        position = 3
 17:    proc  2 (:init::1) ./rx.pml:63 (state 6) <valid end state>
 17:    proc  1 (Position:1) ./rx.pml:35 (state 9) <valid end state>
 17:    proc  0 (Items:1) ./rx.pml:21 (state 4) <valid end state>
5 processes created

今回の実装で問題だったのは、別々のストリームで非同期処理をおこなっていたことによるものでした。 ログを見ると、

  1. 中心位置取得のリクエストが投げられる
  2. 追加するリスト要素を取得するリクエストが投げられる
  3. 中心位置取得のリクエストの結果が返ってくる
  4. 追加するリスト要素を取得するリクエストの結果が返ってくる

となっており、意図していた挙動とは異なることがわかります。

別々のストリームで非同期を走らせると、処理順番は常に同じにならないというのは、非同期処理を扱う上で基本的な話ではあります。 しかし、設計の欠陥に実装した後気づくのと、実装の前に気づくのとでは手戻りのインパクトが異なります。 今回の場合、別々のストリームを作るのではなく、単一のストリームで実装すればよいわけですが、 これは、非同期処理実装時の注意の勘所がわかっていないと気づくのは難しいです。 自分が考えている設計が本当にこれでよいのか、考慮漏れはなさそうかを確認するためにも有効だろう思いました。

今後の展望

いくつかのPoC作りを通して、モデリング言語で表現できる範囲がわかってきました。 これからは、実際のプロダクトコードにモデル検査を適用できる部分を探してみたいと思います。 もともと私が感じていた仕様書問題に対するアプローチという動機からは少し変わり、設計・実装に対するアプローチとなっておりますが、 まずはその方向でさらなる可能性を見極めていきたいと思います。

さいごに

このようにSWETでは、少しずつではありますが、形式手法の可能性について研究をしています。 経過をかなり省いて書いてしまった部分もありますが、なにか疑問がございましたら、@hoddy3190までお尋ねくださいませ。

また、builderscon tokyo 2019に「形式手法を使って、発見しにくいバグを一網打尽にしよう」というタイトルで出したCFPが採択されました! 本記事の続報についてはbuildersconでお話できればと思っています。 是非会場にお越しください!

形式手法の研究は私含め2人のメンバーで行っております。 2人とも半年ほど前に形式手法をはじめたばかりです。 興味がある方、詳しい方、一緒に働いてみようと思ってくれた方、 採用へのご応募お待ちしております!

SWET (Software Engineer in Test) / テストエンジニア (ゲームアーキテクチャ)

また、その他のチームへのご応募お待ちしております。 募集職種に関してましては、本ブログサイドバーの「採用情報」の項目をご覧ください!