DeNA Testing Blog

Make Testing Fun, Smart, and Delighting End-Users

Jenkins Shared Librariesの活用事例の紹介

1. はじめに

SWETグループの井口です(@hisa9chi)です。現在はスマホ向けゲーム開発案件にてゲーム開発者がゲーム開発に集中できるようにCI/CD関連を幅広くサポートしています。 本稿では、その中でも Jenkins Pipeline Job で利用可能な Shared Libraries に関して弊社でどのように活用しているか事例を紹介してみたいと思います。

Jenkinsと聞くとおそらく皆さんは、昔は利用していたが今は運用コストが高いなどの理由から、マネージドなクラウドのCI/CDサービスへ移行したという方が多いのではないでしょうか。しかし、ゲーム開発の現場ではJenkins master / agentのクラスタ構成を構築して、運用を続けているプロジェクトが弊社内にも多く存在します。なぜ、運用コストが高いにもかかわらず構築して運用しているかというと、ゲーム開発特有の理由からです。

ゲーム開発において、修正後にビルドして実機で動作やレイアウトなどを確認するサイクルはとても重要です。このサイクルが短ければ短いほどゲームの修正確認がスムーズに行え、ゲーム開発者はゲーム開発に集中できると思います。しかし、ゲーム開発では大容量のデータを扱うことが多く、開発が進むにつれてビルド時間が増加する傾向にあります。そのため、ビルド時間を少しでも短くするために、高スペックな物理マシンを手元に用意してJenkins master / agentのクラスタ構成を構築して運用しています。

この運用コストに関しては本稿では触れませんが、SWETとして取り組んでいることをCEDEC 2020にて発表しております。発表資料は以下にありますので、そちらも参考にしていただければ幸いです。

CEDEC 2020「モバイルゲーム開発におけるJenkinsクラウド時代のJenkins構築と管理テクニック」

2. 前提

本稿では以下を前提としております。

3. Jenkins Shared Libraries

3.1. 概要

Jenkinsのジョブ作成にてPipeline Jobが多く利用されるようになると、共通処理が複数のPipeline Jobで出現することがあります。この共通処理をPipeline Jobとは切り離して定義できれば、Pipeline Jobのメンテナンス性向上や共通処理等の再利用性も向上します。この共通処理等をPipeline Jobの外部に定義して、Pipeline Jobごとにロードして利用する仕組みが、Shared Librariesです。

3.2. 特徴

Shared Librariesの特徴として以下があります。

  1. ジョブ毎に異なるバージョンの利用が可能
    ライブラリをSCM管理しているため、ブランチ/タグ/コミットハッシュ指定でジョブごとに異なるバージョンのライブラリをロードして利用可能です。

  2. メンテナンス性と再利用性の向上
    共通処理等をPipeline Jobの外に定義しているため、変更はライブラリ内に閉じることが可能です。また、Jenkins masterが異なる場合でも、ライブラリが管理されているリポジトリへアクセス可能であれば容易に利用可能です。他にも、Pluginの利用をラップしたライブラリを作成しておくことで、Pluginの更新に伴う変更もライブラリ内に閉じることが可能な場合もあります。

  3. classメソッドのapproveが不要
    Pipeline Job内でgroovyを用いてclassメソッド1を利用した処理を定義していた場合、そのclassメソッド毎にapproveが必要となる場合があります。Jenksinfile内のgroovyコードで多くのclassメソッドを利用していてapproveされていない場合、このapprove作業は非常に面倒な作業となります。しかし、Shared Librariesであればapproveする必要がないためそのような面倒な作業は発生しません。

3.3. 作成

基本的にはライブラリ群を決められたディレクトリ構造で作成して、そのリポジトリをSCM管理します。そのリポジトリをJenkins側に設定してジョブごとでライブラリを読み込むように設定することで、ジョブごとのワークスペースにライブラリ群がチェックアウトされて利用可能になります。

先に示した公式のドキュメントに詳細は記載されていますが、簡潔に説明するとディレクトリ構造として大きく以下となります。

  • src
    • パッケージに分けて独自のクラスを定義可能
  • var
    • pipeline jobで利用可能な変数定義(.groovy)とヘルプファイル(.txt)
    • xxx.groovyのファイル名を変数として利用可能でありそのファイル内に定義されているメソッドを {ファイル名}.{メソッド名} で呼び出し可能
  • resources
    • groovyではないファイル(xxx.jsonやxxx.sh等)を格納
    • libraryResourceを利用してメソッド内で利用が可能

src/var/resourcesに関しての実装サンプルは公式のサンプルがわかりやすいのでそちらを確認してください。

3.4. 利用

作成したShared Librariesを利用する場合は以下の2ヵ所に設定が必要となります。

  1. Jenkinsのシステム設定
  2. ジョブごとにライブラリをインポート

3.4.1. Jenkinsのシステム設定

Pipeline Jobにてロード可能なライブラリをGlobal Pipeline Librariesへ設定します。JenkinsからSCM経由でライブラリにアクセス可能であることが前提となります。

設定箇所は以下のように 「Jenkinsの管理」-「システムの設定」内の Global Pipeline Libraries の項目になります。

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

項目 設定内容 設定例
Name ライブラリ名。ジョブでインポートする際に利用 mylibrary
Default version ブランチ名、タグやコミットハッシュを指定 master
Retrieval method 利用するSCM Modern SCM
Source Code Management 利用するSCMサービス Git
プロジェクトリポジトリ リポジトリURL SSH or HTTPSアクセス用URL
認証情報 リポジトリへの認証情報 credentialsに登録済みの物を指定

3.4.2. ジョブごとにライブラリをインポート

基本的には、Pipeline Job内で @Library を活用して先ほど設定したライブラリを読み込みます。

// ライブラリの読み込み
// デフォルトバージョンの読み込み
// "Global Pipeline Libraries 設定" の "Default version"
@Library( 'ライブラリ名' ) _

// ブランチやタグを指定する場合
@Library( 'ライブラリ名@{branch名 | tag名}' ) _

// 複数ライブラリの読み込み
@Library( ['ライブラリ名1', 'ライブラリ名2'] ) _

注意が必要なのは最後の _ を忘れないことです。これがなければエラーとなるので注意してください。 _ を記載することでライブラリ側に定義した、var 配下のスクリプトが利用可能になます。利用には var 配下のファイル名をそのまま変数名として利用できます。

また、src などで独自に定義したclassの利用に関してはそのクラスをimportする必要があります。

// srcで定義した class を利用する場合
// 利用したい class を import する必要あり
@Library( 'ライブラリ名' ) import org.foo.Sample

// 利用時は script { } ブロック内で利用する
script {
  def sample = new org.foo.Sample()
  sample.hello()   // Sample 内に定義されている hello() メソッドの呼び出し
}

srcで定義されているclassを利用する場合は script {} ブロックに入れる必要があることに注意してください。

4. 活用事例

では、実際に弊社でどのように利用しているか実際の以下の3つの事例を紹介させていただきます。

  1. 成果物等のGCSアップロード
  2. ジョブ終了時に自動メンション(Slack通知)
  3. ジョブ失敗時のstage名の取得

4.1. 成果物等のGCSアップロード

ジョブを実行した際の成果物やログなどはarchiveArtifacts Pluginを活用してJenkins masterに保存しておくことが一般的かと思います。しかし、ジョブの成果物はサイズが大きい物などもありJenkins masterのディスク容量を圧迫してしまいます。この対策として、ジョブごとにビルド履歴の保存件数を制限するbuildDiscarderを設定する方法があります。ただし、以下の問題があります。

  • ジョブ数が多い場合は結果ディスクフルになってしまいジョブが正しく動作しない
  • ビルド履歴を制限すると過去の成果物の取得ができない

これらを解決する施策として、クラウドのストレージであるGCSやS3にアップロードして保存しておくことが挙げられます。今回はGCSへのアップロードを例にShared Libraryの利用事例をご紹介します。

GCSへのアップロードにはGoogle Cloud Storage Pluginを利用することで簡単に行えます。ただ、アップロードの際には対象のバケット配下に {ジョブ名}/{ビルド番号} のディレクトリを作成してアップロードしたいという要望があります。Pluginをそのまま活用するとアップロード先にバケット名だけでなく {バケット名}/{ジョブ名}/{ビルド番号} とジョブ毎に指定する必要があります。バケット名に関してはジョブ毎に変更する可能性はありますが、ジョブ名ビルド番号は共通してJenkinsの環境変数から取得して指定します。この共通部分をジョブごとに記載するのは煩わしいためライブラリ化しています。

// var/gUploadArtifactsToGCS.groovy
#!/usr/bin/env groovy

// Google Cloud Storage Uplaod(for Google Cloud Storage Plugin)
def call( Map params = [:] )
{
  // 必須パラメータチェック
  if ( params.bucket == null ) {
    println "gUploadArtifactsToGCS: 'bucket' param is not specified."
    return
  }
  if ( params.credentialsId == null ) {
    println "gUploadArtifactsToGCS: 'credentialsId' param is not specified."
    return
  }
  if ( params.pattern == null ) {
    println "gUploadArtifactsToGCS: 'pattern' param is not specified." 
    return
  }

  def uploadDir = "gs://${params.bucket}/${env.JOB_NAME}/${env.BUILD_NUMBER}"
  
  // Google Cloud Storage Plugin の提供メソッド
  googleStorageUpload( bucket: uploadDir, credentialsId: params.credentialsId, pattern: params.pattern )
}
// Jenkinsfile
@Library( 'mylibrary' ) _

pipeline {
  agent { label 'master' }
  stages {
    stage ( 'sample' ) {
      steps {
        sh 'touch sample.txt'
        gUploadArtifactsToGCS( bucket: ARTIFACTS_BUCKET_NAME, credentialsId: GCS_CREDENTIAL_ID, pattern: 'sample.txt' )
      }
    }
  }
}

上記のようにShared Libraryを呼び出すことで指定したバケット ARTIFACTS_BUCKET_NAME 配下に {ジョブ名}/{ビルド番号}/sample.txt としてアップロードされます。

他にも以下のような情報をGCSへアップロードするようなライブラリを作成して利用しています。

  • ジョブ実行時に指定したビルドパラメータとビルドログ
    Jenkinsにて定義済みのclassを活用してファイルに出力してGCSへアップロード

  • ジョブを実行したビルドマシンの環境情報
    agentのOSやツールのバージョンを取得するスクリプトをShared Library側に登録してそれを呼び出して情報を収集してGCSへアップロード

4.2. ジョブ終了時に自動メンション(Slack通知)

ジョブが終了した際に結果をSlackなどへ通知していることは多いかと思います。弊社でもJenkinsのジョブを手動でトリガーした場合やcronで実行された際の結果をSlackへ通知(Slack Notification Pluginを利用しています)しています。しかし、ジョブも多く頻繁に実行されると通知の数が増えるため、Slackの通知が埋もれてしまい気づけない状況が発生するため、特定の人へメンションしたいというケースがあります。

あるプロジェクトでは、ジョブ毎にメンション先をビルドパラメータ化して実行時に設定してもらうという運用がなされていました。しかし、この方法は利用側からすると面倒であり指定を間違えてしまうこともあります。そのため、ジョブをトリガーしたユーザの情報から自動でそのユーザにメンション付きでSlack通知するようにライブラリを作成して利用しています。

仕組みを簡単に説明すると、トリガーしたユーザのメールアドレス(Slackに登録しているアドレスと同一)を元に、SlackのIDを検索してそのユーザへメンションするための文字列である <@UserID> を返却しています。2

// var/gGetSlackUsersMentionString.groovy
import java.util.concurrent.TimeUnit
import groovy.json.*

@Grab( group='com.squareup.okhttp', module='okhttp', version='2.7.5' )
import com.squareup.okhttp.OkHttpClient;
import com.squareup.okhttp.Request;
import com.squareup.okhttp.RequestBody;
import com.squareup.okhttp.Response;

def call( Map params = [:] )
{
  // 必須パラメータチェック
  if ( params.userEmails == null ) {
    println "gGetSlackUsersMentionString: 'userEmails' param is not specified."
    return
  }
  // 必須パラメータチェック
  if ( params.slackAPIToken == null ) {
    println "gGetSlackUsersMentionString: 'slackAPIToken' param is not specified."
    return
  }
  
  String slackApiUrl = 'https://slack.com/api/'
  String apiMethod = 'users.list'
  String query = 'token=' + params.slackAPIToken;
  String requestUrl = slackApiUrl + apiMethod + '?' + query

  Request request = new Request.Builder()
                          .url( requestUrl )
                          .header( 'User-Agent', 'jenkins' )
                          .get()
                          .build();
  
  OkHttpClient client = new OkHttpClient();
  client.setConnectTimeout( 5, TimeUnit.MINUTES );    // connect timeout
  client.setReadTimeout( 5, TimeUnit.MINUTES );       // socket timeout

  // Response
  Response response = client.newCall( request ).execute();

  // json へ変換
  Object json = new JsonSlurper().parseText( response.body().string() )
  
  // User の mention 用 ID 文字列
  String mentionIdsStr = '';

  // email から User ID を検出
  for ( user in json.members ) {
    for ( target in params.userEmails ) {
      if ( user.profile.email == target ) {
        mentionIdsStr += '<@' + user.id + '> ' 
      }
    }
  }

  return mentionIdsStr;
}
// Jenkinsfile
@Library( 'mylibrary' ) _

pipeline {
  agent { label 'master' }
  stages {
    stage ( 'sample' ) {
      steps {
        script {
          def mentionStr = gGetSlackUsersMentionString( userEmails: [ EMAIL_ADDRESS ], slackAPIToken: SLACK_API_TOKEN )
          // Slack Notification Plugin の提供メソッド
          slackSend( color: 'good', message: "${mentionStr}\n Message from Jenkins Pipeline" )
        }
      }
    }
  }
}

上記のように利用することで、ジョブをトリガーしたユーザへのメンション付きでSlack通知することを可能にしています。

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

4.3. ジョブ失敗時のstage名の取得

こちらもSlackに通知する際の便利機能の1つになります。Jenkins Pipeline Jobでは複数のstageが定義されています。ジョブが失敗した際のSlack通知にて、どのstageで失敗したかも補足情報として通知されているとエラー箇所の確認などに役立ちます。しかし、エラーとなったstageを特定するのは以下のように面倒であり、Jenkinsfileへのコード量も増えてしまいます。

// Jenkinsfile
pipeline {
  agent { label 'master' }
  stages {
    stage( 'sample' ) {
      ....
    }
    post {
      // 失敗となった場合に stage 名称を環境変数に設定
      failure {
        script {
          env.ERROR_STAGE='sample'
        }
      }
      // ここまでがエラー stage 名称の設定
    }
  }
  // Declarative: Post Actions
  post {
    failure {
      // Slack Notification Plugin の提供メソッド
      slackSend( color: 'danger', message: "ErrorStage: ${env.ERROR_STAGE}" )
    }
  }
}

上記のように全てのstageの post { failure { script {} } } ブロックにて env.ERROR_STAGE='ステージ名' というような設定を記載する必要があります。これはかなり面倒であり、多くのメンバーでメンテナンスをしていると記載を忘れてしまうこともあります。 そのため、最後の Declarative: Post Actions でfailureの際にエラーとなったstageを取得できれば余計なコードもなくなりスマートになります。Pipeline Jobの環境変数などでエラーstage情報が提供されていないため、Jenkinsのクラスからstageを取得する以下のライブラリを作成して利用しています。

// vars/gGetFailedStageName.groovy
#!/usr/bin/env groovy

import hudson.model.Run;
import org.jenkinsci.plugins.workflow.job.WorkflowRun;
import org.jenkinsci.plugins.workflow.graphanalysis.ForkScanner;
import org.jenkinsci.plugins.workflow.pipelinegraphanalysis.StageChunkFinder;
import com.cloudbees.workflow.rest.external.StageNodeExt;
import com.cloudbees.workflow.rest.external.StatusExt;
import com.cloudbees.workflow.rest.external.ChunkVisitor;

def call() {
  String errorStageName = '';

  WorkflowRun workFlowRun = currentBuild.getRawBuild();
  ChunkVisitor visitor = new ChunkVisitor( workFlowRun );

  // stage 情報の取得
  ForkScanner.visitSimpleChunks( workFlowRun.getExecution().getCurrentHeads(), visitor, new StageChunkFinder() );

  // 全ての Stage のステータスを検索
  for ( StageNodeExt stageExt : visitor.getStages() ) {
    if ( stageExt.getStatus() == StatusExt.FAILED ) {
      errorStageName = stageExt.getName();
      break;
    }
  }

  return errorStageName;
}
// Jenkinsfile
@Library( 'mylibrary' ) _

pipeline {
  agent { label 'master' }
  stages {
    stage ( 'sample' ) {
      ....
      error '強制的にエラー'
      // 以降の stage はスキップ stage のステータスはエラー状態
    }
  }
  // Declarative: Post Actions
  post {
    failure {
      // Slack Notification Plugin の提供メソッド
      slackSend( color: 'danger', message: "ErrorStage: ${gGetFailedStageName()}" )
    }
  }
}

上記のように利用することでエラーとなったstage名をSlackのメッセージ内に入れることが可能になります。

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

ただし、先頭から確認して最初にエラーとなったstage名のみ(上記のサンプルでは "sample" stage名のみ)を返却するようになっています。基本的には最初にエラーとなったstage以降のstageはスキップされ失敗した状態となっています。そのため、最初にエラーとなったstage名だけを返却する方針で良いと判断したためこのような仕組みとなっております。

5. 終わりに

今回は弊社で利用しているJenkins Piepline JobにおけるShared Librariesの利用事例を紹介しました。今回紹介したのは一部であり、Shared Librariesは独自クラスを定義してJenkins Pipeline Jobで利用が可能になるなど他にも多くの利用方法が挙げられます。Shared Librariesに関しては私たちも日々どのように利用するのが良いか色々と模索しています。ですので、自分たちも利用しているという方がおられましたら、これを機会にどのように活用しているかなどのノウハウや情報をお互いに交換できればと思っています。 もし、ざっくばらんに話してみたいなどありましたら井口(@hisa9chi)までご連絡いただければと思います。


  1. 例えばユーザID情報を取得のメソッドであるhudson.model.Cause$UserIdCause getUserId()など

  2. Shared Libraryを使ってSlackのUserIDを取得していますが、現在ではPluginでemailからSlackのUserIdを検索できるようになっています。slackUserIdFromEmail

大規模リポジトリで高速にgit cloneするテクニック

ニッチな話題ですが、業務におけるCI/CDの現場では避けることのできない大規模リポジトリと戦うためのgit cloneのテクニックを紹介します。

この記事はDeNA Advent Calendar 2020の10日目の記事です。
CI/CDマニアの@Kesin11です。SWETではCI/CDチームの一員として、CI/CDの啓蒙活動やJenkinsを必要とするチームのサポートなどの業務を行っています。

はじめに

おそらくどこの会社でも1つぐらいは巨大なリポジトリが存在しているかと思いますが、歴史あるリポジトリはgit cloneするだけで数分を要し、checkout後のリポジトリサイズがGB単位になることも珍しくないでしょう。業務で古くから存在するプロジェクトのリポジトリを触ったことがある方はきっと経験があるかと思います。

git cloneを実行するのは最初のセットアップ時だけなのであまり問題にならないと思われるかもしれませんが、実は1日に何度もgit cloneを行う環境もあります。そう、CI/CDの環境ですね。

CircleCIやGitHub Actionsといった一般的なCIサービスでは、毎回まっさらな環境にリポジトリをgit cloneするところからスタートします。巨大リポジトリではこの1ステップ目のgit cloneだけで数分かかってしまうため、単にLintを行うだけのジョブであってもすぐに結果が返ってこないという状況になります。

gitでは仮にファイルを削除しても昔のコミットから復元可能です。つまり、過去全ての情報が保存されているためリポジトリは歴史と共に太っていく一方であり、改善の見込みもありません1

ところで、git cloneは知らない人がいないほどポピュラーなコマンドですが、実はオプションが豊富に存在することはご存知でしょうか?手元でman git-cloneを実行してみください。きっと知らないオプションがたくさんあり驚くと思います。オプションの中には不要な過去のコミット、ブランチ、ファイルなどを取得しないことでgit cloneを効率的にできるものがありますので、今回はこれらを活用するテクニックをお伝えします。

git cloneの各種オプション

オプションなし

この後に各種オプションを駆使したgit cloneを紹介するのですが、まずは比較のために何もオプションを付けないときの実行時間と、checkout後のリポジトリのサイズを計測しておきます。実験に使用するリポジトリには、歴史がとても長いgit/gitを採用しました。

$ time git clone git@github.com:git/git.git
Cloning into 'git'...
remote: Enumerating objects: 279, done.
remote: Counting objects: 100% (279/279), done.
remote: Compressing objects: 100% (99/99), done.
remote: Total 298430 (delta 193), reused 248 (delta 180), pack-reused 298151
Receiving objects: 100% (298430/298430), 148.71 MiB | 10.06 MiB/s, done.
Resolving deltas: 100% (222617/222617), done.
Updating files: 100% (3866/3866), done.

real    0m27.057s
user    0m14.959s
sys 0m3.038s

$ du -sh git git/.git
214M    git
169M    git/.git

時間についてはインターネット回線、時間帯などに依存するためあくまで参考程度にしてください。時間に加えてcheckout後のリポジトリそのものと、昔のコミットやgitのオブジェクトなどの情報を全て格納している.gitだけのサイズを計測しています。

これを基準とし、どこまで改善できるのかを見ていきましょう。

depth

まずは知ってる人も多いであろうdepthです。depthを指定すると、その数のコミットの歴史だけに限定したデータを取得します。これをshallow cloneと呼ぶことがあり、gitやCIサービスのドキュメントなどにも頻出しますので、shallow = depthなどのオプションによって歴史を省略したcheckoutと覚えておくとよいです。

過去のコミット分の情報が不要ということは、昔のファイルの状態を記録しているGitオブジェクトが軽くて済むということです。2

ではどれほど効果があるのか試してみましょう。

$ time git clone --depth=1 git@github.com:git/git.git git_depth1
Cloning into 'git_depth1'...
remote: Enumerating objects: 3960, done.
remote: Counting objects: 100% (3960/3960), done.
remote: Compressing objects: 100% (3506/3506), done.
remote: Total 3960 (delta 356), reused 1917 (delta 287), pack-reused 0
Receiving objects: 100% (3960/3960), 9.30 MiB | 7.25 MiB/s, done.
Resolving deltas: 100% (356/356), done.
Updating files: 100% (3866/3866), done.

real    0m11.401s
user    0m0.466s
sys 0m0.862s

$ du -sh git_depth1/ git_depth1/.git
 56M    git_depth1/
 11M    git_depth1/.git

(繰り返しになりますが、git cloneにかかる時間自体は様々な要因によって安定しない可能性が高いので、あくまで参考にしてください)

--depth=1を指定すると、指定したブランチ(指定しない場合はデフォルトブランチ)の最後のコミットだけを取得します。オプションを何も付けなかった場合と比較して、時間は1/2以下、リポジトリの容量は約1/4で.gitだけに限定すれば約1/15程度になりました。時間に関しては誤差があるとはいえかなり改善できました。

代わりに、depth付きでgit cloneしたリポジトリではgit logをしても過去のコミットを見ることはできませんし、昔のコードをcheckoutもできません。とりあえず最新のリポジトリの状態でビルドしたいというCIなどの用途においては過去のコミットの情報は不要なので、メリットだけを享受できるでしょう。

single-branch

git cloneについて調べるとdepthの次に目にすることが多いのはsingle-branchではないでしょうか。その名の通り、単一のブランチだけの情報を取得するので通常よりは取得するデータ量が減ります。しかし、depthとは異なり指定したブランチの歴史は全て取得するので、削減量としてはdepthには及ばないはずです。実際に試してみましょう。

$ time git clone -b master --single-branch git@github.com:git/git.git git_single_branch
Cloning into 'git_single_branch'...
remote: Enumerating objects: 832, done.
remote: Counting objects: 100% (826/826), done.
remote: Compressing objects: 100% (804/804), done.
remote: Total 289165 (delta 28), reused 814 (delta 22), pack-reused 288339
Receiving objects: 100% (289165/289165), 143.57 MiB | 9.93 MiB/s, done.
Resolving deltas: 100% (216511/216511), done.
Updating files: 100% (3866/3866), done.

real    0m25.670s
user    0m13.348s
sys 0m2.899s

$ du -sh git_single_branch/ git_single_branch/.git
201M    git_single_branch/
155M    git_single_branch/.git

期待通りの結果になりました。通常よりは多少軽くなりましたが、depth=1には及びません。single-branchを使う場合の注意点としては、今後fetchやpullをした場合でもsingle-branchで指定したブランチ以外のリモートブランチは取得されないことです。つまり、pull-requestが出ているブランチをcheckoutしてエディタ上でコードレビューする、みたいな使い方はできなくなります。

この挙動になる理由はfetchのrefspecを見てもらうと分かります。各オプションでgit cloneしてきたリポジトリで比較してみます。

$ git config --get remote.origin.fetch

# 通常
+refs/heads/*:refs/remotes/origin/*

# depth=1
+refs/heads/master:refs/remotes/origin/master

# -b master --single-branch
+refs/heads/master:refs/remotes/origin/master

refspecを解説するとそれだけで1つ記事が書けてしまうので今回は省略しますが、depthsingle-branchのどちらもこのrefspecによって取得するブランチをmasterだけに限定していることが高速化3のポイントです。refspecについてもっと深く知りたい方はドキュメントを見てみましょう。

補足:depth=1single-branchの併用について

git cloneの高速化でよく目にする例として、git clone --depth=1 -b master --single-branchのように2つのオプションを併用するものですが、これは高速化の観点では意味はないはずです。なぜならdepthを指定している時点で取得するコミットの歴史は制限されていますし、先ほど示したようにrefspecも特定のブランチに限定されているためです。

shallow-since

これは少しマイナーなオプションですがdepthの日付版だと思ってください。depthではコミットの数で限定しましたが、shallow-sinceはある時点以降のコミットに限定して取得できます。CIの用途ではdepthで十分だと思いますが、例えば歴史が長いリポジトリにおいてある時期に全体的に書き換えてv2にしたのでもう昔のv1時代のコミットはgit logで見ることもない、というケースなどで自分のマシンにcloneする場合は便利かもしれません。

$ time git clone --shallow-since="2020/01/01" git@github.com:git/git.git git_shallow_since1
Cloning into 'git_shallow_since1'...
remote: Enumerating objects: 27410, done.
remote: Counting objects: 100% (27410/27410), done.
remote: Compressing objects: 100% (12816/12816), done.
remote: Total 27410 (delta 19562), reused 20870 (delta 14326), pack-reused 0
Receiving objects: 100% (27410/27410), 26.83 MiB | 9.33 MiB/s, done.
Resolving deltas: 100% (19562/19562), done.
Updating files: 100% (3866/3866), done.

real    0m17.672s
user    0m2.304s
sys 0m1.225s

$ du -sh git_shallow_since1/ git_shallow_since1/.git
 74M    git_shallow_since1/
 28M    git_shallow_since1/.git

本当に2020/01/01以降のコミットだけを取得しているのか調べてみましょう。結果に1つだけ2019-12-31が含まれていますが、おそらくタイムゾーン周りをあまり厳密に考慮しなかったからだと思われます。

$ git log --format="%H %as" | tail
0d2116c6441079a5a1091e4cf152fd9d5fa9811b 2020-01-05
9d48668cd51220f1d8b83c7e1aa65416520aeee6 2020-01-04
3a05aacddd8e3d65ba7988dc6e4f8a88bc4e3320 2020-01-04
4c5081614c0fa5a9060ae294cc4364f990254f04 2020-01-03
5bb457409c15d221ee38240f83362b3c6fd96421 2020-01-03
63020f175fe26f3250ac8d19d02ef9ee271006e5 2020-01-02
224c7d70fa14ed44d8e7e3ce1e165e05b7b23725 2019-12-31
9c8a294a1ae1335511475db9c0eb8841c0ec9738 2020-01-02
763a59e71cee1542665e640f63141a0bf89e6381 2020-01-01
44143583b76decf93c55b73adaf2367c22c88998 2019-12-31

sparse-checkout

ここまでは過去のコミット、つまり時間方向へのデータ量削減でした。別の方向としてcheckoutするファイルを限定するということも可能です。

sparse-checkoutによって、巨大なリポジトリの中から必要なファイルとそれに関係するコミットだけを取得できます。例えば、複数のプロジェクトが1つのリポジトリに同居するmonorepo構成の場合に、自分の作業に関係するプロジェクトだけをcheckoutできれば十分というケースで重宝します。

これを実現するためのgit sparse-checkoutというコマンドがv2.25.0から追加されたようなのですが、v2.29.0時点ではまだEXPERIMENTALとのことなのでここでは昔ながらの方法を使います。

# 自前でディレクトリを作って空のリポジトリを作るところからスタート
$ mkdir git_sparse
$ cd git_sparse
$ git init
$ git remote add origin git@github.com:git/git.git

# git/gitのDocumentationディレクトリのみをcheckoutするように設定
$ git config core.sparsecheckout true
$ echo "Documentation" > .git/info/sparse-checkout

# ここで初めてデータを取得しにいく
$ time git fetch --depth=1 origin master
remote: Enumerating objects: 3960, done.
remote: Counting objects: 100% (3960/3960), done.
remote: Compressing objects: 100% (3506/3506), done.
remote: Total 3960 (delta 356), reused 1918 (delta 287), pack-reused 0
Receiving objects: 100% (3960/3960), 9.30 MiB | 7.35 MiB/s, done.
Resolving deltas: 100% (356/356), done.
From github.com:git/git
 * branch            master     -> FETCH_HEAD
 * [new branch]      master     -> origin/master

real    0m11.582s
user    0m0.259s
sys 0m0.126s

# `git init`で作られたmasterブランチとfetchしたorigin/masterは全く別物なので、masterを上書きして完成
$ git reset --hard origin/master

$ du -sh git_sparse/ git_sparse/.git
 16M    git_sparse/
 10M    git_sparse/.git

git cloneが裏で行っている手順を手動で行っているので手間がかかりますが、こうして出来上がったリポジトリはgit/gitのDocumentationディレクトリだけがcheckoutされた状態なのでとても軽くなりました。

git cloneのオプションまとめ

ここまでの結果をまとめるとこのようになります。

オプション 時間(timeのreal) checkout後のサイズ .gitのサイズ
なし 0m27s 214M 169M
--depth=1 0m11s 56M 11M
-b master --single-branch 0m25s 201M 155M
--shallow-since="2020/01/01" 0m17s 74M 28M
sparse-checkout + depth=1 0m11s 16M 10M

実行時間に関してはあくまで参考値ですが、depthを付けると相当に改善が見込めそうです。checkout後のリポジトリのサイズは、当然ですがsparse-checkoutによってファイルを限定できれば無駄な容量を削減できることが分かるかと思います。

submodule

ここまではgit cloneによる1つのリポジトリのケースを見てきましたが、実際の業務ではsubmoduleをたくさん抱えているリポジトリに出会うこともあるかと思います。git cloneにはcloneと同時にsubmoduleを取得し、さらにそのときの振る舞いを制御するオプションも存在します。

歴史あるOSSをいくつか調べてみたのですがサンプルになるようなsubmoduleをたくさん抱えているリポジトリが見つからなかったため、社内のリポジトリを使って実験しました。そのため、詳細なコマンドは省いて結果だけを示します。

オプション 時間(timeのreal) checkout後のサイズ .gitのサイズ
なし(--recurse-submodules 3m15s 4.2G 2.7G
--depth=1 --recurse-submodules --shallow-submodules 2m2s 2.5G 1.0G
--depth=1 --recurse-submodules --shallow-submodules -j4 1m57s 2.5G 1.0G

--recurse-submodulesgit submodule update --init --recursiveと同じ処理をgit cloneと同時にするオプションです。git cloneだけでsubmoudleのセットアップも完了するので便利です。

--shallow-submodulesを付けるとsubmoduleのリポジトリがdepth=1git cloneされます。submoduleのリポジトリについて過去のコミットが不要であれば速度の向上、少なくともリポジトリのサイズを減らすことができます。

-j4(--jobs=4)はドキュメントによるとsubmoduleのfetchを同時に行う数とのことです。個人的には4並列にすることでもう少し時間が短縮されることを期待していたのですが、ほぼ変化はありませんでした。

今回実験したリポジトリはsubmoduleの数自体は多いのですが、一部のsubmoduleだけが突出してサイズが大きいという構成上、並列化の恩恵を受けにくかったのかもしれません。あるいは社内ネットワークに由来する何かがボトルネックだった可能性もありますが、今回の本題から外れてしまうので深く調査は行いませんでした。

各CIサービスの対応状況

git cloneの各オプションの効果について理解できたと思いますので、実際のCIサービスでこれらが活用できるのかを見ていきましょう。

なお、調査したのは執筆時点の2020/12です。各サービスとも日進月歩で更新されていますし、フォーラム等で機能要望を受け付けているので将来的には改善されているかもしれません。

CircleCI

CircleCIでは何も意識せずに最初のステップでcheckoutしていると思いますが、実はこの裏ではdepthオプション無しでgit cloneが実行されているため過去の全コミットが取得されています。これはCircleCIのログでCheckout codeのステップのログから確認できます。

CircleCIのサポートからは以下のページにて、checkoutの代わりに自前で--depth--shallow-sinceオプション付きでgit cloneするか、そのような機能を提供しているOrbsの利用が提案されています。

Shallow Repository Cloning

他に探したところ、30GBを超える超巨大なリポジトリをdepthsparse checkoutで対応した事例もあるようです。

巨大なリポジトリのJenkinsからCircleCIへの移行においてshallow cloneとsparse checkoutで前処理を高速化する

Bitrise

Bitriseのsteps-git-cloneはデフォルトではdepthのオプションが有効になりません。ですが、clone_depthというオプションを設定するとdepthオプション付きでcloneされるようになります。

https://github.com/bitrise-steplib/steps-git-clone/blob/b1cbe45b5704863c4c930111edeb7aa67d38f5c1/step.yml#L101-L107

sparse checkoutや、submoduleへのdepthの対応は自分がコードを読んだ限りでは無いように見えました。それらの対応が必要な場合は自分でgit cloneのコマンドを書く必要があります。

GitHub Actions

GitHub Actionsのactions/checkoutは、v2からデフォルトでdepth=1のオプションが有効になっています。逆にdepth無しでcloneが必要な場合はdepth: 0を指定するようにとREADMEに書いてあります。

submoduleについてはREADMEに詳細は書かれていませんでしたが、fetch-depthを指定するとsubmoduleを取得するときにもdepthが有効になりそうです。(v2.3.4のコードより)

https://github.com/actions/checkout/blob/v2.3.4/src/git-command-manager.ts#L317

sparse checkoutはサポートされていないようでしたので、必要な場合は自分で一連の処理を書く必要があります。

まとめ

大規模リポジトリでgit cloneするときのニッチなテクニックを紹介しました。

業務のリポジトリは何年も生き続けて肥大化していく一方なので、CIにおける最初のgit cloneにいつの間にか数分かかってしまうということも珍しくありません。そこはCIサービス側がいい感じに対応してくれていると思いきや、意外とそんなことはなかったりします

git cloneのオプションについて理解しておくことで、CIサービス側が用意している機能にオプションを渡すだけで改善の余地があるのかどうかを判断できるようになります。最悪、用意されている機能には頼らずに自分でgit cloneのコマンドを書くことでCIの実行時間を短縮できる可能性があります。

git cloneのオプションは他にもたくさん存在しており、なかなか奥深いのでman git-cloneを実行するか、Webのドキュメントをぜひ一読してみてください。

宣伝

DeNAでは今年、以下の3種のアドベントカレンダーを書いてます!それぞれ違った種類なのでぜひ見てみてください。

この記事を読んで「面白かった」「学びがあった」と思っていただけた方、よろしければTwitterやfacebook、はてなブックマークにてコメントをお願いします。

また DeNA 公式 Twitter アカウント @DeNAxTech では、 Blog記事だけでなく色々な勉強会での登壇資料も発信してます。ぜひフォローして下さい!


  1. コミットの歴史を修正してファイル自体がなかったように歴史を改変してしまう場合はその限りではないですが、相当に難しい作業です。

  2. .git/objectsの中身について興味がある方は10.2 Gitの内側 - Gitオブジェクトが参考になるでしょう。

  3. このrefspecについて理解できるとPinterestで1行追加しただけでgit cloneが99%高速化されたという理由が分かります。

Goによるロードテスト

はじめに

SWETグループGoチームの金子 (@theoden9014) です。

弊社が運営するライブコミュニケーションアプリであるPococha(ポコチャ)においてロードテストを実施する際、Go言語を利用して独自のロードテストツール開発しました。今回は、その知見を共有したいと思います。

20201201171506

この記事はDeNA Advent Calendar 2020の4日目の記事です。

本記事ではシステムをWebシステムと前提としていますのでご注意ください。

ロードテストとは (Load Testing)

JSTQBISTQBにおいては性能テスト (Performance Testing) の1つと定義されています。 性能テストとはシステムテストにおける非機能テストの1つです。 ロードテスト、耐久性テスト、ストレステスト等があり、それぞれ目的に応じて実施します。

ロードテストは、システムがユーザ負荷のピーク時でも継続して稼働できるか検証します。 サーバ等のインフラも含めて本番規模のシステム構築するケースが多く、非常にコストが掛かる検証です。 よって、システム導入時や大規模なシステム改修時など、要所要所で単発的に実施する事が多いです。

検証する観点はサービスの性質やシステムの特性によって違うので一般化することは難しいです。例えば、想定されるスパイクアクセスに耐えられるか、想定される同時アクセス数を捌く事ができるかのような観点で検証します。

ロードテストはパフォーマンスの目標値をまず最初に設定します。この目標値を満たすことができるかロードテストツール使って検証し、満たしていない場合はシステム全体でボトルネックとなっている箇所を特定し、そのボトルネックを改善します。

この検証、分析、改善のサイクルを目標値を満たすまで繰り返し行います。

ロードテストにおいてボトルネックの特定は最も重要な要素です。 ロードテストを実施する際にはロードテストツールのメトリクスでは不十分で、システムの可観測性を高めておく必要があります。 しかし、今回は記事の主旨がロードテストツール側にあるので、検証対象のシステムについての可観測性については割愛させていただきます。

ロードテストツールの選定

最初に設定した目標値を元に、具体的な負荷シナリオや変動させるパラメータの設計をします。

静的なリクエストを生成するにはapachebenchvegetaなどのツールがあります。 ロジックに基づいたリクエストパラメータの生成やURLの生成などの、動的なリクエストを生成するにはGatlingJMeterLocust等のツールがあります。

このようなロードテストツールは他にもいくつかあります。 しかし、どうしてもユースケースに合わなかったり、欲しい機能が存在しなかったりといったケースが発生することもあります。 そういった場合は既存ツールを拡張するか、独自でロードテストツールを開発することになります。

今回のロードテストの要件

今回は今後想定されるユーザ数のリクエストを問題なく捌けるか検証したかったので、重要機能においての特定ユースケースのロードテストを実施しました。 同時利用ユーザ数、APIエンドポイント毎の秒間実行回数を目標値として設定し、その他変動させる必要のあるパラメータの設計をしました。

今回は様々な要件がありましたがその中でも難しかったのは、キャッシュのヒット率を下げるためにユーザの行動に重み付けをした上でランダム性を持たせなければならない、というところでした。例えば、ライブ視聴中におけるユーザの行動を挙げると以下のようなパラメータです。

  • コメントやアイテムやいいねの送信、他ユーザへの拍手、等々の行動の選択
  • 送信するコメント内容、アイテムの種類、拍手する対象ユーザの選択

認証やライブ視聴中のKeepAliveなど他にも技術的な要件や課題がいくつかあり、既存のロードテストツールでそれらを実現しようとすると通常の機能だけでは難しいと判断し、今回は使い慣れているGo言語を使ってロードテストツールを独自で実装することにしました。

Goによるロードテストツールの実装

Goではgoroutineとchannelを利用することで並行処理をシンプルに記述できます。 今回は以下のようなアーキテクチャで開発しました。

20200930130505

全てのコードの解説は行えないので、今回はポイントとなるコンポーネントであるEvent (+EventGenerator) とDispatcherを抜粋してご紹介します。 EventGeneratorではリクエストパラメータにランダム性を担保させるようにし、Dispatcherではそれを実行するユーザにランダム性を担保させるようにしています。

まずEventについてです。 Eventは処理を行う単位としており、行いたい処理の種類によって構造体とそれを生成するための関数を定義していきます。

一定の間隔でランダムなパラメータの生成を行い、channelに対してEventを送信します。

import (
    "context"
    "time"
)

type Event interface {
    Emit(ctx context.Context, c *APIClient) error
    Name() string
}

type commentEvent struct {
    // イベント生成側と処理側で共有するパラメータをここに記述します
    text string
}

// イベントの処理内容、複数のAPIで整合性が必要な処理もここにまとめます
// ここでは認証などの必要な初期化処理を行ったAPIClient(ユーザ)を再利用したいので引数で渡せるようにしています
func (e *sendCommentEvent) Emit(ctx context.Context, c *APIClient) error {
    if err := c.LiveViewing.SendComment(ctx, e.text, e.toUserID); err != nil {
        return err
    }
    return nil
}

// トレーシングや統計情報を集計する際に別のイベントと区別する為のイベント名
func (e *sendCommentEvent) Name() string {
    return "request: send comment"
}

// 任意のユーザがコメントを送信するイベントを発生させます
func commentEventGen(ctx context.Context, d time.Duration) chan Event {
    // EventGenはd間隔でイベントを生成して戻り値のチャネルに対して送信します
    return EventGen(ctx, d, func(tm time.Time, ev chan Event) {
    // 今回は適当にイベント生成時の時間を生成しています
    // ランダム性のあるパラメータはここで担保できるようになります
        ev <- &commentEvent{
            text: tm.String(),
        }
    })
}

// EventGenはイベントを一定間隔で生成する為の関数を生成する為のヘルパー関数
// 第三引数の関数でパラメータを与えてEventを生成してチャネルに送信する処理を記述します
func EventGen(ctx context.Context, d time.Duration, f func(time.Time, chan Event)) chan Event {
    eventChan := make(chan Event)
    if d == 0 {
        return eventChan
    }

    go func() {
        ticker := time.NewTicker(d)
        defer ticker.Stop()

        for {
            select {
            case t := <-ticker.C:
                f(t, eventChan)
            case <-ctx.Done():
                close(eventChan)
                return
            }
        }
    }()

    return eventChan
}

次に、生成されたイベント毎のchannelを1つに集約し、各ワーカーに対して処理を委任していく役割であるDispatcherについてです。 このDispatcherは一番重要な箇所です。 様々な種類のEventを1つのchannelにまとめます。その1つのchannelを複数のワーカーが受信することによって偏りを持たせずに処理を行えるようになります。

1ワーカー1ユーザとなるようにしているので、これによって各ユーザが実行するイベントの種類にランダム性を持たせてることが可能になります。 今回は記事の尺の都合上、他コンポーネントについては割愛させていただくのでinterfaceとして定義しております。

import (
    "context"
    "sync"
)

type Worker interface {
    WaitEvent(context.Context) Event
    Process(context.Context, Event) error
    Close() error
}
type WorkerPool interface {
    Get() Worker
    Put(Worker)
}

type Dispatcher struct {
    mu     sync.RWMutex
    inputs []<-chan Event

    pool WorkerPool
}

// 処理するイベントのchannelをセットします
// 複数のchannelをセットすることが可能です
func (d *Dispatcher) In(ev chan Event) {
    d.mu.Lock()
    defer d.mu.Unlock()
    d.inputs = append(d.inputs, ev)
}

type WorkerFunc func(context.Context, Event) error

// これを利用してWorker追加時に、WorkerFuncのクロージャ内にAPIClientを閉じ込めることによって
// 1Worker---1APIClient--1User の紐付けを行うことができます。
func (d *Dispatcher) NewWorker(id string, f WorkerFunc, r <-chan Event) {
    w := &worker{
        id: id,
        f:  f,
        r:  r,
    }
    d.pool.Push(w)
}

// Startはchannelを繋ぎ合わせ、設定したWorkerを起動していくメソッドです
func (d *Dispatcher) Start(ctx context.Context) {
    // d.inputs のチャネルを1つのチャネルに集約していきます
    input := make(chan Event, len(d.inputs))
    for _, evc := range d.inputs {
        evc := evc
        go func(evc <-chan Event) {
            for {
                select {
                case ev := <-evc:
                    input <- ev
                case <-ctx.Done():
                    return
                }
            }
        }(evc)
    }

    // WorkerPoolからWorkerを取得して、
    // それぞれのWorkerをgoroutineでイベントループで起動していきます
    for w := d.pool.Get(); w != nil; w = d.pool.Get() {
        w := w
        go func() {
            defer w.Close()
            for {
                select {
                case ev := <-input:
                    if ev == nil { // closed Event channel
                        return
                    }
                    w.Process(ctx, ev)
                case <-ctx.Done():
                    return
                }
            }
        }()
    }
}

これらのコードを以下のように繋ぎ合わせることで、複数種類のイベントを、パラメータと実行するユーザにランダム性を持たせることができます。

// 第二引数でイベントの生成間隔を指定、
// それ以降の引数はGeneratorの生成アルゴリズムによって任意の値を指定
cChan := commentEventGen(ctx, 1/10*time.Second)
iChan := itemEventGen(ctx, 1/20*time.Second, itemIds...)
.
.
.
dispatcher := NewDispatcher()
dispatcher.In(cChan)
dispatcher.In(iChan)
.
.
.

// テスト用のユーザデータは予め用意しておいて読み込むようにしておきます
for _, user := range users {
    // クライアントの認証や、
    // その他細かいクライアント毎のセットアップ処理
    auth := NewDebugAuthenticator(user)
    // 今回はアプリケーションの負荷を確認したかったのでコネクションプールを共通化していますが、
    // インフラ側のテストも兼ねて共通化したくない場合は、
    // APIクライアント毎にコネクションプールを作った方が良いでしょう。
    client := NewAPIClient(baseURL, http.DefaultClient, auth)
    // 生成されたイベントはchannelを通じて、
    // クロージャの第二引数であるEventに渡ってきます
    dispatcher.NewWorker(user.Name, func(ctx context.Context, ev Event) error {
        return ev.Emit(ctx, client)
    })
}

dispatcher.Start(ctx)

ボトルネックの可視化

runtime/traceパッケージ

Goでは標準パッケージのruntime/traceを利用することでトレーシングを行うことができます。これを使うとランタイムレベルのトレースと、ユーザアノテーションを使ったトレーシングを行うことができます。 今回はユーザアノテーションを使ったトレーシングについてお話ししたいと思います。

ユーザアノテーションをするにはtrace.Tasktrace.Regionの2つを使って計測したい区間を自分で定義していきます。

trace.Taskはgoroutineを跨いで計測できます。 trace.Regionを使うとgoroutine内の細かいトレース情報を取得できます。しかし、計測範囲をgoroutine内に絞る必要があります。

トレーシングの実施方法

runtime/traceは常にトレーシングを行っているわけではなく、ランタイム内のトレーシングフラグが有効な際にトレーシングが行われます。 以下の2つの方法でトレーシングをフラグを有効にして結果を取得できます。

1つはテスト時にトレーシングを行う方法です。go test -trace=${OUTPUT_FILE}フラグを追加することで${OUTPUT_FILE}にトレース結果のファイルを生成できます。

もう1つはプログラム実行中にhttpハンドラー経由でトレーシングを行う方法です。net/http/pprofを空インポートをするとhttp.DefaultMuxにトレーシングとプロファイリングのエンドポイント用のハンドラーが追加されます。 http.ListenAndServe(":8080", nil) 等でHTTPサーバを立ち上げて:8080/debug/pprof/traceへアクセスするとデフォルトで30秒間、その間だけトレーシングを実施して結果を取得できます。

これらのトレース結果ファイルはgo tool trace ${OUTPUT_FILE} コマンドでWebUIから確認できます

ロードテストツールでの利用

今回はruntime/traceを利用してイベントの種類(今回はAPIエンドポイント)毎にRegionを定義しました。 go tool traceを使うとWebUIから以下のようにイベントの種類毎の処理時間分布を確認できるので、処理が遅くなるエンドポイントを特定できました。(以下の画像はサンプル用に用意したものです)

20201202210836

OpenCensusOpenTelemetryのような分散トレーシングを使っても良いとは思いますが、今回はオーバースペックだと判断したので利用はしませんでした。

参考までにですが、以下のようなMakefileを用意しておくと簡単にプロファイルとトレーシング結果を取得できます。

GO          := go
PPROF_URL   := http://localhost:6060/debug/pprof
DEBUG_TYPES := profile.pdf goroutine.pdf heap.pdf block.pdf mutex.pdf trace.out
DEBUGDIR    := ./debug

.PHONY: debug
debug: debug/clean $(DEBUG_FILES)

.PHONY: debug/clean
debug/clean:
    @rm -f $(DEBUGDIR)/*

$(DEBUGDIR)/%.pdf:
    $(GO) tool pprof -pdf -output $@ $(PPROF_URL)/$*?seconds=30

$(DEBUGDIR)/%.out:
    curl -fsL -o $@ $(PPROF_URL)/$*?seconds=30
    $(GO) tool trace $@

まとめ

これらによって、負荷を生成しつつAPIエンドポイント毎の処理時間の分布が簡単に確認できるようになり、ボトルネックとなっているAPIエンドポイントを特定できました。

このツールはiOSアプリのパフォーマンステストでも利用しているので、興味がある方はこちらの記事もご覧ください。

ご紹介したソースコードは実装の概要をご紹介する為に一部簡略化している箇所や書き換えている箇所がございますのでご注意ください。他にも、実際はgolang.org/x/time/rateを利用してトークンバケット方式でバースト制御して想定外のスパイク負荷を発生させないようにしたりといった様々な工夫をしておりますが今回は省略させて頂いております。

このようにGoを利用するとロードテストツールもシンプルに実装可能です。 みなさまがパフォーマンステストを行う際の選択肢の1つとして助けになると幸いです。

私事ではありますが、汎用化を目的としてパフォーマンステストフレームワークを趣味で開発しているので、もし興味がございましたらご覧ください。

https://github.com/theoden9014/evbundler

関連リンク

この記事を読んで「面白かった」「学びがあった」と思っていただけた方、よろしければTwitterやfacebook、はてなブックマークにてコメントをお願いします! また DeNA 公式 Twitter アカウント @DeNAxTech では、Blog記事だけでなく色々な勉強会での登壇資料も発信してます。ぜひフォローして下さい!

【改訂版】継続的にiOSアプリのパフォーマンスを計測する

はじめに

SWETグループiOSチームのkariad(@kariad_uu)です。

本記事はiOSDC 2020 Japanにて発表した「継続的にアプリのパフォーマンスを計測する」の内容を元にブログという形で改めて紹介する記事となります。 発表時のスライドは以下を参照ください。

iOSチームではiOSアプリのパフォーマンス計測に取り組んできました。 iOSアプリのパフォーマンス計測方法はたくさんありますが、中でもInstrumentsを利用したパフォーマンス計測とその自動化について紹介します。

なぜパフォーマンス計測が必要なのか?

取り組んだ計測方法についてご紹介する前にアプリにとってパフォーマンスとその計測がなぜ重要なのかという点を説明します。

起動に時間がかかる、読み込みに時間がかかるアプリは動作が遅くユーザにストレスを与えてしまいます。 短時間で熱くなってしまうアプリでは端末が熱くて持てなくなってしまうだけではなくバッテリーの消費量も大きくなってしまいます。 また場合によってはパフォーマンスが悪いことでアプリのクラッシュが引き起こされる可能性もあります。 こうした問題はアプリのユーザーの離脱を引き起こしてしまう可能性があります。

アプリのパフォーマンスについてAppleが以下のドキュメントを出しています。 https://developer.apple.com/documentation/xcode/improving_your_app_s_performance

このドキュメントによるとパフォーマンス改善のサイクルを回していくことが推奨されています。 改善のサイクルを回すことでパフォーマンスが、ユーザーが離脱してしまうほど悪化する前に発見、修正できます。

Instrumentsでパフォーマンスを計測する

InstrumentsはXCUITestに組み込んで自動的に計測するような自動化の仕組みはありません。 そのため自動化し、継続的にパフォーマンス計測をするためにはツールを組み合わせる必要があります。

Instrumentsを利用して今回の事例で計測する項目は、CPU使用率とThermal State(端末温度)です。1 以前アプリの性能が悪化した際に原因を調査したところ、CPU使用率が高くなることで端末温度が上昇し、クラッシュに繋がることが判明したからです。

そして今回SWETでは次のような構成で自動計測基盤を構築しました。

  • 最新のアプリ: Bitriseでビルド&resign
  • 実機: HeadSpin
  • 計測環境: Jenkins
  • アプリ操作用UIテストフレームワーク: XCUITest
  • 計測ツール: Instruments CLI
  • アプリに負荷をかけるツール: お手製
  • 計測結果確認手段: Slack

この構成での実行フローは次のようになります。

  1. Bitriseにてreleaseビルドでipaを作成
  2. ipaに対してdevelopment証明書でresign
  3. HeadSpinにipaを配布、dSYMをArtifactsとして保存
  4. BitriseからJenkins jobトリガーを実行
  5. Jenkinsで負荷生成ツールのDL、起動、HeadSpinへの接続等の準備
  6. 計測開始と完了
  7. 結果のtraceファイルをXCUITestを用いてSSの撮影とSlackへの投稿
  8. traceファイルなどをJenkinsのArtifactsとして保存

20200930210832

構成について

なぜこのような構成となったのか、注意するべきポイントとともに、実行フローに沿って説明していきます。

最新のアプリ

コードは毎日変更されます。パフォーマンスが悪化したことをいち早く知るため変更に追従した最新のアプリで計測する必要があります。場合によってはちょっと改善を試したブランチなどもありえます。 またInstrumentsでのデバッグ用にdSYMも必要となるためこれらを毎日ビルドをして用意する必要があります。 しかしただビルドをするだけではなく、Instrumentsで正しく計測できるためにはいくつかの条件を満たす必要もあります。

  1. コンパイラの最適化をreleaseビルドと同等にする。
  2. developmentのCertificateでsignする。

Xcodeのデフォルトの設定ではdebugビルドとreleaseビルドではコンパイラの最適化の設定が異なっています。 releaseビルドでは、開発中頻繁にビルドされるdebugビルドよりもアグレッシブな最適化が行われます。最適化の差異はパフォーマンス計測結果の差異に繋がるため2、実際にユーザーが利用するreleaseビルドと同等の最適化設定でビルドされたアプリを対象に計測する必要があります。

またInstrumentsで計測する際にはdevelopmentのCertificateでsignされている必要があり、distributionではエラーとなってしまいます。

これらをまとめると「releaseと同等の最適化設定を利用しながらdevelopmentのCertificateでsign」という条件になります。 これは一見矛盾しているようにも見えます。計測用のビルドのために設定を変える方法もありますが、今回は別の方法を採用することとしました。それがipaのresignです。

ipaのresignとは生成されたipaに対して、名前の通りsignし直すことができます。 今回の例だとreleaseビルドに対してdevelopmentのCertificateでresignします。 resignにはいくつか方法がありますが、今回はfastlaneのresignを利用することにしました。 fastlaneは次のように書くことで簡単にresignが実行できます。

    resign(
      ipa: ipa_path,
      signing_identity: "iPhone Developer: Xxxxxx Xxxxxxx(FFFFFFFFF)",
      provisioning_profile: {
        "com.swet.app" => provisioning_profile_path,
        "com.swet.app.notificationservice" => provisioning_profile_notification_path
      },
      display_name: "SWET Debug"
    )

これらのビルドは元々Bitriseを利用していたため、そのままBitriseで実施しています。 そして、Bitriseから作成したipaを計測端末に配布します。

実機

Thermal Stateを計測するためには実機での計測が必要となります。 しかし実機を自前で管理すると次のような理由からコストは高くなってしまいます。

  1. テストが走っているタイミングで他のテストが実行されないように排他制御の仕組みを持つ必要
  2. OSのバージョン管理が必要
  3. 物理的な設置が必要
  4. 現在のリモート環境において問題が発生した場合に出社が必要となる可能性がある

これらの問題点を解消するためにクラウド型のデバイスファームを利用するという選択肢があります。 クラウド型のデバイスファームはデータセンターなどに設置された実機を利用できるサービスです。その中の一部サービスではWebなどから実際の手元の実機と同じように操作できる機能が提供されています。クラウド型のデバイスファームの有名所ではAWS Device FarmやRemote Testkitなどがありますが、今回はHeadSpinというデバイスファームを利用することにしました。

HeadSpinを選択した理由はすでにSWETで契約済みであり、追加で費用がかからなかったという点が大きいです。 それ以外にもHeadSpinには以下のような便利な機能があるため利用しています。

  • Webから実際の画面を見ながら操作できる
  • Xcodeから直接Runできる
  • Wi-Fiだけではなく特定のSIMで4G回線での利用もできる
  • AppiumサーバーがHeadSpin側で用意されている
  • 端末単位の契約で他社と共有することがない

20200930154745

HeadSpinはAPIも充実しています。例えば次のようなものがあります。

  • ipaのインストールとアンインストール
  • デバイスのロック、アンロック
  • OSアップデートのポップアップを消す

Bitriseからのipa配布にもこのAPIを利用しています。 その他APIも今回の構成でいくつか利用しています。

計測環境

計測する環境については最大2時間を想定としていたことからBitriseは選択肢に入りませんでした。 (本ブログ執筆時点でBitriseの最大時間は90分) またCircleCIも弊社では契約していますが、Enterprise版を利用しており、macOSでの利用は弊社では利用できません。そうした状況からオンプレで汎用性のあるJenkinsが候補となりました。

Jenkinsを選択したことでJenkins自体の管理が必要になる、という課題が発生します。 しかしSWETにはCI/CDチームが管理しているJenkinsがあります。そのJenkinsに相乗りする形で新たに管理しなくてはいけない項目を増やさずに済みました。このJenkinsはXcodeの新しいバージョンが簡単にインストールできる仕組みなどそれ以外にも便利なしくみが揃っています。またJenkinsで躓いた場合相談できるメンバーが周りにいるという点もJenkinsを採用する上での心強いポイントでした。

そのCI/CDチームがCEDEC 2020で発表した内容がこちらです。 Jenkins周りが気になる方はぜひ御覧ください。

モバイルゲーム開発におけるJenkinsクラウド時代のJenkins構築と管理テクニック

アプリ操作用UIテストフレームワーク

計測のためには、UIテストフレームワークを介してアプリを計測対象画面まで遷移させます。 採用したUIテストフレームワークはXCUITestです。

しかし最初からXCUITestを利用していたわけではありませんでした。 最初はAppium x RSpec(Ruby)を利用していました。

ここでAppiumについて軽く説明します。 AppiumはオープンソースのUIテストフレームワークで様々な言語を利用して実装できます。 その特徴としてWebDriverを用いてアプリを操作します。

20200930210826

上図のとおりAppiumの実行にはAppiumサーバーが必要ですが、相性のよいことにHeadSpin自体がAppiumサーバーの実行環境を用意しています。そこで当初は、AppiumのほうがXCUITestより良い選択肢だと考えていました。

Appiumで起きた問題

Appiumを利用する上でいくつかの問題が発生しました。

  • HeadSpin上のAppiumサーバーを利用する場合Xcodeのバージョンを柔軟にコントロールできない
  • HeadSpin上のAppiumサーバーを利用する場合traceファイルの転送が必要となり転送先を用意する必要がある
  • 原因不明だがconnection errorが発生する

Instrumentsの実行はAppiumサーバーと同じ環境で実行されます。そのため今回の場合はHeadSpin側でInstrumentsも実行されることとなり、向こう側のXcodeのバージョンに依存することとなります。 またInstrumentsの計測結果であるtraceファイルの転送も必要となり、転送先を考える必要がありました。 これらを考慮したときにJenkins上でAppiumサーバーを持つことで、Xcodeのバージョンは簡単に切り替えることができ、traceファイルもJenkinsのArtifactsとして保存すれば良いだけとなることからJenkins側でAppiumサーバーを持つ形へと変更することとなりました。

最後の問題として、負荷をかけ続けた状態で一定時間経つとAppiumで新しいコマンドを実行した際にconnection errorが発生しました。 原因を調査しましたが、詳細はわかりませんでした。しかし前述の理由でAppiumサーバーをJenkinsで立てることになり、Appiumを使い続ける理由がほぼなくなっていたため、XCUITestの挙動を確認することにしました。 すると安定して動作したためXCUITestへ移行したというのが現在です。

アプリに負荷をかけるツール

アプリに負荷をかけ続けた状態でのパフォーマンスを計測することにしました。 そのため、SWETの他チーム(Goチーム)のメンバーと協力して負荷生成ツールを作成しました。 このツールをJenkins上で同時に実行しています。

計測ツール

計測ツールについては以下の理由からInstrumentsを利用することにしました。

  • すでにパフォーマンスが問題となっており、ユーザーへ届ける前に問題がないかを知る必要があった
  • 問題がある場合は素早くその原因を特定し解決したい
  • 一定時間の負荷をかけた状態での端末の温度とCPU使用率の変化を計測したい

これらを満たせるのが現時点ではInsturmentsしかありませんでした。

InstrumentsはXcodeの一部として提供されているツールです。 アプリのパフォーマンスを計測するだけではなく、どこの処理のパフォーマンスが悪いのかまで特定できます。 今回はその特定まで行いたいという動機がInstrumentsを採用した大きな理由の1つです

Instruments実行方法

InstrumentsはInstruments.appとInstruments CLIという2種類に分けることができます。 結果の確認はInstruments.appでしかできませんが、計測だけならばどちらでも可能です。 そのため、コマンド1つで実行できるInstruments CLIを利用しました。 Instruments CLIはAppiumからも利用できます。

計測結果確認手段

Instrumentsではtraceファイルから計測結果を定量的な数値で取得する方法がありません。 (実際にはある程度取れる外部ツールが存在したのですが、この時点では存在を知りませんでした) そのため、結果の確認にはtraceファイルをInstruments appで開き、GUIで確認する必要がありました。 しかし負荷をかけ続けての長時間の計測ともなるとファイルサイズが150〜200MBと大きく、Instruments appで開くまでに1分以上かかることもあります。これを毎回手動で結果確認をするのは面倒です。

その解決策としてInstruments appをXCUITestで操作してスクリーンショットを撮影、Slackに投稿させるという方法を取りました。 Thermal Stateは次のような画面で結果を確認できます。

20200930154923

state 説明
Nominal 平常時、問題がない状態
Fair 対応が必要なほどではないが熱くなり始めている
Serious 非常に熱く、アプリ自体にも影響が出始める
Critical 今すぐに冷やす必要がある

またこの各stateの開始時間と終了時間がThermal Stateのグラフを選択することで詳細な情報としてみることができます。

続いてCPU使用率についても実際のスクリーンショットをもとに説明していきます。

20200930155118

CPU使用率に関してはGUIでもとても数値が見づらいです。 CPU使用率の絶対値はグラフでしか確認できません。 しかもそのグラフでさえもグラフの天井へ張り付いたときの数値が固定ではなくその計測回の一番高い数値になります。そのため複数の計測結果のグラフを並べてもグラフだけではどちらのCPU使用率が高いかわかりません。グラフにマウスカーソルを合わせることで初めて具体的な数値がわかります。 またグラフを選択した際に表示される詳細情報では選択した時間での全体のCPU使用率を100%とした場合の具体的な処理毎の使用率内訳が表示されます。つまり全体の使用率の平均などを見ることはGUIでもできません。

これらのスクリーンショットを撮影してSlackに投稿していますが、Thermal Stateについては詳細から各stateの文字列も取得できるため、そこからCriticalに到達したら失敗させるといったことも可能になりました。 一方でCPU使用率についてはスクリーンショットを撮影していますが、それだけではわからずtraceファイルをInstruments appで見なくてはわからないままです。

20200930155209

パフォーマンス計測で注意するべきこと

これらを組み上げる間には手元で確かめたりと様々な試行錯誤を重ねました。その中でパフォーマンス計測していく上で注意する必要があると気がついた点があります。

端末の状態

計測は1パターンだけでなくいくつかのパターンで計測したい場合があります。 しかし連続して同じ端末で計測すると、前の計測で既にThermal Stateが上がりきった状態で計測を開始してしまうため正しい計測結果が得られません。 環境にもよりますが、端末が冷えて前回の影響を受けずに計測するためには30分~1時間の間隔を開ける必要があります。 この問題には私達も直面し、そこで取った対応として端末数を増やして、さらに次のような実行計画を組むことでなるべく時間のロスなく計測パターンに対応させました。

20201021194915

Thermal Stateなど、物理的に影響のある項目を計測する場合には前回の計測から間を空けて、影響を受けないようにすることが必要になります。

外部気温

空調などで完全に管理された空間で無い限り物理的な端末での端末温度の計測は外部気温の影響を受けます。 それにより冬では大丈夫でも夏にはとても熱くなってしまうということもありえます。 性能が悪化していなくてもユーザーからすると使いづらい状況になるため、パフォーマンスの改善したほうが良いでしょう。 また上記で連続して計測する際に端末が熱い状態で開始すると正しく計測できず、クールタイムが必要になると説明しました。このクールタイムも外部気温が高いほど端末は冷えにくくなるため同じ端末で連続して計測する際には注意が必要となります。

これからについて

まだまだ改善していきたいところやまだやれていないことがたくさんあります。 今後やっていきたいことには次のようなものがあります。

  • xctraceによる計測結果の確認
  • 計測項目の追加と結果確認方法の改善

xctrace

Xcode12からinstrumetsコマンド(Instruments CLI)がdeprecatedとなります。 代わりにxctraceというツールが追加されました。 このxctraceは基本的にはinstrumentsコマンドを置き換えたものになるのですが、新しい機能も追加されています。 その中で最も特徴的なものがXML形式でのデータのexportです。 これによりtraceファイルをInstruments appで開かずとも計測結果のデータを取得できるようになります。 実際にThermal Stateは詳細に表示されていた各stateの開始時間と終了時間がexportできるようになりました。 一方でCPU使用率についてはまだxctraceで取得できません。 これはそもそもグラフでしか表現されていないのが問題かもしれませんが、いつか対応されることを期待しています。

xctraceの使い方

ここでxctraceのexport機能について紹介したいと思います。 以前Xcode12についての記事を書いたのですが、その時点ではまだbeta版であり、なおかつxctraceに関するドキュメントがmanコマンド以外ほぼ存在しないという状態でした。 そのため詳細の紹介を控えたのですがめでたくXcode12がリリースされたため、私が試したexport機能について、私が理解した範囲で紹介します。 また予め断りを入れておきますが、manコマンド以外のドキュメントが無く手探りで試して得られた利用方法のため、正しくない可能性があります。ご了承ください。

xctraceにはrecord、import、export、list、helpの5つコマンドが存在します。 その中でもexportがxctraceから使えるようになったtraceファイルのデータをXMLで出力できるコマンドです。 exportコマンドは次のように使います。例としてTime ProfilerのTemplateで計測した結果を利用します。

いきなり詳細を出力する前に--tocでTOC(Table of contents、計測項目の一覧と思われるが明確な記載は無いので推測です)を出力します。

$ xcrun xctrace export --input xx.trace --toc 

すると次のようなXMLで表現された結果が得られます。

<?xml version="1.0"?>

<trace-toc>
    <run number="1">
        <info>
            <target>
                <device name="device name" uuid="udid"/>
            </target>
            <summary/>
        </info>
        <data>
            <table schema="tick"/>
            <table schema="device-thermal-state-intervals"/>
            <table target-pid="ALL" kdebug-match-rule="0" exclude-os-logs="0" schema="region-of-interest" signpost-code-map="&quot;&lt;null&gt;&quot;"/>
            <table schema="os-log" category="PointsOfInterest"/>
            <table target="ALL" schema="kdebug" codes="&quot;0x1,0x25&quot;"/>
            <table target="ALL" schema="kdebug" codes="&quot;0x2b,0xd8&quot;"/>
            <table target="ALL" schema="kdebug" codes="&quot;0x2b,0xdc&quot;"/>
            <table target="ALL" schema="kdebug" codes="&quot;0x1f,0x7&quot;"/>
            <table codes="&quot;0x2d,*&quot;" schema="kdebug" target="ALL" callstack="user"/>
            <table target="ALL" schema="kdebug" codes="&quot;0x2b,0x87&quot;"/>
            <table codes="&quot;0x31,0xca&quot;" schema="kdebug" target="ALL"/>
            <table category="InduceCondition" schema="os-signpost" subsystem="&quot;com.apple.ConditionInducer.LowSeverity&quot;"/>
            <table target-pid="ALL" kdebug-match-rule="0" exclude-os-logs="0" schema="roi-metadata" signpost-code-map="&quot;&lt;null&gt;&quot;"/>
            <table schema="life-cycle-period" target-pid="ALL"/>
            <table codes="&quot;0x2b,0x65&quot;" schema="kdebug"/>
            <table codes="&quot;0x1,0xa&quot;" schema="kdebug" callstack="user"/>
            <table schema="os-signpost" category="PointsOfInterest"/>
            <table schema="tick" frequency="1"/>
            <table codes="&quot;46,2&quot;" schema="kdebug" callstack="user"/>
            <table sample-rate-micro-seconds="1000" all-thread-states="NO" schema="time-sample" target="ALL" callstack="user"/>
            <table codes="&quot;0x21,0xa&quot;" schema="kdebug" callstack="user"/>
            <table schema="gcd-perf-event" target-pid="ALL"/>
            <table schema="os-signpost-arg" category="PointsOfInterest"/>
            <table target-pid="ALL" exclude-os-logs="0" schema="global-poi-layout" signpost-code-map="&quot;&lt;null&gt;&quot;" colorize-by-arg4="0"/>
            <table target-pid="ALL" kdebug-match-rule="0" schema="global-roi-layout" signpost-code-map="&quot;&lt;null&gt;&quot;" colorize-by-arg4="0"/>
            <table schema="os-log-arg" category="PointsOfInterest"/>
            <table target-pid="ALL" high-frequency-sampling="0" schema="time-profile" needs-kernel-callstack="0" record-waiting-threads="0"/>
            <table target-pid="ALL" schema="kdebug-signpost" signpost-code-map="&quot;&lt;null&gt;&quot;"/>
            <table schema="thread-name"/>
        </data>
    </run>
</trace-toc>

詳細を見るためにはこのTOC XMLの階層を--xpathオプションでたどる必要があります。 例えばこのTOCからThermal Stateの詳細を得るためのコマンドが次になります。

$ xcrun xctrace export --input xx.trace --xpath '/trace-toc/run[@number="1"]/data/table[@schema="device-thermal-state-intervals"]'

このコマンドを実行すると以下のような結果が得られます。

<?xml version="1.0"?>
<trace-query-result>
  <node xpath='//trace-toc[1]/run[1]/data[1]/table[2]'>
    <schema name="device-thermal-state-intervals">
      <col>
        <mnemonic>start</mnemonic>
        <name>Start</name>
        <engineering-type>start-time</engineering-type>
      </col>
      <col>
        <mnemonic>duration</mnemonic>
        <name>Duration</name>
        <engineering-type>duration</engineering-type>
      </col>
      <col>
        <mnemonic>end</mnemonic>
        <name>End</name>
        <engineering-type>start-time</engineering-type>
      </col>
      <col>
        <mnemonic>thermal-state</mnemonic>
        <name>Thermal State</name>
        <engineering-type>thermal-state</engineering-type>
      </col>
      <col>
        <mnemonic>track-label</mnemonic>
        <name>Track</name>
        <engineering-type>string</engineering-type>
      </col>
      <col>
        <mnemonic>is-induced</mnemonic>
        <name>Is Induced</name>
        <engineering-type>boolean</engineering-type>
      </col>
      <col>
        <mnemonic>narrative</mnemonic>
        <name>Narrative</name>
        <engineering-type>narrative</engineering-type>
      </col>
    </schema>
    <row>
      <start-time id="1" fmt="16:03.938.891">963938891916</start-time>
      <duration id="2" fmt="12.28 min">737085646124</duration>
      <start-time id="3" fmt="28:21.024.538">1701024538040</start-time>
      <thermal-state id="4" fmt="Serious">Serious</thermal-state>
      <string id="5" fmt="Current">Current</string>
      <boolean id="6" fmt="No">0</boolean>
      <narrative id="7" fmt="Serious thermal state">
        <thermal-state ref="4"/>
        <narrative-text id="8" fmt=" thermal state"> thermal state</narrative-text>
      </narrative>
    </row>
    <row>
      <start-time id="9" fmt="09:48.940.922">588940922875</start-time>
      <duration id="10" fmt="6.25 min">374997969041</duration>
      <start-time ref="1"/>
      <thermal-state id="11" fmt="Fair">Fair</thermal-state>
      <string ref="5"/>
      <boolean ref="6"/>
      <narrative id="12" fmt="Fair thermal state">
        <thermal-state ref="11"/>
        <narrative-text ref="8"/>
      </narrative>
    </row>
    <row>
      <start-time id="13" fmt="00:00.000.000">0</start-time>
      <duration id="14" fmt="9.82 min">588940922875</duration>
      <start-time ref="9"/>
      <thermal-state id="15" fmt="Nominal">Nominal</thermal-state>
      <string ref="5"/>
      <boolean ref="6"/>
      <narrative id="16" fmt="Nominal thermal state">
        <thermal-state ref="15"/>
        <narrative-text ref="8"/>
      </narrative>
    </row>
  </node>
</trace-query-result>

中身を見てみると、Thermal stateの変化について出力されています。 開始から9.82minがNominal、その直後から6.25minがFair、最後にSeriousが12.28min、あることがわかります。 これはInstruments.appのGUIでThermal stateを選択した際の詳細情報として表示されていたものと同等のものが出力されているように見えます。

例としてThermal stateで試しましたがschemaにある項目であればすべて表示可能です。しかしTime Profilerなどはとてつもない長さのXMLが出力されるなど、正直あまり使い勝手がいいとは言えない気はします。 まだ最初のリリースなのでこれからに期待したいところです。

他項目の計測と結果確認方法の改善

現在他項目の計測も進めています。 項目によってはInstrumentsではなく別の方法で計測するのですが、結果をBigQueryに格納し、DataStudioで閲覧できるように準備中です。 今回の計測についてもこのフォーマットに合わせて閲覧できると計測結果がまとめて確認できるようになるためデータの可視化というのを進めていきたいと考えています。 その他MetricKitなど実際のユーザ環境での計測も検討しています。

まとめ

SWETが取り組んだパフォーマンス計測についてiOSDC Japan 2020で発表した内容をもとに計測の一例を紹介しました。

すべてのアプリがThermal stateを見る必要があるかというと必ずしもそうではないかもしれません。 大切なのは自分のアプリがどんな課題を持ちうるか検討した上で早めにパフォーマンス計測の仕組みを導入しておくことです。

今回紹介した計測でもさらなる改善や異なる観点での計測も考えています。 これからもパフォーマンス計測についてSWETでは取り組んでいきますのでさらなる知見が溜まったらまたブログ等で発信していきたいと思います。

最後に今年のiOSDCではViewの表示、バッテリーなど他にもパフォーマンスに関する発表がありました。 他社がどのようにパフォーマンス計測に対して取り組んでいるかを知ることができ、とても有意義でした。 発表については聞いてくださった皆様ありがとうございます。 当日質問できなかった、このブログを読んで気になることができた等あれば片山までTwitterでぜひお気軽に聞いてください。


  1. InstrumentsはCPU使用率とThermal State以外にも様々な項目を計測可能です。

  2. パフォーマンスへの影響についてはWWDC2019「Getting Started with Instruments」のセッションでも述べられています。

形式手法でデータ構造を記述・検査してみよう:Alloy編

SWETの仕様分析サポートチーム所属のtakasek(@takasek)です。

仕様分析サポートチームでは、社内のプロダクト開発に対する形式手法の活用可能性を模索しています。当ブログでも、継続的に形式手法に関する情報発信をしています(形式手法 カテゴリーの記事一覧)。

当記事は、Kuniwak(@orga_chem)により社内開催されたAlloyガイダンスを元に再構成した記事です。よく知られたデータ構造であるStackを形式仕様記述しビジュアライズすることで、Alloyの使い方と利点を実感できます。Alloy未経験者でもステップバイステップで試せるように構成しました。是非、お手元にAlloyをインストールして読み進めてください。環境はAlloy 5.1.0を想定しています。

https://github.com/AlloyTools/org.alloytools.alloy/releases

Alloyとは

形式手法の言語およびツールです。システムの構造の制約や振る舞いを簡易に、しかし強力に記述できるようデザインされています。記述されたモデルは要素同士の関係グラフとして視覚化できるというのが特徴です。

もっとも単純なモデルを記述する

Alloyのエディタを起動したら、以下を記述してください。

sig A {}

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

sig はsignatureを意味し、atom(要素)の集合を示します。signatureはオブジェクト指向プログラミングのclassと書き方がなんとなく似ていますが、その実態はまったく異なるものなのでご注意ください。

Executeの結果

ExecuteボタンをクリックするとVisualizerが開きます。Visualizerでは、Nextボタンをクリックするたびにインスタンスが次々とグラフとして表示されます。インスタンスというのは、関係における変数を特定の値で束縛したものです。1

f:id:swet-blog:20200501184029g:plain

Aのatomが0, 1, 2, 3, 4個存在する例を確認できました。現実的には当然5個以上になることもありますが、個数が増えると探索コストが急激に増大するためAlloyは割り切ってデフォルトの探索範囲を少なくしています。それでもおおよその反例は見つかるだろうという前提を小スコープ仮説といいます2。もし無限のケースについて正しさを証明したい場合は、CoqやIsabelleに代表される定理証明支援系の技術が必要になります。

2シグネチャの関係を記述する

sigを2つに増やして、その関係を記述してみましょう。

sig A {
    b: B // 集合Aに属する各々のatomは、集合Bに属するただ1個のatomと関係を持つ
}

sig B {}

Executeの結果

抜粋すると…

関係 グラフ
Aの要素:0個
Bの要素:1個
Aの要素:1個
Bの要素:2個
Aの要素:2個
Bの要素:2個
Aの要素:2個
Bの要素:2個

最後の例があることは提示されて初めて気づく人も多いのではないでしょうか。このように、パターンを網羅的に目で見て確認できるのがAlloyの良いところです。

関係の多重度は、さまざまな形で表現可能です。

sig A {
    b: B, // 1個の要素への関係
    c: one C, // 1個の要素への関係(※省略はoneを意味する)
    d: lone D, // 0〜1個の要素への関係
    e: set E // 0個以上の要素への関係
    // 他にもあります
}

Stackを表現する

ここからが本題です。Stackをモデルとして記述していきましょう。見慣れた連結リストです。

sig Stack {
    value: univ,
    next: lone Stack
}

Stack というsignature、すなわち集合を定義しました。

Stackvaluenext という2つのフィールドを持つsignatureです。フィールド value は、Stackに属する各々のatomと、univに属するただ1個のatomとの関係を示します。フィールド next は、Stackに属する各々のatomと、Stackに属する0〜1個のatomとの関係を示します。なお、univは「あらゆるatomが属する集合」を意味しています。

(勘の良い方は、このモデリングに「おや?」と思われたかもしれません。後ほどその点にも触れますので、今はこのまま読み進めてください)

ここまで書けたら、Executeします。

ビジュアライズの結果を読み解く

結果を見る前に…

f:id:swet-blog:20200501184118g:plain

数値が変わるだけの同じような例が連続して出てきてキツいですね。よく見るとVisualizerのステータスバーに Run Default for 4 but 4 int, 4 seq expect 1 とあります。実はAlloyは、Executeの対象がない場合は暗黙的に以下のような出力命令が省略されているものと解釈しているようです(Alloy5現在)。

run{} for 4 but 4 int, 4 seq expect 1

for 以下は、Visualizerの表示要素数の範囲(スコープ)を示します。なお省略されたrunに任せず明示的に run {} と書いた場合には、 run{} for 4 but 4 int, 4 seq expect 1 よりも少量のインスタンスがビジュアライズされるようです。なぜそうなるのかは調査できていません。

ビジュアライズ結果を読み解く

run {} で再度Executeして、ビジュアライズされた関係グラフを読み解きましょう。最初のグラフはこれですが…

いきなり想定外のグラフです。Visualizer上の Stack のnextが自分自身になっています。データ構造としてStackは循環を許しません。

「Stackは循環しない」という不変条件を書く

Stackが循環しないことを示すためには「このモデルは常にこうなる」という制約を与える論理式(不変条件)を書く必要があります。sig 内の記述だけに着目すれば成立するインスタンスでも、与えられた制約に違反する場合はビジュアライズされません。

「nextは自分自身にはならない」ことを示す

ただし不変条件を正しく書けなかった場合、正常なはずのインスタンスも表示されなくなってしまい、かつそれに気づけないリスクがあります。人間は異常なものを見つけることは得意ですが、あるべき正常なものの欠損に気づくのは苦手です。まずは論理式が本当に過不足のない制約を表現しているかを確認すべきです。

そのために表明(assert)を使います。不変条件はモデルに対して強制的に適用される論理式ですが、表明は現状のモデル自体には影響を与えず「モデルの制約はそうなっているはず」を表現する論理式です。3

今回、Stackが循環しないことを示すには「nextは自分自身にはならない」論理式を書きます。

// 「すべてのStackについて、自身のnextの集合に、自身が含まれない」という表明
assert TestRecursion {
    all s: Stack | s not in s.next
}
// check命令は、表明に違反するインスタンスがないかを確認します
check TestRecursion

ツールバーから Execute > Check TestRecursion すると、コンソールに以下のようなメッセージが表示されました。

Executing "Check TestRecursion"
   Solver=sat4j Bitwidth=4 MaxSeq=4 SkolemDepth=1 Symmetry=20
   483 vars. 72 primary vars. 827 clauses. 7ms.
   Counterexample found. Assertion is invalid. 3ms.

反例(counterexample)、すなわち現状のモデルの中で表明に違反するインスタンスが見つかりました。グラフとして確認できるので見てみましょう。

いいですね。「すべてのStackについて、自身のnextの集合に、自身が含まれない」の反例……すなわち「自分自身がnextになる」インスタンスだけが出力されています。表明をそのまま不変条件に昇格させれば「常にモデルがそれを満たす」という制約の記述になります。

記述全体は以下のようになります。不変条件の記述は fact です。

sig Stack {
    value: univ,
    next: lone Stack
}
fact NoRecursion {
    all s: Stack | s not in s.next
}

不変条件を加えたモデルが想定どおりか、runして確認していきましょう。

run {}

すると、気になるものが見つかりました。

あるStack自身の循環はなくなりましたが、 Stack0.nextStack1Stack1.nextStack0 という間接的な循環が残っています。

「nextを辿った先が自分自身にはならない」ことを示す

「nextは自分自身にはならない(s -(next)-> s)」ことは示しましたが、それだけでは足りなかったのです。 s -(next)-> ... -(next)-> s という関係を表現する必要があります。

そのためには推移閉包を使います。厳密な定義は置いといて、ざっくり言えば「関係Rについて s1 -(R)-> s2 s2 -(R)-> s3 が言えるとき s1 -(R)-> s3 も言える」という関係の集合が推移閉包です。Googleイメージ検索したら感覚は掴めると思います。Alloyには推移閉包オペレータがあり、 ^ をつけるだけで推移的関係を表現可能です。

……というのは本当でしょうか? 我々には確認の手段があります。Alloyの力を借りましょう。 fact NoRecursion はそのまま、表明を追加します。最初に書いたTestRecursionからの差分は、nextに推移閉包オペレータをつけただけです。

...
fact NoRecursion {
    all s: Stack | s not in s.next
}
assert TestRecursion {
    all s: Stack | s not in s.(^next)
}
// 似たようなIntの例が多すぎて邪魔なので、forでスコープを絞ってcheckします
check TestRecursion for 2 Int

再びツールバーから Execute > Check TestRecursion します。さっき見つけた双方向の矢印のパターンが反例として上がってくればしめしめです。どうでしょうか……

nextが双方向の矢印になる例だけが見つかりました。Stackの表示要素数が2個より多い場合の関係も検査してみましょう。

check TestRecursion for 2 Int, 4 Stack

3個のStackが循環参照している反例が見つかりました。いい感じです。

では、表明を不変条件に格上げします。

fact NoRecursion {
-    all s: Stack | s not in s.next
+    all s: Stack | s not in s.(^next)
}

含意の確認をする

ちょっと待ってください。本当にこの格上げは正しいのでしょうか。推移閉包によって「nextを辿った先が自分自身にはならない」ことを表現できました。しかし、これを満たすとき、今までの「nextは自分自身にはならない」も満たすかと言われると確証がありません。感覚的には間違っていないように思えますが、論理の力で保証したいところです。

つまり、論理式「『nextを辿った先が自分自身にはならない』ならば『nextは自分自身にはならない』」が正しいことを確認したいのです。

assert TestImplies {
    (all s: Stack | s not in s.(^next))
        implies (all s: Stack | s not in s.next)
}

implies は含意、つまり論理学の初歩で習う「AならばB」です。

check TestImplies をExecuteすると、コンソールに以下のメッセージが表示されました。

Executing "Check TestImplies"
   Solver=sat4j Bitwidth=4 MaxSeq=4 SkolemDepth=1 Symmetry=20
   560 vars. 72 primary vars. 907 clauses. 16ms.
   No counterexample found. Assertion may be valid. 2ms.

反例が見つからないということは、「『nextを辿った先が自分自身にはならない』ならば『nextは自分自身にはならない』」は正しいようです。不変条件の格上げは適切だと自信を持てました。

valueの不変条件を書く

ここまでで、全体の記述は以下のようになっています。

sig Stack {
    value: univ,
    next: lone Stack
}
fact NoRecursion {
    all s: Stack | s not in s.(^next)
}

run {} for 1 Int

runの結果……

value周りにおかしな関係が見つかりました。「Stack0.nextStack1」「Stack1.nextStack1」など、あるStackのvalueがStackだったケースでは循環が生まれてしまうようです。

その原因は、valueの属する集合が univ すなわち「あらゆるatomの集合」になっているせいです。

どのように対処しましょうか。

  • valueフィールドとnextフィールドの関係を適切に記述する
  • 「valueはStackにならない」という仕様で逃げる

理想的なのは前者ですが割愛します。腕に自信のある方は正面突破してみましょう。今回は「valueはStackにならない」という仕様を導入して逃げることにします。より厳密な日本語で表現するなら「すべてのStackについて、自身と関係するvalueはStackではない」というべきでしょうか。

assert TestValue {
    // ここに「すべてのStackについて、自身と関係するvalueはStackではない」という論理式を書く
}

もしあなたが手元にAlloyをインストールして進めているなら、ちょうどいい練習問題なので自分で書いてみましょうか。すこしスペースを空けますね。

...

...

...

...

...

答え合わせです。 答えのひとつは、次のとおりです。

assert TestValue {
    all s1, s2: Stack | s1.value not in s2
}

check TestValue for 1 Int, 4 Stack で検査してみるといいでしょう。

論理式をリファクタする

実は all s1, s2: Stack | s1.value not in s2 はベストな解ではありません。よりシンプルに書くなら、

all s: Stack | s.value not in Stack

で十分です。

そういうことなら…と書き換える前に、一度立ち止まりましょう。プログラミングでもそうですが、リファクタリングでは修正前後に記述の意味が変化してしまってはいけません。論理の世界の言葉で言い換えると「修正前の論理式と修正後の論理式が同値である」ことを確かめたいです。

そこで、式変形前後での同値性の確認のテクニックを紹介します。手順は先述した含意の確認とよく似ています。

assert TestTrans {
    (all s1, s2: Stack | s1.value not in s2)
        iff (all s: Stack | s.value not in Stack)
}

iffif and only if の略で「Bのときに限りAが成り立つ。Aが成り立つのはそのときに限る」つまり必要十分条件を示します。そのような論理式は同値として扱えます。オペレータとして <=> と書くこともできます。

check TestTrans をExecuteすると、コンソールに以下のメッセージが表示されました。

Executing "Check TestTrans"
   Solver=sat4j Bitwidth=4 MaxSeq=4 SkolemDepth=1 Symmetry=20
   1158 vars. 69 primary vars. 2196 clauses. 7ms.
   No counterexample found. Assertion may be valid. 3ms.

反例が見つからないということは、修正前後の論理式は同値だということです。より大きなスコープで反例が見つかる可能性を頭の片隅に常に置いておく必要がありますが、今回は大丈夫でしょう。自信を持って書き換えましょう。なお、もし反例があった場合にはVisualizerでグラフとして確認可能です。Alloyは便利ですね。

完成したモデルを確認

sig Stack {
    value: univ,
    next: lone Stack
}
fact NoRecursion {
    all s: Stack | s not in s.(^next)
}
fact NoRecursiveValue {
    all s: Stack | s.value not in Stack
}

この記述が適切なものか確認しましょう。

run {} for 1 Int, 4 Stack

よさそうです。

push, popの前後関係をpredで表現する

データ構造のStackに対しては、pushとpopという操作が可能です。その確認もしたいです。

Alloyの状態モデルではミュータブルな手続きを書くことはできません。かわりに操作前後の状態を別物(事前状態、事後状態)として扱います。事後状態は慣習的に ' をつけて表記します。

  • push前のStack = s
  • push後のStack = s' (読みはs-prime)

s になんらかの値をpushした状態」と「s'」が同値だと示す論理式を書けば、前後の関係が表現できます。

任意個数の変数から真理値(true/false)を導く論理式を述語(predicate)といいます。これまで書いてきた run {}{} も、実は述語です。このrun命令は、次のように分解できます。

pred P {} // 述語の宣言と定義
run P     // 定義した述語に対する出力命令

pushを表現したいなら、次のような三変数述語になります。なお改行はandを示します。

pred Push[s, s': Stack, x: univ] {
    s'.value = x
    s'.next = s
}

この述語を満たす関係を run Push for 1 Int, 4 Stack でビジュアライズすれば、push前後のStackの関係が読み取れます。

上図の表記は、以下を意味します。

  • $Push_sPush の変数の s
  • $Push_s'Push の変数の s'
  • $Push_xPush の変数の x

sx をpushした結果が s' になることが図示できていますね。

popについても同じように書けます。難しいところはないので特に説明しません。練習問題として試してみるとよいでしょう。

まとめ

AlloyでStackを記述・検査する一連の流れを通じて、Alloyの使い方を紹介しました。とっつきやすいツールですので、是非ご活用ください。

仕様が妥当なものか確認せずに、手続き的に複雑な仕様を作り上げてしまうことはよくあります。形式仕様記述を用いて仕様をシンプルに書き、ときにリファクタしながら研ぎ澄ませると、より安全で自信に満ちあふれた開発プロセスが手に入るかもしれません。

なお、本記事よりも高度な記述が知りたい場合は公式のlanguage referenceをご参照ください。 https://alloytools.org/download/alloy-language-reference.pdf

SWET視点でピックアップしたテストに関するWWDC 2020まとめ

お久しぶりです。 SWETグループの平田(@tarappo)と片山(@kariad_uu)です。

本稿では、先日オンラインでおこなわれたWWDC2020の中から、SWET視点で気になった次の2点について紹介したいと思います。

  • The suite life of testing
  • Instruments周り

これら以外にもテスト周りの追加としては待望の「Xcodeを用いたStoreKitのテスト」がありますが、本記事では割愛します。

本記事でアップしているXcodeの画像はXcode11.6でとった画像となります。 Xcode12では見た目が変わっている可能性があります。

The suite life of testing

まず、WWDC2020で公開された「The suite life of testing」について平田が紹介していきます。

WWDC2020では「The suite life of testing」として次の5つの動画が公開されています。

  • Handle interruptions and alerts in UI tests
  • Get your test results faster
  • XCTSkip your tests
  • Triage test failures with XCTIssue
  • Write tests to fail

これらの動画での説明はそれぞれがある程度関係をしていて、大きくまとめると「自動テストが失敗したときの対応」についての話がメインといえます。

そこで、ここでは1つ1つの動画の中身について説明するのではなく、これらの動画を簡潔にまとめて説明をします。 (なお「Handle interruptions and alerts in UI tests」についてはUIテスト時の割り込みやアラートをどのように対応するかの話なので今回は除外します)

自動テストは常に成功するわけでなく失敗もします。 この自動テストの失敗時の調査は、テストコードを維持し続けるために大事なことです。 しかし、調査にコストがかかりすぎてしまうのは問題です。

このコストがかかりすぎないように、動画内でも説明されているものとして、次の2つの観点で説明をしていきます。

  • フィードバックを早くする
  • フィードバックからの情報取得

フィードバックを早くする

自動テストからのフィードバックは早いことが望ましいです。 たとえば、CI/CDサービスで自動テストを動かしていて、いつまでたっても終わらないといったことが起きてしまうと結果を待ちきれなくなってしまいます。

その結果、自動テストを実行しないといったことにもつながってしまう恐れがあります。

次をおこなうことでフィードバックを早くできます。

  • テストの実行時間の制御
  • テストのスキップ
  • 並列実行

これらについて次に説明をしていきます。

テストの実行時間の制御

必要な理由

テストの実行時間がのびてしまう原因の1つとして、自動テストが想定の時間以上かかってしまうということがあります。

たとえば、テストのハングにより自動テストが終わらなかったとします。

その場合、CI/CDサービスの制限時間や実行している環境(デバイスファームなど)の制限時間により、テストが無理やり終了させられてしまうことになります。

そうすると、テストが無事に終了していないため自動テストの結果を得ることも出来ずに、なにが問題だったのかがわからなくなってしまいます。

Xcode11.4でテストの実行時間を制御できるようになりました。

この機能を利用することで、特定の時間以上がかかった場合にそのテストを失敗扱いにできます。

これにより、想定外の挙動をしてテストが終了せずに実行時間が長くなってしまうといったことを止めることができます。

制限時間の設定

本機能はTestPlansで下記画像にあるように「Test Timeouts」をONにすることで利用できるようになります。

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

テストの制限時間はデフォルトで10分が指定されています。 この値は、複数の方法で変更できます。

その際の優先度は次のとおりです。

  1. テストコード側で利用するexecutionTimeAllowance API
  2. テスト実行時に指定するxcodebuildのtimeallowanceオプション
  3. TestPlansの設定
  4. デフォルトの時間(10分)

これらの設定方法のうち、Xcode12から3にあるTestPlansからも値を設定できるようになりました。

これらの指定する値は、秒数で指定できますが注意が必要です。 たとえば、60秒未満を指定した場合は1分に切り上げられます。 100秒であれば、2分に切り上げられます。

この指定した時間以上に実行時間がかかった場合、Xcode上では次のようなエラーが表示されます。

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

この画像の例では制限時間を1分と指定していて、テストの実行時間がその時間を過ぎています。

なお、エラーが出たときの情報の詳細はXcode12からより詳細に出るようになったので、後述します。

テストのスキップ

必要な理由

自動テストを実装している中で、実行する環境に依存するようなテストを実装することもあります。

たとえば、ユニバーサルアプリでiPadのみで動くテストとか、特定のOSバージョン以上で動作するテストなどです。

これらはすべてテストの実行時にしか判断できない条件になります。 今まではXCTestではテストの結果は「成功」または「失敗」しかありませんでした。

そのため、実行環境によって動かないテストは求めている実行環境以外で動作したときに「成功」または「失敗」のどちらかになるようにするか、またはそのテスト自体を無効化するかといった選択肢しかありませんでした。

成功とすると、テストを実行していないのに問題ないという判定をすることになります。 失敗とすると、何も問題が発生していないのに落ちた原因をチェックするコストを使うことになります。

Xcode11.4からテストをスキップできるXCTSkipというAPIが追加されました。 これにより、特定の条件の際に自動テストをスキップできます。

XCTSkipを利用してテストのスキップ

このXCTSkipを利用することで、明示的にテストを特定条件の際にスキップできます。

例えば次のコード例のようにXCTSkipUnlessを利用することで、実行デバイスがiPadでなければテストをスキップできます。

func testSkipExample() throws {
    try XCTSkipUnless(UIDevice.current.userInterfaceIdiom == .pad, "iPad only")
    
    //なにかしらの処理
}

このようにテストをスキップさせた場合、Xcode上では次のように表示されます。 今までは「成功の緑」と「失敗の赤」だけでしたが、この「スキップのグレイ」が増えました。

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

また自動テストはCI/CDサービスで動かすと思います。 そのため、CLI上で動かしたときに生成されるテスト結果にもテストが成功・失敗の情報だけでなくスキップされた情報も必要になります。

利用できるテスト成果物として、Xcode11から追加されたResult Bundleがあります。 このResult Bundleは次のコマンドを用いることでJSONフォーマットとして情報を生成できます。

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

生成された情報の一部は次のようになります。 これを見るとわかりますが、testSkippedCount としてスキップの情報もあることがわかります。

  "metrics" : {
    "_type" : {
      "_name" : "ResultMetrics"
    },
    "errorCount" : {
      "_type" : {
        "_name" : "Int"
      },
      "_value" : "2"
    },
    "testsCount" : {
      "_type" : {
        "_name" : "Int"
      },
      "_value" : "3"
    },
    "testsFailedCount" : {
      "_type" : {
        "_name" : "Int"
      },
      "_value" : "1"
    },
    "testsSkippedCount" : {
      "_type" : {
        "_name" : "Int"
      },
      "_value" : "1"
    }
  }

このResult Bundleを活用することにより、CI/CDサービスで自動テストを実行した場合でも、テストの「成功」「失敗」「スキップ」といった情報を取得できます。

Result Bundleについては、以前本ブログで書いた記事を参考にしてもらえればと思います。

並列実行

必要な理由

並列実行は自動テストにおいてよくある実行時間の短縮の方法です。 テストを分割しておこなえるために、トータルとしての実行時間が短くできます。

XCTestにおいてもいろいろな方法で並列実行をおこなうことができますが、Xcode10からiOSシミュレーターの並列実行が容易にできるようになっています。

ただし、並列実行をすれば必ずしも実行時間が確実に短縮されるわけではありません。 ある特定のテストケースだけ実行時間が長いと、トータルの実行時間はそのテストケースに引っ張られてしまいます。

このXcode10から追加された並列実行の方法や注意点については、以前書いたSWETブログの記事を参考にしてください。

Xcode12における並列実行

Xcode11.4からxcodebuildコマンドを利用すると、iOS/tvOSの実機に対して並列実行を行うことができるようになりました。

これにより、iOSシミュレーター / iOS実機と並列実行をできるようになり、いろいろなパターンで自動テストを実行できるようになりました。

本機能による並列実行の注意点として、自動テストがどの端末に割り当てられるかは指定できません。 (XCTestでは実行するテスト/実行しないテストを指定できるので、どの端末にどのテストを指定するかを決めることによる並列実行もできますが、手動管理になるため大変です)

そのため、基本的には同一のデバイス・同一のOSバージョンで端末を用意しておくのが理想的です。

同じ端末を用意できずに、異なるデバイスやOSを使って自動テストの並列実行する場合は、デバイスやOSに依存するようなテストは実行しないのが望ましいです。

フィードバックからの情報取得

テストが常に成功するのであれば、テストからのフィードバックに付加的な情報はそこまで必要ないかもしれません。

しかし、テストは失敗するものです。 そのため、テストからのフィードバックで得られる情報は重要です。

フィードバックからの情報としては、次のような情報を得られることが重要になってきます。

  • どのように失敗したのか
  • なぜ失敗したのか
  • ソースコードのどこで失敗したのか

Xcode12ではテストの失敗時に上記のような情報をより分かりやすく提供してくれるようになっています。

ここでは次の3点について説明をしていきます。

  • テストの実行時間の制御(spindump)
  • アサーション
  • テスト失敗時の情報(XCTIssue)

テストの実行時間の制御(spindump)

テストの実行時間の制御によりテストが失敗した場合、どうして想定時間より長くかかったかを知る必要があります。

ハングした原因を推測することは難しいことが多いです。 Xcode12からテスト実行時間の制御によりテストが失敗した場合、テスト結果にspindumpが添付されるようになりました。

テスト結果のspindumpを開くことで、失敗したテストケース名があるはずです。 この情報をおうことで、最も多くの時間を費やしているメソッドを教えてくれます。

これにより、ハングした原因の調査の助けになります。

アサーション

テストが失敗した場合に、指定した期待する値と実際の値が異なっていたとして、それぞれの値がどのような意味を持つのかがわからないことがあります。

これがわかるようにアサーションメッセージを適切に使うことは重要です。 次のようにXCTAssertsではメッセージを設定できます。

XCTAssertEqual(expected, actual, "アサーションメッセージ")

このメッセージに適切な情報を含めておくことが重要です。 しかし、このメッセージに失敗したファイルパスを含める必要はありません。

Xcode12では失敗時の情報がより適切に表示されるようになりました。

例えば独自のアサーションメソッドを用意することがあると思います。 その場合、今までは次のようにテストケース側にアノテーションが付与されずに、実際に呼び出しているアサーションメソッド側にアノテーションが付与されます。

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

そのため、実際にどこでテストが失敗したのかがわかりづらいところがありました。 今までは、次のようにfileとlineを利用することで上記の画像の場所ではなく、テストケース側でアノテーションを表示できました。

func assertSample(file: StaticString = #file, line: UInt = #line) {
    XCTAssertTrue(false, file: file, line: line)
}

しかし、この対応でも問題は残ります。 この対応ではテストケース側にアノテーションが表示されます。 そのため、アサーションメソッド内のどのアサーションで失敗したかまではわかりません。

Xcode12ではこれらの問題が解決しています。

今回のような場合では、テストケース側にはグレイのアノテーションが表示され、アサーションメソッド側には赤いアノテーションが表示されるようになりました。

テスト失敗時の情報(XCTIssue)

Xcode12からテスト失敗時の情報が今までよりまとまっています。

今まではXCTestではテスト失敗時にrecordFailure APIを呼び出しています。 これは次にあるように4つの情報を保存しています。

func recordFailure(withDescription description: String, 
            inFile filePath: String, 
            atLine lineNumber: Int, 
            expected: Bool)

expectedはアサーションの失敗によってテストが失敗した場合はtrueで、例外の場合だとfalseになります。

Xcode12からはrecordFailureはdeprecatedになり、新しくrecord APIが追加され、このAPIが呼び出せるようになりました。

このAPIを見るとわかるように、今までのような値の渡し方ではなくXCTIssueにカプセル化されています。

そして、今までよりも複数の情報が追加されています。

これにより、自動テストを実行したXcode上や自動テストの成果物であるResult Bundleでテスト失敗時の情報がより細かく分かるようになりました。

このrecord APIはテストの失敗時に呼ばれるので、overrideしてカスタムなものを作ることもできます。

overrideできることで、情報を増やしたり絞ることもできます。 たとえば、Attachmentとしてログを別途追加するといったこともできます。

    override func record(_ issue: XCTIssue) {
        var issue = issue
        issue.add(XCTAttachment(string:  log))
        super.record(issue)
    }

Instruments

ここからは、片山がXcode12からのInstrumentsについて、業務でInstrumentsを利用している中で特に嬉しい変更について2つご紹介します。

xctraceの登場

今回Instrumentsにおいても大きな変更が入りました。 今までCLIから実行する際にはinstrumentsコマンドを用いていましたが、Xcode12でdeprecatedとなりました。 その代わりとしてxctraceコマンドが新たに追加されました。xctraceに新生したと同時に新たにできることが増えました。 そのxctraceで新たにできるようになったことで最も嬉しいと感じたのが、「Analysis Core Tables」のexportです。

Xcode11までのInstrumentsでは、計測した結果のtraceファイルを基本的にはInstruments App以外で見ることができませんでした。 そのため、計測した結果をGUIでチェックする必要がありました。 例えば端末温度を計測をした際はtraceファイルをInstrumentsで開いて、GUIを操作して問題ないかをチェックする必要があります。 (我々は、InstrumentsアプリをXCTestで動作させ、スクリーンショットやUI要素の情報を得ることで計測結果を簡易的に取得することをおこなっていました)

これらの計測結果のtraceファイルの内容をxctraceからXML形式でexportできるようになったため、exportしたXMLをパースして計測結果を機械的にチェックすることも可能になります。 毎日計測して結果を得たい場合にはとてもありがたい機能追加です。 細かい使い方についてはmanコマンドにしか詳細が無いのでぜひそちらを参照してみてください。

また、xctraceではかゆいところに手が届く変更も入りました。 instrumentsコマンドは以下のような少しだけ不便な部分がありました。

  • 選択できるtemplateの一覧をinstrumentsコマンドから見ることができない
  • 選択できる端末の一覧をinstrumentsコマンドから見ることができない

些細な問題かもしれませんが、ちょっとだけ不便なところがxctraceでは改善されました。 templateと端末の一覧をxctraceのコマンドから簡単に調べることができます。

xctraceについては当然まだベータ版であり、不具合と思われる報告もされています。 気になる方はApple Developer Forumで探してみてください。

保存 / 読み込み速度の改善

Instruments App本体でも少しうれしい改善がありました。 traceファイルは長時間計測するとサイズも大きくなっていき、保存/読み込みにかなりの時間が掛かっていました。 その問題となっていた保存/読み込みのパフォーマンスが次のように改善されました。

  • 保存時:最大40%高速化
  • 読み込み時:最大80%高速化

あくまで最大と書いてあり状況によって変化するため、私個人として試したところでは劇的に早くなったとは感じないが、少しは早くなったかなという感想です。 Resolvedの項目に記載されてるため、Appleとしても気にしていたと思われます。 今後のアップデートでも保存/読み込み速度の改善が継続的にされると嬉しいです。

おわりに

今回、WWDC2020の動画からSWET視点で選んで上述した2点について紹介しました。

WWDC2020の動画を聞いた感想として、すでにある機能が今まで以上により使いやすい形でまとめられ提供されるようになったと感じています。

また、まだXcode12がリリース前の関係で変わることもあるかと思いますし、伝えきれなかった情報もあります。 それらについては正式にリリースした時点で追加記事を書ければと思います。

参考資料

おまけ

SWETではテストに特化した勉強会としてTest Nightを定期的に開催しています。

昨今の状況もあり開催できていませんでしたが、オンラインでの開催をおこなう予定です。 それにともない、Test Nightとは別の「Test Online」というグループをconnpassで作成しています。

このグループでの勉強会を近日中にconnpassを公開するので、ぜひとも参加していただければと思います。

おまけ2

今回、Instruments周りについて紹介した理由としてパフォーマンス計測に力を入れて取り組んでいるというのがあります。

今までの取り組みについて、iOSDC Japan 2020で登壇予定ですのでぜひ視聴していただければと思います。

継続的にアプリのパフォーマンスを計測する by kariad | トーク | iOSDC Japan 2020 - fortee.jp

DroidKaigi 2020発表動画公開記念:RobolectricでUIテストを動かすのに必要なことのまとめ

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

先日、DroidKaigi 2020で発表予定だったセッション「Robolectricの限界を理解してUIテストを高速に実行しよう」 の動画がYouTubeのDroidKaigiチャンネルで公開されました。

新型コロナウイルスの影響でDroidKaigi 2020が中止になってしまったのは残念でしたが、 発表したかった内容を皆さんに伝えることができて、とても嬉しいです。

発表スライドはこちらです。

合わせてサンプルコードも公開していますので、よろしければご覧ください。

さて、本記事では「RobolectricでもUIテストを動かしてみたいけど・・・動画を見る時間がない!」という方に向けて、 ぜひ押さえておきたいポイントを厳選してお伝えします1

  • 試してみる前に知っておきたいポイント
  • テストがすぐ書ける環境を構築する
  • 工夫が必要なJetpackコンポーネント

なお「何故そうしなければならないのか」といった理由や背景についての解説は割愛しています。 その点に興味がある方は動画やスライドを参照してください。

試してみる前に知っておきたいポイント

Robolectric上でUIテストを動かそうとする前に、最低限知っておきたいポイントは次の3点です。

  • 最初はInstrumented Testで動かす
  • 動かないと分かっているのを避ける
  • ひとつの画面で完結するものから着手する

これらのポイントを事前に押さえておけば、大きな手戻りが発生するリスクを抑えられます。 「すぐに試してみたい!」という方もご一読ください。

ポイント1:最初はInstrumented Testで動かす

テストを高速に実行できるRobolectricですが、UIテスト実行における大きな弱点は「画面が無い」点です。 画面が無いため、テストコードが意図通り動作しているのか(=意図通りUIを操作しているのか)目視で確認できません。

そのため、同じテストコードをInstrumented Testでも動かせるようにしておきましょう。 Instrumented Testであれば、テストの動作状況を画面を見ながら確認できます。

特に、テストコードを書いている途中では、画面を目視しながらの動作確認は必須と言えます。 最初はInstrumented Testで動くようにテストを書き、その後にRobolectricでも動くようにしていく方法がおすすめです。 テストコードの具体的な配置方法については後で紹介します。

ポイント2:動かないと分かっているものを避ける

発表時点の調査で、Robolectricで意図通り動かないと分かっているものは次の通りです。

Robolectricで動かすUIテストでは、これらの操作は避けましょう。

ポイント3:ひとつの画面で完結するものから着手する

事前に動かない操作を知っていたとしても、テストを書き進めると意図せずそのような操作に直面してしまうことがあります。 そのリスクを避けるために、できるだけ操作するステップが少ないテストから書き始めましょう。 複数の画面をまたがるテストだと操作ステップが増えがちなので、ひとつの画面で完結するものから始めるのがおすすめです。

テストがすぐ書ける環境を構築する

事前に知っておきたいポイントを押さえたところで、テストがすぐ書ける環境を構築していきます。 既にプロダクトコードをAndroid Studioでビルドできる環境があることを前提とします。

ここで説明する手順に沿って構築すれば、次のすべてが揃った環境を入手できます。

  • IdlingResourceに対応するように筆者が改変したRobolectric
  • DataBindingの非同期処理を待ち合わせるDataBindingIdlingResource2
  • Architecture Componentのバックグラウンドスレッドを待ち合わせるTaskExecutorWithIdlingResourceRule
  • Instrumented TestとRobolectricが動作するLocal Testでテストコードを共有できるShared Test

手順1:必要なファイルをコピーする

サンプルコードをcloneし、次のディレクトリをご自身のプロジェクトにコピーします。 そのときに、コピー先も同じディレクトリ構成にしてください。 たとえばapp/src/test/java/androidxディレクトリは、 ご自身のプロジェクト側でもapp/src/test/java/androidxディレクトリとしてコピーしてください3

  • app/local-repo
  • app/src/test/resources
  • app/src/test/java/androidx
  • app/src/sharedTest/java/com/android/example/github/util
  • app/src/sharedTest/java/com/example/android/architecture/blueprints/todoapp/util

手順2:build.gradleに追記する

app/local-repoディレクトリを、Maven形式のリポジトリ参照先として追加します。 app/build.gradleに、次のように追記してください。

repositories {
    maven { url = file('local-repo') }
}

トップレベルのbuild.gradleに書くこともできます。 その場合はfile('app/local-repo')のように、local-repoディレクトリの相対パスが正しくなるように注意してください。

次に、Robolectricを使えるようにするためのandroid.testOptions.unitTests.includeAndroidResourcestrueにセットします。 app/build.gradleに、次のように追記してください

android {
    ...
    testOptions {
        unitTests.includeAndroidResources = true
        ...
    }
}

次に、Shared Testを置くapp/src/sharedTestディレクトリをInstrumented TestとLocal Test両方のソースセットに加えます。 app/build.gradleに、次のように追記してください。

android {
    ...
    sourceSets {
        androidTest {
            java.srcDirs += file('src/sharedTest/java')
        }
        test {
            java.srcDirs += file('src/sharedTest/java')
        }
    }
}

最後に、依存関係に必要なライブラリを追加します。 Robolectricはapp/local-repoのものを参照する必要があるので、バージョン表記を4.3.1-modifiedとしてください。 なお、このRobolectricはEspressoのIdlingResourceに対応するように筆者が改変したものです。詳しくはスライドの34〜57ページを参照してください。

dependencies {
    ...

    androidTestImplementation "androidx.test:core-ktx:1.2.0"
    androidTestImplementation "androidx.test.ext:junit-ktx:1.1.1"
    androidTestImplementation "androidx.test.espresso:espresso-contrib:3.2.0"
    androidTestImplementation "androidx.test.espresso.idling:idling-concurrent:3.2.0"
    androidTestImplementation "androidx.arch.core:core-testing:2.0.0"
   
    testImplementation "org.robolectric:robolectric:4.3.1-modified"
    testImplementation "androidx.test:core-ktx:1.2.0"
    testImplementation "androidx.test.ext:junit-ktx:1.1.1"
    testImplementation "androidx.test.espresso:espresso-contrib:3.2.0"
    testImplementation "androidx.test.espresso.idling:idling-concurrent:3.2.0"
    testImplementation "androidx.arch.core:core-testing:2.0.0"
}

これらのモジュールが提供する主な機能は次の通りです。

モジュール 主な提供機能
androidx.test:core-ktx Activityの起動を伴うテスト
androidx.test.ext:junit-ktx AndroidJUnit4
androidx.test.espresso:espresso-contrib Espresso
androidx.test.espresso.idling:idling-concurrent EspressoのIdling Resource
androidx.arch.core:core-testing Android Architecture Componentのテスト
org.robolectric:robolectric Robolectric

Robolectric以外のモジュールは、Local TestとInstrumented Testの両方で使うため、 androidTestImplementationtestImplementationで宣言します。 RobolectricはLocal Testだけで使いますので、testImplementationのみで宣言します。

手順3:テストクラスを用意する

テストクラスは、Instrumented Test向け・Local Test向けのものをそれぞれ用意します。 ここで紹介する内容をコピー&ペーストして、クラス名とテスト対象Activity名を置換すれば、すぐに使い始められます。

Instrumented Test向けのテストクラス

Instrumented Test向けの典型的なテストクラス定義は次の通りです。 このクラス定義をapp/src/androidTestに置いてください。

import androidx.test.espresso.IdlingRegistry
import androidx.test.ext.junit.rules.activityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.android.example.github.util.TaskExecutorWithIdlingResourceRule
import com.example.android.architecture.blueprints.todoapp.util.DataBindingIdlingResource
import com.example.android.architecture.blueprints.todoapp.util.monitorActivity
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class MyActivityTest {

    @get:Rule
    val activityScenarioRule = activityScenarioRule<MyActivity>()

    @get:Rule
    val taskExecutorWithIdlingResourceRule = TaskExecutorWithIdlingResourceRule()

    private val dataBindingIdlingResource = DataBindingIdlingResource() // ★

    @Before
    fun setUp() {
        val idlingRegistry = IdlingRegistry.getInstance() // ★
        dataBindingIdlingResource.monitorActivity(activityScenarioRule.scenario) // ★
        idlingRegistry.register(dataBindingIdlingResource) // ★
    }

    @After
    fun tearDown() {
        IdlingRegistry.getInstance().unregister(dataBindingIdlingResource) // ★
    }

    @Test
    fun myTest() {
        // app/src/sharedTest/java にあるテストコード共通部分を呼び出す
    }
}

これを雛形にすれば、AAC (Android Architecture Components)が使われたアプリのテストはだいたいカバーできると思います。 なお、★印を付けたdataBindingIdlingResourceに関係する処理は、Data Bindingを使っていない場合は不要です。

Local Test向けのテストクラス

次はLocal Testにおけるテストクラス定義です。 このクラス定義はapp/src/testに置いてください。

import androidx.test.espresso.IdlingRegistry
import androidx.test.ext.junit.rules.activityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.android.example.github.util.TaskExecutorWithIdlingResourceRule
import com.example.android.architecture.blueprints.todoapp.util.DataBindingIdlingResource
import com.example.android.architecture.blueprints.todoapp.util.monitorActivity
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.LooperMode

@RunWith(AndroidJUnit4::class)
@LooperMode(LooperMode.Mode.PAUSED)
class RobolectricMyActivityTest {

    @get:Rule
    val activityScenarioRule = activityScenarioRule<MyActivity>()

    @get:Rule
    val taskExecutorWithIdlingResourceRule = TaskExecutorWithIdlingResourceRule()

    private val dataBindingIdlingResource = DataBindingIdlingResource() // ★

    @Before
    fun setUp() {
        val idlingRegistry = IdlingRegistry.getInstance() // ★
        dataBindingIdlingResource.monitorActivity(activityScenarioRule.scenario) // ★
        idlingRegistry.register(dataBindingIdlingResource) // ★
    }

    @After
    fun tearDown() {
        IdlingRegistry.getInstance().unregister(dataBindingIdlingResource) // ★
    }

    @Test
    fun myTest() {
        // app/src/sharedTest/java にあるテストコード共通部分を呼び出す
    }
}

ほとんどInstrumented Testと同じですが、以下の点に注目してください。

  • テストクラス名がInstrumented Testのものと違う
    現状では、app/src/androidTestapp/src/testで同じ名前のクラスを定義するとAndroid Studio上でエラーになってしまいます(バージョン3.6.3で確認)。 エラーを無視してビルドはできるものの、IDE上でエラーのままコードを書いていくのは苦痛です。 テストクラス名は別の名前にすることを推奨します。
  • @LooperModeアノテーションが付いている
    Robolectricのタスクスケジューラの種類をPAUSEDにしています。 詳しく知りたい方はスライドの22〜26ページを参照してください。

こちらも、★印を付けたdataBindingIdlingResourceに関係する処理は、Data Bindingを使っていない場合は不要です。

手順4:Instrumented Test・Local Test両方から呼びだせるようにテストを書く

手順3で作成したテストクラスにおける、テストの種類とテスト実行時に呼び出されるテストメソッドの関係は次のようになります。

テストの種類 テスト実行時に呼び出されるテストメソッド
Instrumented Test MyActivityTestクラスのmyTestメソッド
Local Test RobolectricMyActivityTestクラスのmyTestメソッド

これら2つのmyTestメソッドに同じテストコードを書けば、両者から同じテストを実行できるようになります。 とはいえ、両方のメソッドに同じコードを書くとコードが重複しメンテナンス性の低下を招きます。 その事態を避けるために、テストコード本体はapp/src/sharedTestに配置し、 myTestメソッドからはテストコード本体を呼び出すだけにします。

例えば、app/src/sharedTestに配置したテストコード本体をdoMyTest()関数としましょう。

// app/src/sharedTestに配置する
fun doMyTest() {
    Espresso.onView(...).perform(click())
    Espresso.onView(...).check(isDisplayed())
    ...
}    

その場合2つのmyTestメソッドでは、コード重複をできるだけ抑えるためにdoMyTest()を呼び出すだけとします。

// app/src/androidTestに配置する
...
class MyActivityTest {
    ...
    @Test
    fun myTest() {
        doMyTest()
    }
}    
// app/src/testに配置する
...
class RobolectricMyActivityTest {
    ...
    @Test
    fun myTest() {
        doMyTest()
    }
}    

この例におけるdoMyTest()部分の設計パターンはPage Objectデザインパターンがおすすめです。 Page Objectデザインパターンの詳細については割愛しますが、本パターンを適用するとmyTestメソッドの本体は次のような感じになります。

fun myTest() {
    MyGardenPage
            .goPlantList()
            .showPlantDetail("Mango")
            .addToMyGarden()
            .goBackPlantList()
            .goMyGarden()
            .assertPlanted("Mango")
}

スッキリ見通しが良くなりますね。

Page Objectについて詳しく知りたい方は、 DeNA CodelabsでSWETが公開している 「Espressoの知識ゼロでも書ける!Android UIテストはじめの一歩」に挑戦してみてください。

補足:テストクラスも共通化する方法

ここで紹介した方法ではmyTestメソッド内のコード重複は避けられません。

その重複も回避したい場合、本記事で紹介した範囲であれば2つのテストクラスを共通化することもできます。

その場合はLocal Test向けのテストクラスだけをapp/src/sharedTestに置きます。 app/src/androidTestapp/src/testには対応するテストクラスは置かないでください。

そして、app/build.gradleにInstrumented Testの依存関係としてorg.robolectric:annotationsを追加します。

dependencies {
    ...
    androidTestImplementation "org.robolectric:annotations:4.3.1"
}

Robolectricのアノテーションの違いだけであれば、この方法でテストクラスを共通化できます。 ただし、筆者個人の意見としては、あまりこの方法はおすすめしません。 おすすめしない理由は、両者のテスト間で完全に同じコードを維持できない可能性が高いと考えるからです。

たとえば、RobolectricのShadowクラスを使いたくなる場合など、どうしても共通化できない部分がでてきたとします。 そうなった場合、共通化できない部分をapp/src/androidTestapp/src/testへ切り出すことになります。

// app/src/sharedTestに配置したコード
@Test
fun myTest() {
  ...
  doActionOnlyLocalTest() // Local Testでしか実行したくない処理
}

// app/src/testに配置したコード
fun doActionOnlyLocalTest() {
  // ここにLocal Testでしか実行したくない処理を書く
}

// app/src/androidTestに配置したコード
fun doActionOnlyLocalTest() {
  // 何も書かない
}

ところがこの方法を取ってしまうと、前述した 「app/src/androidTestapp/src/testで同じ名前のクラスを定義するとAndroid Studioでエラーになる」 というAndroid Studioの振舞いに抵触してしまいます。 エラーを無視してビルドはできるものの、現状ではおすすめするのが難しいです。

将来Android Studioの振舞いが改善されたら、この方法がベストな選択肢になると思います4

工夫が必要なJetpackコンポーネント

Jetpack Componentのうち、RoomWorkManagerでは更なる対応が必要です。 その対応方法を説明します。

Room

Roomを使うアプリのうち、RoomDatabase(のサブクラス)への参照をstaticなフィールド(Kotlinのcompanion objectなどを含む)に保持しているケースが問題になります。 問題となる理由は、Robolectricの次のような振舞いと相性が良くないからです。

  • テストごとに(ひとつのテストメソッド実行の度に)SQLiteのデータベースファイルが新規に作られる
  • テストをまたいでもstaticな変数は初期化されない

RoomDatabaseのインスタンスは、データベースファイルと一対一対応しているため、 データベースファイルが作り直されたときは改めてインスタンスを作り直す必要があります。 また、RoomDatabaseのインスタンスが変わるとDAOのインスタンスも変わってしまう点にも注意が必要です。

そのため、そのようなアプリをRobolectricでテストするときは、テストのセットアップ時に次の処理を行うようにしましょう。

  1. RoomクラスのdatabaseBuilderメソッドを使って、RoomDatabaseインスタンスを作り直す
  2. アプリ内で保持している古いRoomDatabaseへの参照を、作り直したインスタンスへの参照に更新する
  3. 同様に古いDAOインスタンスへの参照を、新しいRoomDatabaseから取得し直したDAOインスタンスへの参照に更新する

RoomDatabaseの初期化をアプリケーションクラスで行っている場合は、Robolectric用に別のアプリケーションクラスを用意するのが簡単です。 Robolectricではテストクラスに次のようなアノテーションを指定できます。

@Config(application = TestApplication::class)
class RobolectricMyActivityTest { ... }

これで、Robolectricはこのアノテーションで指定されたアプリケーションクラスを使って初期化するようになります。 この例ではTestApplicationクラスのonCreateメソッドが呼び出されるようになりますので、 そのonCreateメソッドの中でRoomDatabaseインスタンスを作り直すなどの処理をすると良いでしょう。 なお、Robolectricではアプリケーションクラスの初期化もテストごとに行われます。

テスト対象アプリによって事情が異なるため具体的なコード例は割愛しますが、 Android Sunflowerアプリの修正例をスライド70〜75ページに掲載していますので、あわせて参考にしてください。

WorkManager

WorkManagerのデフォルトの振舞いでは、未完了のWorkはアプリが終了しても消えることがありません。 次回アプリ起動時に再開されます。

そのようなWorkの特徴により、前回のテストで未完了だったWorkが次のテストで意図せず起動することがあります。 その事態を回避するためにはWorkManagerTestInitHelperを使います。具体的な手順を見ていきます。

まず、app/build.gradleにLocal Testの依存関係としてandroidx.work:work-testingを追加します。

dependencies {
    ...
    testImplementation "androidx.work:work-testing:2.3.1"
}

次に、app/src/test/AndroidManifest.xmlを次の内容で作成します。

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <application>
        <provider
            android:name="androidx.work.impl.WorkManagerInitializer"
            android:authorities="${applicationId}.workmanager-init"
            tools:node="remove" />
    </application>

</manifest>

既にファイルが存在する場合は<provider>部分を<application>タグの中に追記してください。

最後に、テストのセットアップフェーズで、次のようにWorkManagerを初期化します。 Roomのケースと同様にテスト用アプリケーションクラスのonCreateメソッドで初期化するのが良いと思います。

class TestApplication : Application() {
    override fun onCreate() {
        ...
        WorkManagerTestInitHelper.initializeTestWorkManager(this)
        ...
    }
}    

テスト用アプリケーションクラスを用意する場合は、忘れずにテストクラス側でアプリケーションクラスを指定するようにしましょう。

@Config(application = TestApplication::class)
class RobolectricMyActivityTest { ... }

この対応で、未完了だったWorkが起動することはなくなります。 また、起動したWorkが同期的に実行されるようにもなるので、UIテストの安定化に寄与するはずです。

おわりに

RobolectricでUIテストを動かすときのポイントを紹介しました。 これらを実践すれば、UIテストの大部分がRobolectricでも動作するようになっていることと思います。

是非、簡単なところから少しずつ書きはじめてみてみてください!


  1. この文脈における「UIテスト」は、EspressoのAPIを使ったテストとします

  2. このコードは孫FragmentにDataBindingが使われているケースをサポートしていませんでした。サンプルコードではその問題を修正しています

  3. 特にapp/src/test/java/androidx配下のクラスは、パッケージ名を変更すると動かなくなりますので注意してください。app/src/sharedTest配下のクラスについては、パッケージ名を変更した上で別のディレクトリに配置しても問題ありません

  4. 途中から違う事象について議論しているので確証はありませんが、Issue 140375151が関連するバグチケットのようです