DeNA Testing Blog

Make Testing Fun, Smart, and Delighting End-Users

Google Cloud Buildとは一体何者なのか

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

こんにちは。SWETの加瀬(@Kesin11)です。
Google Cloud Next ’18でGoogleのCI/CDサービスとしてGoogle Cloud Build(以後GCBと略します)というものが発表されました。
https://cloud.google.com/cloud-build/
https://www.youtube.com/watch?v=iyGHW4UQ_Ts

CI/CDサービスを追っているものとして、これは要チェック!
ということで、GCBを軽く使ってみて分かった特徴をCircleCIと比較してまとめてみました。

最初にまとめを書いてしまうと、GCBはCI/CDとして見るとCircleCIと比べてまだまだ扱いづらいところがあります。一方で、従量課金制のフルマネージドなビルドサービスというCircleCIとは違った使い方もできるところが特徴と言えます。

GCBの特徴

1ステップ1コンテナ

一般的なCI/CDサービスでは、以下のようなステップをGUIや専用のファイルに自分で記述していきます。

  • git checkout
  • 依存ライブラリのインストール
  • ビルド
  • テスト実行
  • デプロイ

CircleCIを例にすると、それぞれのステップは1つの仮想マシン、あるいは最近では1つのdockerコンテナ上で実行されるのが一般的です。

.circleci/config.yml

version: 2
jobs:
  build:
    docker:
      - image: circleci/node:9
    working_directory: ~/repo

    steps:
      - checkout
      - restore_cache:
          keys:
          - v1-dependencies-{{ checksum "package.json" }}
          - v1-dependencies-
      - run: npm install
      - save_cache:
          paths:
            - node_modules
          key: v1-dependencies-{{ checksum "package.json" }}
      - run:
          name: lint
          command: npm run lint
      - run:
          name: test
          command: npm run test

上の.circleci/config.ymlの場合、circleci/node:9のコンテナ上で全てのステップが実行されることになります。Workflowsを活用した場合には複数のコンテナを使用してビルドフローを構築することも可能ですが、基本的には1コンテナ上で複数のステップを実行するという考え方です。

GCBではここから根本的に異なっており、1ステップ1コンテナで実行されます。

cloudbuild.yaml

steps:
# build node script
- name: 'gcr.io/cloud-builders/npm'
  args: ['install']
- name: 'gcr.io/cloud-builders/npm'
  args: ['run', 'lint']
- name: 'gcr.io/cloud-builders/npm'
  args: ['run', 'test']
- name: 'gcr.io/cloud-builders/docker'
- args: ['build', '-t', 'gcr.io/$PROJECT_ID/sampleproject:latest', '.']

各ステップに必ずdockerイメージ名が記載されており、実際にこのコンテナ上で各ステップが実行されます。 そのため、例えばnpmを使いたいときはnpmが使えるコンテナ、docker buildしたいときはdockerが使えるコンテナを指定します。

Cloud Buildではよく使われるコマンドを実行できるdoockerイメージをGitHub上で公式コミュニティがメンテしています。 例えばgcloudkubectlfirebaseなどが使えるイメージが存在します。

もちろん、Cloud Buildによってメンテされているdockerイメージ以外も使用可能です。例えば先ほどのcloud-buildersのnpmのディレクトリにあるREADMEを見ると、以下のようにentrypointを指定することで公式のnodeイメージを使用することも可能であると書かれています。

steps:
- name: node:10.8.0
  entrypoint: npm
  args: ['install']

より詳しく知りたい場合、GCBのCreating Custom Build Stepsのドキュメントを参照するとよいでしょう。
https://cloud.google.com/cloud-build/docs/create-custom-build-steps?hl=en

GitHubのChecks APIが使える

2018/05にGitHubからChecks APIというものが公開されました。 これはPull Requestの画面からCI結果の詳細が見られるようになるという新機能なのですが、まだ対応されているサービスは多くありません。 この記事が公開された2018/08の時点で対応しているサービスはTravis CI、Visual Studio App Center、そしてGCBの3つしかないようです。 https://developer.github.com/v3/checks/

CircleCIは5月の発表時点では名を連ねていましたが、まだ使えるようになっていません。

docker imageのpushが簡単

GCBの特筆すべき特徴の1つとして、Google Cloud Platform(GCP)に含まれるGoogle Container Registry(GCR)との連携が挙げられます。 GCRはプライベートなコンテナレジストリを提供してくれるサービスで、特にGCPのコンテナを扱うサービス(GKEなど)を利用する上ではとても便利です。

GCBでは以下のようにdocker buildしたイメージをimagesで指定するだけで、自動的にGCRにpushしてくれます。 認証のための鍵などを考える必要はなく、非常にお手軽です。

cloudbuild.yaml

steps:
# docker build
- name: 'gcr.io/cloud-builders/docker'
  args: ['build', '-t', 'gcr.io/$PROJECT_ID/sampleproject:latest', '.']

# push built images to Container Registry
images: ['gcr.io/$PROJECT_ID/sampleproject:latest']

ローカルからコマンド実行するだけで手軽にビルドできる

CircleCIなどのCIサービスは基本的にGitHubにpushされたトリガーによってジョブが走るため、最初にビルドフローをトライ&エラーで構築するときに設定ファイルを少し変えてはgit pushをひたすら繰り返すことになります。

GCBの場合はgcloud builds submit --config=cloudbuild.yaml .このコマンド一発だけでローカルのソースコードを全てtarで固めて、cloudbuild.yamlに従ってクラウド上ジョブが実行されるので非常に簡単です。
https://cloud.google.com/cloud-build/docs/running-builds/start-build-manually?hl=en

設定ファイルを修正するたびにgit pushして動作確認するのもそれほど手間ではありませんが、トライ&エラーの時期にはコマンド一発で動作確認ができるのは意外と便利です。また、CI/CDという文脈を外せばリモートでdocker buildなどを実行できる便利なサービスとして使うこともできるでしょう。

ちなみにGCBでも手動で実行するだけではなく主要なCIサービスと同様に、GitHubにpushしたタイミングで自動的にジョブが動くように設定することは可能です。
https://cloud.google.com/cloud-build/docs/run-builds-on-github?hl=en

8コア、32コアマシン

GCBではビルドを実行するマシンのスペックを簡単に選択できます。どれだけ簡単かというと、先程のコマンドに--machine-typeオプションを追加するだけです。

gcloud builds submit --config=cloudbuild.yaml --machine-type=n1-highcpu-8 .

これだけでビルドを実行するマシンは8コアのスペックになります。オプション1つでマシンスペックを上げることが可能なので、非常に時間がかかるビルドフローを実行する場合だけハイスペックマシンにするという使い方が可能です。
もちろんcloudbuild.yamlに直接マシンスペックを記載することで、常に8コアマシンを使用してビルドするように設定することも可能です。
https://cloud.google.com/cloud-build/docs/api/reference/rest/v1/projects.builds#machinetype

従量課金制

CircleCIは月額課金制で、並列数やコンテナ数に応じて料金が変動する料金体系になっています。
https://circleci.com/pricing/

GCBでは料金体系が一般的なCIサービスとは異なっており、月額課金ではなくて従量課金制です。1コアマシンなら毎日120分は無料で、それを超えた先は分単位で課金されます。
ちなみに無料枠は1コアマシンだけのようで、先程紹介した8コア、32コアマシンの場合は無料枠がありませんので注意してください。
https://cloud.google.com/cloud-build/pricing?hl=en

CircleCIとは料金体系が異なるのでどちらが良いとは一概に言えませんが、少なくともGCBの場合はハイスペックマシンが必要な瞬間や、ビルドフローによってマシンスペックを変更することが可能という柔軟性があります。

GCBのイマイチな点

ここまでGCBの特徴として基本的に良い点を取り上げてきましたが、使ってみてイマイチだと感じた点もあるのでこちらについても触れておきます。

Slack連携がない。Cloud Functionでやるしかない

これが痛すぎるマイナスポイント。なんとビルドの成功/失敗を通知するという基本的な機能ですらデフォルトでSlackやメールに対応していません。

ビルドの結果はGoogle Cloud Pub/Subに流されるので、それをトリガーにしたCloud Functionを自前で用意してSlackなりMailgunのようなメール送信サービスのAPIを叩けということのようです。

https://cloud.google.com/cloud-build/docs/send-build-notifications https://cloud.google.com/cloud-build/docs/configure-third-party-notifications

CircleCIのような主要なCI/CDサービスではSlackなどに簡単に接続できるので、この点はだいぶ方針が異なると思いました。

キャッシュの記法が無い

CircleCIでは、ビルド時間を短くするために有効な方法として依存ライブラリのキャッシュがあります。CircleCIではキャッシュの読み取り/保存のためにrestore_cachesave_cacheという特別なステップが用意されています。

GCBの場合には、そのようなキャッシュのためのステップは用意されていないようです。自分がドキュメントを探した限りでは代替手段の解説も見当たりませんでした。
どうしてもキャッシュを使用したい場合は、Google Cloud Storage(GCS)をキャッシュの代わりに使用することで実現できるかも…?

並列ビルドフローの書き方が分かりにくい、フローのGUIが無い

CircleCIでは2.0からWorkflowを使うことで比較的簡単に並列のビルドフローを組み立てることができ、さらにビルドフローの図をGUIで表示してくれます。
https://circleci.com/docs/2.0/workflows/

GCBでも並列のビルドフローを組み立てることは可能ですが、設定ファイルの記述方法がCircleCIと比較してとても把握しやすいとは言えません。

例として、1つのリポジトリの中にツール用のTypeScriptと、Firebase Hostingにデプロイする用のディレクトリが存在する場合に並列で処理させる場合の書き方を比較します。

ディレクトリの構成はこのような感じです。

.
├── firebase.json
├── node_modules
├── package-lock.json
├── package.json
├── ts # ツール用のTypeScript
├── js # build後に生成されるjs
├── tsconfig.json
├── tslint.json
└── web # Firebase HostingにデプロイするNuxt.jsによるウェブサイト
    ├── assets
    ├── components
    ├── dist
    ├── layouts
    ├── middleware
    ├── node_modules
    ├── nuxt.config.js
    ├── package-lock.json
    ├── package.json
    ├── pages
    ├── plugins
    ├── static
    └── store

まずはCircleCI。一連のステップをbuild, build-web, deployのジョブに分割し、最後のworkflowsで順序や依存関係を記述するスタイルです。

以下の設定ではこのような流れになります(キャッシュ周りなど、今回の説明に不要な一部のステップは省略しています)。

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

.circleci/config.yml

version: 2
jobs:
  build:
    docker:
      - image: circleci/node:9
    working_directory: ~/repo

    steps:
      - checkout
      - run: npm install
      - run: npm run lint
      - run: npm run build

  build-web:
    docker:
      - image: circleci/node:9
    working_directory: ~/web

    steps:
      - checkout
      - run:
          name: npm install
          command: npm install
          working_directory: web
      - run:
          name: lint
          command: npm run lint
          working_directory: web

  deploy:
    docker:
      - image: circleci/node:9
    working_directory: ~/repo

    steps:
      - checkout
      - run:
          name: firebase deploy
          command: npm run deploy -- --token "$FIREBASE_TOKEN"

workflows:
  version: 2
  build_and_deploy:
    jobs:
      - build
      - build-web
      - deploy:
          requires:
            - build
            - build-web

次がGCBです。ステップごとにidを設定しておき、waitForで依存するステップを記述するスタイルです。
waitForが無いステップでは、自動的に1つの上のステップに依存するという挙動になります。 ただしwaitForで'-'を指定した場合は特別で、どのステップにも依存しないという意味になるので並列化する部分の起点になります。

以下の設定ではbuild webの最初のステップでwaitFor: ['-']を指定することで、build TypeScriptbuild webの一連のステップが並列化され、両方が完了してからfirebase deployが実行されます。

cloudbuild.yaml

secrets:
- kmsKeyName: ****
  secretEnv:
    FIREBASE_TOKEN: ****

steps:
# build TypeScript
- name: 'gcr.io/cloud-builders/npm'
  args: ['install']
- name: 'gcr.io/cloud-builders/npm'
  args: ['run', 'lint']
- name: 'gcr.io/cloud-builders/npm'
  args: ['run', 'build']
  id: 'script:build'

# build web
- name: 'gcr.io/cloud-builders/npm'
  args: ['install']
  dir: 'web'
  waitFor: ['-']
- name: 'gcr.io/cloud-builders/npm'
  args: ['run', 'lint']
  id: 'web:lint'

# firebase deploy
- name: 'gcr.io/cloud-builders/npm'
  args: ['run', 'deploy']
  id: 'firebase:deploy'
  waitFor: ['script:build', 'web:lint']
  secretEnv: ['FIREBASE_TOKEN']

個人的には、どの部分が並列に実行されるかがwaitForの挙動を把握していたとしても分かりづらいと感じました。さらに、CircleCIのworkflowと比べてyaml上で全体的なフローが把握しにくいだけではなく、GUIでフロー図を出してくれる機能もないのがマイナスポイントです。

とてもお手軽にマシンスペックを変更可能な点がGCBの長所なのですが、この並列ビルドフローの書きにくさがその長所を活かしにくくしてしまっています。

https://cloud.google.com/cloud-build/docs/configuring-builds/configure-build-step-order?hl=en

設定ファイル中に分岐処理を記述できない

CircleCIのconfig.ymlにはif-elseや、ブランチの指定などによって例えばmasterブランチの場合にだけデプロイのステップを実行する、といった制御を行うことができます。
先ほどのconfig.ymlも最後のworkflowsの部分にfiltersを追加することで、最後のdeployのジョブはmasterブランチの場合のみ実行されるといった制御が可能です。

workflows:
  version: 2
  build_and_deploy:
    jobs:
      - build
      - build-web
      - deploy:
          requires:
            - build
            - build-web
          filters:
            branches:
              only:
                - master

一方で、GCBには1つの設定ファイルの中で条件分岐を行うための記法が存在しません。分岐させたい処理だけシェルスクリプトに記述して実行させるという方法でおそらく実現は可能だと思いますが、全体の見通しは悪くなってしまうでしょう。

代わりにGCBはビルドトリガーを設定する際に、ブランチ名を正規表現で指定してビルドに使用する設定ファイルをcloudbuild.yaml以外にも指定できます。例えば以下のようにブランチ毎に使用する設定ファイルを分けることでブランチ毎のフロー制御が可能です。

  • developブランチ
    • cloudbuild.yaml(lintやテストを行う)
  • masterブランチ
    • cloudbuild_deploy.yaml(デプロイを行う)

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

ただし、CircleCIと異なり設定ファイルを分割する必要があるので、保守性の観点から個人的にはCircleCIの方法の方が好みです。

シークレットキーの扱いが面倒

デプロイを実行するフローを構築する場合に、秘密にしておく必要があるトークンをどうやってCIサービス中に読み取るかという問題があります。トークンが記されたファイルをリポジトリに含めてしまうのが最も簡単ですが、セキュリティ的な観点からは望ましくありません。
CircleCIではconfig.yamlとは別にCircleCIのGUIから環境変数の登録が可能なため、秘密にしておきたいトークンなどは環境変数にセットしてしまうのが一般的な方法です。

GCBの場合でも環境変数を使うというアプローチは可能なのですが、GCB単体にはその機能は存在せず、Cloud Key Management Service(Cloud KMS)というまた別のサービスと連携させる必要があります。
先程のcloudbuild.yamlでは、secrets:の箇所で暗号化されたトークンを環境変数としてセットしています。
https://cloud.google.com/cloud-build/docs/securing-builds/use-encrypted-secrets-credentials?hl=en#encrypting_an_environment_variable_using_the_cryptokey

詳しい使い方は上記のドキュメントを参照してください。正直、環境変数を登録したいだけなのにCircleCIと比較すると手順が多いという印象です。

まとめ

他のCI/CDサービス(今回は代表としてCircleCI)と比較したときのGoogle Cloud Buildの良い点、イマイチな点を紹介しました。最後にまとめとして、自分の主観ですがざっくりとした比較表を用意してみました。

CircleCI Google Cloud Build
ビルドフローの書きやすさ
GitHub連携
dockerコンテナのデプロイ(GCPとの連携)
チャットツール連携
マシンパワー
料金 月額課金 従量課金

GCBをCircleCIと比較してみて、個人的にはCI/CDとしてはまだ発展途上かなという印象でした。GCPとの連携は強みですが、ビルドフローが書きにくかったり、Slack通知がデフォルトでは無いあたりが現時点では少々扱いづらい点です。

一方で、コマンド一発でソースコードをアップロードしてクラウドでビルドができたり、マシンスペックを簡単に変更できるところが特徴的で、Cloud Buildというその名の通りフルマネージドな従量課金ビルドサービスとして非常に便利だと思います。

ブランチ戦略によってビルドやテスト、デプロイを複雑に制御するCI/CD用途よりもdocker buildを並列に行ってコンテナレジストリに登録したり、ソースコードのコンパイルといった、シンプルにコア数の多いハイスペックマシンを用意できると時間が短縮できるタスクを行う方が向いているという印象です。

近い将来、もはやビルド用のハイスペックマシン実機を手元に置いておく必要はなくなるかもしれません。
コーディング環境はコード補完が不自由ない程度のスペックのPCを使い、ビルドしたいときだけGCBにソースコードを送りつけてビルドしてもらう。そんな未来が来るかもしれないですね。

iOSデバイスの情報をアプリ内から取得する

こんにちは。SWETの加瀬(@Kesin11)です。
今回は、iOSデバイスの色々な情報をSwiftから取得するのに便利なLuminousというライブラリを紹介します。

デバイス情報の必要性

SWETではテスト自動化以外にもQAチームによるマニュアルテスト作業の効率化に取り組んでおり、その中でもバグチケットに記載されるデバイスの情報に着目しました。 開発者としては、バグが発生したときの検証デバイスのモデル名や、OSのバージョンといった情報は再現確認やデバッグのために貴重な情報です。

一方、QAチームは検証に使用したデバイスの情報を人力で記入しているという現状があるため、表記が微妙に統一されていなかったり、記載された情報が足りないというケースがありました。そのような場合には、開発者がQAチームに詳細を問い合わせるといったコミュニケーションコストが発生していました。

開発チームとQAチームにヒアリングをしたところ、以下の項目についてバグチケットに記載されていると助かるという回答が得られたため、これらの情報をアプリ内から取得する方法について調査してみました。

  • モデル名(iPhone 8、iPhone Xなどの名前)
  • OSバージョン
  • 画面解像度
  • メモリ
  • ストレージ空き容量

それぞれの項目についてSwiftから情報を取得することは可能なのですが、各情報を取得するためのクラスが別々だったりして少し面倒です。 今回、その面倒を解消してくれるLuminousという各種デバイス情報を取得するのに便利なライブラリを利用してみたのでその紹介をしたいと思います。

Luminous

Luminousはデバイスに関する各種情報を簡単に取得できるAPIを提供してくれるライブラリです。

以下は、前述の5項目についてLuminousを使って情報を取得してみた例です。

print("モデル名: \(Luminous.System.Hardware.Device.current.model)")
print("iOSバージョン: \(Luminous.System.Hardware.systemVersion)")

let nativeScreenBounds = Luminous.System.Hardware.Screen.nativeBounds
print("画面解像度: \(Int(nativeScreenBounds.width)) x \(Int(nativeScreenBounds.height))")

let totalMemoryMb = Int(Luminous.System.Hardware.physicalMemory(withSizeScale: .bytes) / 1024 / 1024)
print("メモリ: \(totalMemoryMb) MB")

let totalMemoryMbRounded = totalMemoryMb + 256 - (totalMemoryMb % 256)
print("メモリ(丸め): \(Int(totalMemoryMbRounded)) MB")

print("ストレージ空き容量: \(Luminous.System.Disk.freeSpace)")

// 実行結果
//
// モデル名: iPhone 6
// iOSバージョン: 10.1
// 画面解像度: 750 x 1334
// メモリ: 989 MB
// メモリ(丸め): 1024 MB
// ストレージ空き容量: 7.03 GB

メモリのところだけ補足をすると、実際に取得できる値はLuminous.System.Hardware.physicalMemoryによる989MBとなっており、残念ながら何らかの理由によってカタログスペックよりも低い値となってしまっています(iPhone 6のカタログスペックは1GB)。
そのため、256刻みで一番近いところに丸めた値も用意してみました。用途によると思いますが、カタログスペックと一致した値を表示させたい場合には上記のコードのように値を丸めてもいいかもしれません。

サンプルプロジェクト

Luminousでは他にも様々な情報を取得できます。他にどういった情報を取得できるのかはREADMEに記載されていますが、リポジトリにサンプルプロジェクトも同梱されているので、これをビルドして実際に確認することもできます。

サンプルプロジェクトをビルドして実機で動かすと以下のようにコンソールに表示されます(一部の情報については伏せています)。

以下の環境で実行した結果となります。

  • Xcode 9.3.1
  • Swift 3.3(現時点で古いですが、サンプルプロジェクトのBuild Settingsで設定されているため)
  • Luminous 1.0.2
  • iPhone 6(実機)
------------
Network
------------
isConnectedViaCellular: false
isConnectedViaWiFi: true
SSID: ****
------------
Locale
------------
currentCountry: en_JP
currentCurrency: JPY
currentCurrencySymbol: JP¥
currentLanguage: en-JP
currentTimeZone: Asia/Tokyo (current)
currentTimeZoneName: Asia/Tokyo
decimalSeparator: .
usesMetricSystem: true
------------
Carrier
------------
allowsVOIP: true
ISOCountryCode: jp
mobileCountryCode: 440
name: ****
networkCountryCode: 20
------------
Hardware
------------
bootTime: 47519.0146807917
physicalMemory: 1.08742e+15
processorsNumber: 2
systemName: iOS
systemVersion: 10.1
isLowPowerModeEnabled: false
------------
Screen
------------
bounds: (0.0, 0.0, 375.0, 667.0)
brightness: 0.255796
isScreenMirrored: false
nativeBounds: (0.0, 0.0, 750.0, 1334.0)
nativeScale: 2.0
bounds: 2.0
------------
Device
------------
current: Deviice
Identifier: iPhone7,2
Type: iPhone6
Model name: iPhone 6
Connectivity: wiFi4G
Screen size: screen4Dot7Inches
identifierForVendor: 1E75D506-AF8D-4743-A0DE-23574EBF75F8
orientation: UIDeviceOrientation
------------
Accessory
------------
2018-05-22 19:10:47.888318 Luminous_Example[630:247318] Couldn't find the "com.apple.private.externalaccessory.showallaccessories" entitlement
connectedAccessories: []
connectedAccessoriesNames: []
count: 0
isHeadsetPluggedIn: false
------------
Sensors
------------
isAccelerometerAvailable : true
isGyroAvailable : true
isMagnetometerAvailable : true
isDeviceMotionAvailable : true
------------
Disk
------------
freeSpace: 7.03 GB
freeSpaceInBytes: 7548157952
totalSpace: 11.21 GB
totalSpaceInBytes: 12039397376
usedSpace: 4.18 GB
usedSpaceInBytes: 4491239424
freeSpaceInPercentage: 62.6955%
usedSpaceInPercentage: 37.3045%
------------
Battery
------------
level: 99.0
state: charging
------------
Application
------------
clipboardString: test
version: 1.0

Luminousを使うときには、このサンプルプロジェクトの表示を見ながら必要なコードを見つけるのが早いでしょう。

サンプルプロジェクトの注意点

残念ながら、バージョン1.0.2現在ではサンプルプロジェクトをビルドするとエラーになってしまいます。

対処法として、Build Phases > Link Binary With Librariesの設定にExternalAccessory.frameworkを追加するか、このフレームワークが必要な該当コードをコメントアウトすることで正常にビルドできるようになります。
この記事を書いている途中にpull requestを送っておいたので、もしかしたら記事が公開される頃には修正されているかもしれません。

その他の注意点としては、プロジェクトの署名に作者のプロビジョニングプロファイルが設定されているので、自分の実デバイスで実行したい場合には実機デバッグができるように署名の設定を変える必要があります。

まとめ

Luminousというライブラリを使ってデバイス情報を取得する方法の紹介をしました。

今回はiOSデバイスについて紹介しましたが、Androidでも同様にモデル名やOSバージョンに限らず、様々な情報が取得可能です。
そちらについても近いうちにまたご紹介していきたいと思いますのでお楽しみに!

SWETの新メンバーから見て驚いたこと、そこから生まれたDIライブラリ不使用宣言

はじめまして!4/1よりSWETに加わった@Kuniwakです。 今回は、私がSWETに入って驚いたことと、そしてSWETだからこそ生まれたものについてお話しします。

まずKuniwakはどんな人?

開発を高速化させるテストや静的検査を生業としています。主に、以下のような記事やスライドを書いています。

では、こんな私がSWETという自動テストのエキスパートのチームで働くことになって驚いたことを紹介します。

SWETに入って驚いたこと

SWETに入って驚いたのは、テストに関連するトピックについてどなたも一家言をもっていたことです。例えば、以下のようなやりとりが実際にありました。

Kuniwak:Mockライブラリ1やDIライブラリ2は必要だと思いますか?いずれもライブラリに頼らずにシンプルに書ける気がします。皆さんはどういうご意見をお持ちでしょうか。

A さん:Mockはライブラリに頼りたいですね。自前でMockのコード書くとMock自体のテストとかが必要になって面倒なことになると思います。

B さん:複数のプログラミング言語にまたがるような組織では、それぞれの言語に対するメンバーの習熟度にもばらつきがありますから、既存の文献やコード資産を参考にできるデファクトなライブラリを使った方がいいという視点もありますね。

C さん:Mockライブラリに限らず、他のライブラリと同じようにメンバーの学習コストやメンテナンスの継続性、利用者数などから考慮するといいのではないでしょうか。

D さん:この記事3でも言われているように、Mockライブラリがパワフルになりすぎて濫用しがち、という話はありますね。個人的な経験で言えば、テストではStub/Spy/Fake4で十分だと思っていて、あまり無理にライブラリは使わない感じですね。

このやりとりでは、会話しているメンバーのいずれもMock/DIライブラリについての深い経験をもっていることに感動しました。また、だからこそそれぞれが違った意見をもっているのだと感じます。このような議論のできる環境は、自動テストのエキスパートで構成されるSWETでなければなかなか巡り会えないのではないでしょうか。

さらに、このような素晴らしい議論ができたことで、以下のとても重要な知識を得られました。

  • Mock/DIライブラリの不必要派は1人じゃない

    今まで、Mock/DIはライブラリを使うのが当たり前というのがよく聞く意見で、私のようにライブラリを不必要と思う派閥はいないのかもしれないと思っていました。しかし、Dさんのように私と同じライブラリの不必要派は一定数存在することがわかりました。

  • Mock/DIライブラリの必要性の議論では無条件の合意をとれない

    ここに書ききれなかったやりとりの中で、Mock/DIライブラリを必要だと思うかどうかは経験してきた言語/プロジェクトの性質によって左右されることがわかりました。例えば、これまで私はJavaScriptやSwiftといった変化の著しい言語を同時に複数を相手にしてきたため、ライブラリの恩恵よりも負の側面を強く感じていたことに気づきました。逆に、このような環境でなければライブラリの恩恵の側面を強く感じたとしても不思議ではありません。つまり、Mock/DIライブラリを使うべきかどうかは言語やプロジェクトに依存するのです。

  • Mock/DIライブラリを使わない選択肢を解説する文献が少ない

    これはBさんとCさんの意見からうかがえる事実です。このやりとりをするまで、文献の少なさを気にしたことがありませんでした。

このやりとりのあと、私はすぐにここで得た知識を活かす機会を思いつきました。私が声をあげることで、本来ライブラリを使わなくてもよい状況でライブラリを無理に使ってしまう問題を減らせると思ったのです。そこで、「バニラDI宣言(Vanilla DI Manifesto)」と「バニラMock宣言」を書くことにしました。

メンバーとの議論から生まれた「バニラDI宣言」と「バニラMock宣言」

バニラDI/Mock宣言とは、状況に応じてライブラリを使わないことを選ぼう、ということを表明した宣言です。このバニラという表現は、何も手が加えられていないという意味でよく使われるものです。ライブラリを使わずに言語本来の自然な書き方で実現していくという方針にぴったりだと思い、この名前をつけました。なお、以降では既に公開済みのバニラDI宣言の方を中心に解説していきます(バニラMock宣言は鋭意準備中です)。

さて、そもそもライブラリを使わないでDIを実現できるのでしょうか?この疑問にお答えするために、バニラDI宣言には各言語のコードサンプルを付属させました。例えば、JavaScriptにおけるバニラDIは次のように表現できます:

// これらは依存先コンポーネントです。後述のコンポーネント内で使われます。
class X {}
class Y {}

// これは依存元コンポーネントです。X と Y への依存をもっています。
class Z {
  constructor(dependency) {
    this.dependency = dependency;
  }

  doSomething() {
    const {x, y} = this.dependency;
    // x と y を使って処理をします。
  }
}

// すべての依存先コンポーネントは、依存元コンポーネントの
// 初期化時に束縛します。
const z = new Z({
  x: new X(),
  y: new Y(),
});

z.doSomething();

ご覧の通り、DIをコンストラクタ引数への指定だけで実現しています(この方法はコンストラクタ注入と呼ばれます)。とても単純な仕組みであることがよくわかると思います。また、静的型検査のある言語でもどのように表現されるのか見てみましょう。例えば、Swiftでは次のようになります:

// これらは依存先コンポーネントです。後述のコンポーネント内で使われます。
class X {}
class Y {}


// これは依存元コンポーネントです。Xと Yへの依存をもっています。
class Z {
    typealias Dependency = (
        x: X,
        y: Y
    )
    private let dependency: Dependency


    init(dependency: Dependency) {
        self.dependency = dependency;
    }


    func doSomething() {
        let (x, y) = self.dependency;
        // x と y を使って処理をします。
    }
}


// すべての依存先コンポーネントは、依存元コンポーネントの
// 初期化時に束縛します。
let z = Z(dependency: (x: X(), y: Y()));

z.doSomething();

静的型検査のある言語でも同様の書き方で実現できることがわかります。 さて、どうして私はこのようなバニラDIを好むのでしょうか。その理由は以下の6点です:

  • 実装が単純
  • 簡単に利用できる
  • 依存するライブラリはゼロ
  • 初心者に対しても可読性が高い
  • 多くの言語で実用可能
  • 悪い設計だとすぐに破綻する

このうち上の5つは自明だと思いますが、私が特に強調したいのは最後の「悪い設計だとすぐに破綻する」ということです。では、破綻のスメルが出ている具体例を見てみましょう。

バニラDIが可視化する設計の破綻

まず、コンストラクタの引数が多くなってきたケースです。たとえば次のようなクラスがあったとします:

class SomethingGreatService {
    private let foo: Foo
    private let bar: Bar
    private let fooBar: FooBar
    private let baz: Baz
    private let qux: Qux
    private let quux: Quux


    init(foo: Foo, bar: Bar, fooBar: FooBar, baz: Baz, qux: Qux, quux: Quux) {
        self.foo = foo
        self.bar = bar
        self.fooBar = fooBar
        self.baz = baz
        self.qux = qux
        self.quux = quux
    }
}

クラス定義もだいぶつらそうですが、それよりつらいのはコンストラクタの呼び出し側です。たとえば以下のようになるかもしれません:

let service = SomethingGreatService(
    foo: Foo(),
    bar: Bar(
        corge: Corge()
    ),
    fooBar: FooBar(
        grault: Grault(
            garply: Garply()
        )
    ),
    baz: Baz(
        waldo: Waldo()
    ),
    qux: Qux(),
    quux: Quux(
        plugh: Plugh()
    )
)

特にSomethingGreatServiceを対象としたユニットテストでは繰り返しこのクラスを作成することになることが多く、テストを書いている途中でうんざりすることになります5。これに対して、DIライブラリを使えば呼び出し側の記述をかなり省略できます。この点だけを見れば、DIライブラリの方が優れているという印象を受けることでしょう。しかし、バニラDIでは異なる見方をします。

バニラDIでは、このような状況を設計破綻のスメルとみなします。今回のスメルは次の2つの可能性のいずれかを示唆しています:

  • SomethingGreatServiceの責務過多
  • 依存対象であるFooBar等の抽象度の不足

そして、それぞれへ対応する解決策は次のようになります:

  • SomethingGreatServiceの責務を分割
  • 依存対象の関係を整理してこれらをまとめた新コンポーネントを作成

さて、これらの解決策によって先ほどの引数が多いという問題も同時に解決できます。前者の責務分割では、SomethingGreatServiceが複数のコンポーネントへ別れますから、それぞれのコンポーネントへコンストラクタ引数が分散されます。後者の依存物の整理では、直接の依存が新コンポーネントを通しての間接的な依存へ変わりますから、直接の依存であるコンストラクタの引数は少なくなります6

このように、バニラDIでは設計の破綻が書きづらさや面倒さとなってすぐに可視化されます。これらを取捨選択して解決していく過程で、自然と適切な責務の分割や抽象化へと導かれるのです。これこそがバニラDIの最大の利点の1つだと私は考えています。

終わりに

この記事では、私から見てSWETに入って驚いたこと、そしてテストに関するエキスパートが集結しているSWETでなければ思いもつかなかったバニラDI/Mock宣言について紹介しました。バニラDI/Mock宣言についてご質問やご意見等ある場合は、お気軽にissueKuniwakまでお問い合わせください。では、弊社主催であるiOS Test Nightの運営スタッフとしてお会いできることを楽しみにしております。


  1. Mockライブラリとは、テストケースの実行に必要な偽のコンポーネントをつくりやすくするライブラリです。JavaScriptではSinon.JS、SwiftではCuckoo、Pythonではunittest.mockが有名です。

  2. DIライブラリとは、Dependency Injectionをやりやすくするライブラリです。JavaScriptではInversifyJS、SwiftではDIKitが有名です。

  3. t-wadaさんと家永さんの対談記事:「希薄化したTDD、プロダクトの成長のために必要なものは?〜『健全なビジネスの継続的成長のためには健全なコードが必要だ』対談(6)

  4. これらはすべてテスト用の偽のオブジェクトのことです。Stubとは、テスト対象のコンポーネントへの入力をテスト用の入力へ差し替えるためのオブジェクトです。Spyはテスト対象のコンポーネントからの出力を記録するためのオブジェクトです。Fakeはテスト対象からの依存対象を簡易的に代替するオブジェクトです。それぞれを詳しく知りたい場合はxUnit Test Patternsを読むことをお勧めします。

  5. このつらさを解消するためにデフォルト引数を使うこともできますが、一般的にこれはあまりよい選択ではありません。デフォルト引数は何がデフォルトとして使われても問題のないケースでのみ使うべきだからです。もし、デフォルト引数を変えたら壊れてしまうような場合は、定義元を読まない限りわからない暗黙の期待を埋め込んでいるということを意味します。このような暗黙の期待は可読性を損ないます。つまり、呼び出し側が特定の振る舞いを期待する場合は明示的に引数に指定する方がよいのです。今回のケースでは何がデフォルトとして使われても構わないとはいえないので、デフォルト引数を使わない方がよいでしょう。

  6. 素直に考えると、新コンポーネントを作成しても依存している数には変わりないと思うかもしれません。しかし、実際はそうなりません。まず、抽象コンポーネントを定義することでStub/Spy等のテストダブルを書きやすくなります。テストダブルは依存物を必要としないですから、テスト内では間接的な依存を意識しなくてすむようになります。また、テスト外の場合でも、SomethingGreatServiceを内部的に利用するクラスが既に作成済みの依存物を保持していることが多く、テスト内と同様に間接的な依存は見えなくなります。もし、間接的な依存が見えてしまう場合はカプセル化が不十分であることを示唆しています。

Puppeteerによるフルページスクリーンショットを画像遅延読み込みに対応させる

こんにちは、薦田です(@toshiya_komoda)。 今回はChromeブラウザのブラウザテスト自動化ツールであるPuppeteerに関するTipsを紹介します。

Puppeteerによるフルページスクリーンショット

Puppeteerは、Chrome DevTools ProtocolのNode.jsクライアントであり、Chrome DevToolsチームがメンテナンスしています。 その主な用途の1つにブラウザ自動テストが挙げられます。 PuppeteerはChromeの操作に特化しており、操作対象がChromeに限定されるもののSeleniumにはない便利なAPIを利用できます。

Puppeteerで利用できる便利なAPIの1つに、フルページスクリーンショットがあります。 フルページスクリーンショットとは、ウェブページの上端から下端までを1つなぎの画像としてスクリーンショットを撮ったものです。

例えば、以下の画像はフルページスクリーンショットの例です。DeNA Testing Blogの過去の記事です。

DeNA Testing Blog記事のフルページスクリーンショット

Seleniumでもスクリーンショットを取得可能ですが、取得対象の領域がviewportに制限されます。 このためSeleniumでフルページスクリーンショットを実現しようとすると、スクロールしながら複数枚のスクリーンショットを撮った後、1枚につなぎ合わせるといった実装が必要になります。

Puppeteerを利用した場合の実装は以下のように簡潔なものになります。 Node.js v8.11.1, Puppeteer 1.3.0で動作確認しています。

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch({headless: true});
  const page = await browser.newPage();
  page.setViewport({width: 1200, height: 800})
  const url = 'http://swet.dena.com/entry/2018/02/16/104605'
  await page.goto(url);
  await page.waitForNavigation({waitUntil:'networkidle2', timeout:5000})
            .catch(e => console.log('timeout exceed. proceed to next operation'))
  await page.screenshot({path: 'testing-blog.png', fullPage:true})
  console.log("save screenshot: " + url)
  await browser.close()
})();

補足:Puppeteerの以前のバージョンではフルページスクリーンショットの高さに16384pxの制限がありましたが、1.2.0以降のバージョンではこちらの制限はなくなっているようです。

遅延読み込み対策

Puppeteerでは、前述のようにフルページスクリーンショットを簡単に利用することができ、Visual Regression Testなどに利用することが可能です。 しかし、画像の遅延読み込みに対応したウェブページでは、フルページスクリーンショットをうまく取得できないことがあります。

こちらの画像がそのような例です。 lazysizesという画像遅延読み込みライブラリのデモページで別に画像を用意して試しています。

遅延読み込みページのフルページスクリーンショット

使用画像: Test by Ray Bouknight is licensed under CC BY 2.0

ページ中にロードしきれていない画像が存在しており、正しくフルページスクリーンショット画像が撮れていません。

この問題はPuppeteerのissueにも報告されており、 不具合扱いではありませんが、Feature RequestとしてPuppeteer側での対応が提案されている状況です。 現時点では以下のように一度ページ下端までウィンドウをスクロールさせることで問題を回避できます。

const puppeteer = require('puppeteer');

async function scrollToBottom(page, viewportHeight) {
  const getScrollHeight = () => {
    return Promise.resolve(document.documentElement.scrollHeight) }

  let scrollHeight = await page.evaluate(getScrollHeight)
  let currentPosition = 0
  let scrollNumber = 0

  while (currentPosition < scrollHeight) {
    scrollNumber += 1
    const nextPosition = scrollNumber * viewportHeight
    await page.evaluate(function (scrollTo) {
      return Promise.resolve(window.scrollTo(0, scrollTo))
    }, nextPosition)
    await page.waitForNavigation({waitUntil: 'networkidle2', timeout: 5000})
              .catch(e => console.log('timeout exceed. proceed to next operation'));

    currentPosition = nextPosition;
    console.log(`scrollNumber: ${scrollNumber}`)
    console.log(`currentPosition: ${currentPosition}`)

    // 2
    scrollHeight = await page.evaluate(getScrollHeight)
    console.log(`ScrollHeight ${scrollHeight}`)
  }
}

(async () => {
  const viewportHeight = 1200
  const viewportWidth = 1600
  const browser = await puppeteer.launch({headless: true});
  const page = await browser.newPage();
  page.setViewport({width: viewportWidth, height: viewportHeight})
  const url = 'http://afarkas.github.io/lazysizes/#examples'
  await page.goto(url);

  // 1
  await page.waitForNavigation({waitUntil: 'networkidle2', timeout: 5000})
            .catch(e => console.log('timeout exceed. proceed to next operation'));

  await scrollToBottom(page, viewportHeight)
  await page.screenshot({path:'scroll-sc.png', fullPage: true})

  console.log("save screenshot: " + url)
  await browser.close()
})();

こちらのコードは、Vasilev氏のPuppeteer Screenshotsの実装を参考にしつつ、以下2点の改良を加えたものです。

  1. page.waitForNavigation()関数の条件として、networkidle2(ネットワーク接続が2本以下の状態が500ms以上続く状態)を 指定しています。 ただしウェブサイトによっては、 画像ロード以外のネットワーク接続の影響でこの条件が恒久的に満たされないため、timeoutオプション(デフォルトは30sec)を明示的に指定しています。
  2. また、scrollToBottom()関数の中ではwhileループの中でスクロールしながら毎回scrollHeightの値を更新しています。 これは、スクロール操作によってページのscrollHeightの値が動的に変わる場合があるためです。

ページを一度下端までスクロールしておくことで、さきほどのページのフルページスクリーンショットは以下のように改善されます。

遅延読み込みページのフルページスクリーンショット(遅延読み込み対応版)

画像が全てロードされ、きれいなフルページスクリーンショットが取得されています。

補足Tips

以前のバージョンにあった高さ制限の問題とは別に、Puppeteerでは非常に大きなフルページスクリーンショット画像を取得しようとすると、その下端がかけてしまうことがあります。 こちらの現象は遅延読み込みとは別の問題ですが、こちらのissueで説明されています。 こちらの問題はChromium側での対応が必要ということで、Chromiumの不具合としてあがっていますが、Puppeteer1.3.0にバンドルされたChromiumでは未対応です。

この現象が発生した場合には、スクロールしながらスクリーンショットを複数枚取得し最後につなぎ合わせる、といったSeleniumの場合と同様のワークアラウンドが必要になります。

DeNA TechCon 2018でSWETの取り組みについて発表してきた

初めまして、SWETの坂本です。先日行われたDeNA TechCon 2018のBlueStageにて、SWETの取り組み内容についての発表を行いました。発表資料は以下になります。

発表内容

今回の発表ではSWETの具体的な取り組みとして、"画像"が絡むようなテスティングに関する2つの取り組みについて、前後半に分けて紹介しました。

前半の発表では、SWETで開発を行っている、WebアプリのVisual Regression Testを支えるクローラーについてお話ししました。
クローリングの高速化のための並列化の方法、Visual Regressionに使うためのスクリーンショットの取り方のテクニックなどについて述べました。また、単純なクローラでは突破することができない登録フォームなどに対応するために、機械学習の活用を検討している状況についても発表しました。

後半の発表では、テンプレートマッチング(画像処理)を使った自動化のツラミを低減するためにSWETで開発しているサービス「ExMachina」についてお話ししました。
ExMachinaの基本的なアイデアは、「ページ情報」という形でテスト用のイメージ素材を管理することにより、WebやNativeアプリと同様にセマンティックな情報を利用してUIテストの自動化が行えることです。 発表ではその基本的なコンセプトを共有し、ExMachinaを使った自動化の処理の流れ、ExMachinaのクローラーへの応用などについて説明しました。

おわりに

SWETでは、まだベストな方法が確立されていないテスティングの領域に対してR&D的な取り組みを行っており、今後も拡充していきたいと思っています。
また、その取り組みの中で機械学習を活用したアプローチも検討しています。

SWET視点のDroidKaigi 2018参加レポート

はじめまして。SWETの外山(@sumio_tym)です。
2018年1月よりSWETで働いています! どうぞよろしくお願いします!

さて、2018年2月8日(木)から2日間にわたって開催されたDroidKaigi 2018の参加レポートをお届けします。
DroidKaigi 2018では、私達DeNA SWETグループからも、私を含めて3名が登壇しました!

f:id:swet-blog:20180215202534j:plain
(C) 2018 DroidKaigi実行委員会

このエントリでは、SWETらしく、テスティングに関係するセッションを以下の2つに分けてご紹介します。

  • DeNA SWETメンバーによる発表
  • DeNA以外の方による発表

DeNA SWETメンバーによる発表

Androidで利用できるデバイスファームのメリット・デメリットの紹介

duckさんによる発表です。

サーバーで集中的に管理されたAndroid端末を、遠隔操作・テストできる「デバイスファーム」について、 以下の観点で比較していました。

  • 端末を操作できるか?
  • 自動テストで何が動かせるか?
  • 利用者がテストを用意しなくても良い「モンキーテスト」ができるか?
  • adbで接続できる「ダイレクトアクセス」ができるか?
  • CI/CDと連携可能か?

また、最後に、以下の点を強調していたのが印象的でした。

「必ず、無料枠で、自分達の業務に適用できるか試してみること」

デバイスファーム選びの参考にしてみてください。

UIテストの実行時間を短縮させる方法

tarappoさんによる発表です。

前半では、UIの自動テストに関する以下の点について、具体例を挙げながら解説していました。

  • テスト実行時間が増加しやすい要因
  • テスト実行時間増加により引き起こされる問題

後半では、実行時間短縮のために必要な、以下のプラクティスを解説していました。

  • 無駄にUIの自動テストを増やさない
  • sleepの利用を減らす
  • 事前にテストアセットを用意する
  • テストを実行させる端末の種類を減らす
  • テストケースを分割して、テストの実行を並列化する

特に、単純な並列化だけではスケールしない場合に、 ボトルネックを特定しつつ更に並列度を上げていく過程はとても参考になると思います。

Espressoテストコードの同期処理を究める

外山の発表です。

AndroidのUIテストツールEspressoを使っていると、 テスト対象アプリのUI更新の完了を待てずに失敗することがあります。

その問題を解決するために必要なIdlingResourceについての知識や、 UI Automatorを併用してUI更新完了を待ち合わせる方法について説明しました。

独自の非同期処理機構を持つRxJavaなどを採用しているアプリでは、Espressoによるテストの失敗頻度が高くなります。 そのようなときに、この発表を参考にして、失敗しないEspressoテストに書き換えてみてください。

また、外山が過去のDroidKaigiで発表した以下の内容も役に立つと思います。あわせて参照してみてください。

DeNA以外の方による発表

はじめてのUnit Test

@fushiroyamaさんによるハンズオンです。 自分のEspressoの発表と時間が重なっていたため、参加は叶わなかったのですが、スライドを読むだけでも参考になります。

このスライドでは、AndroidのLocal Unit Testを書くのに必要な知識が、MockitoやRobolectricの使い方も含めて丁寧に解説されています。

ハンズオンに利用するソースコードも公開されていますので、 AndroidのLocal Unit Testに入門したい方はチャレンジしてみると良いと思います。

How to improve your MVP architecture and tests

kiriminさんによる発表です。

MVPアーキテクチャを採用したアプリにおいて、Presenter部分をテストする方法を解説する内容でした。

  • MVP採用時にありがちなアンチパターンの解説
  • 正しくViewとPresenterを分離し、Mockitoを使ってテストを書く方法

が丁寧に説明されており、具体的でとても分かりやすい内容でした。

MVPアーキテクチャを採用するときはもちろん、そうでないときでも、 Androidのユニットテストを書くときの参考にすると良いと感じました。

Moving Forward with JUnit 5

Marcel Schnelleさんによる発表です。

JUnit4が抱えている問題の説明から始まり、以下のような流れで解説していました。

このGradleプラグインはAndroid Test Night #1でも少し話題になっていました。

当時はLocal Unit Testしかサポートしていませんでしたが、 現在ではInstrumented Testもサポートしている(Experimental)とのことです。

AndroidでJUnit4が使えるようになるまでは長い時間が必要でしたが、JUnit5は案外すぐに使えるようになるかも知れません。 今後が楽しみですね!

おわりに

DroidKaigi 2018のセッションのうち、テスティングに関係するものをご紹介しました。

SWETグループでは、DroidKaigiでの登壇に限らず、積極的に対外的なアウトプットを行っています。 加えて、SWETグループ主催の勉強会である、Android Test NightiOS Test Nightも開催していますので、ご興味の有る方は是非ご参加ください。

また、SWETグループではAndroidに限らずテストの自動化やCI/CDに興味があり、私達と一緒に働いてくれる仲間を募集しています。ご応募お待ちしています!

Golang testingことはじめ(3)〜アプリケーションのテスト〜

こんにちは。
2回にわたってGolang標準の testing パッケージを使ったユニットテストについてお伝えしてきました。

今回はGolangで作成したアプリケーションをテストする際に利用できるライブラリなどについて紹介します。

この文章中に登場するサンプルは GitHub にありますので、実際に動作させることが可能です。

$ go get github.com/duck8823/sample-go-testing
$ cd $GOPATH/src/github.com/duck8823/sample-go-testing
$ git checkout refs/tags/blog
$ dep ensure # 依存パッケージのダウンロード

なお、文章中のコマンドは全てバージョン1.9.2での動作を確認しています。

アプリケーションのテスト

アプリケーションをテストする場合はいくらかのテクニックが必要になります。
ここでは、モックを使ったテストやウェブアプリケーションのrouting(エンドポイント)のテストについてGolangでの書き方について記述します。

モック

通常、ある程度の大きさ以上のアプリケーションを作成する場合、複数のパッケージに分けて分割します。 分割したアプリケーションのそれぞれのコードをテストする場合、モックを利用して外部(テスト対象以外)の依存を減らすことが望ましいです。
Golangでは gomock が提供されており、 testing フレームワークと組み合わせて利用することができます。
gomock では静的なモック用のソースコードを作成します。

インストール

モックを生成する mockgen はcliツールですが、go get でインストールできます。
Golangはツールの配布・利用も簡単です。

$ go get github.com/golang/mock/mockgen

モックの作成

モックはインタフェースに対して作成します。

foods/food.go には food インタフェースが含まれているので、これに対してモックを生成します。

foods/food.go

package foods

type Food interface {
    Name() string
}

type Apple struct {
    cultivar string
}

func NewApple(cultivar string) *Apple {
    return &Apple{cultivar}
}

func (apple *Apple) Name() string {
    return apple.cultivar
}

mockgenコマンドを実行します。-sourceオプションでインタフェースが含まれるソース、-destinationオプションで出力先を指定します。

$ mockgen -source=foods/food.go --destination foods/mock_foods/mock_foods.go

foods/mock_foods/mock_foods.go

// Code generated by MockGen. DO NOT EDIT.
// Source: foods/food.go

// Package mock_foods is a generated GoMock package.
package mock_foods

import (
    gomock "github.com/golang/mock/gomock"
    reflect "reflect"
)

// MockFood is a mock of Food interface
type MockFood struct {
    ctrl     *gomock.Controller
    recorder *MockFoodMockRecorder
}

// MockFoodMockRecorder is the mock recorder for MockFood
type MockFoodMockRecorder struct {
    mock *MockFood
}

// NewMockFood creates a new mock instance
func NewMockFood(ctrl *gomock.Controller) *MockFood {
    mock := &MockFood{ctrl: ctrl}
    mock.recorder = &MockFoodMockRecorder{mock}
    return mock
}

// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockFood) EXPECT() *MockFoodMockRecorder {
    return m.recorder
}

// Name mocks base method
func (m *MockFood) Name() string {
    ret := m.ctrl.Call(m, "Name")
    ret0, _ := ret[0].(string)
    return ret0
}

// Name indicates an expected call of Name
func (mr *MockFoodMockRecorder) Name() *gomock.Call {
    return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Name", reflect.TypeOf((*MockFood)(nil).Name))
}

デフォルトのパッケージ名はソースとなるパッケージに mock_ の接頭辞をつけたものになります。これは -package オプションで変更することができます。
その他のオプションは gomock -help で確認することができます。

モックを使ったテスト

インタフェース food を引数にした以下の関数に対してテストを記述します。

func (duck *Duck) Eat(food foods.Food) string {
    return fmt.Sprintf("%s ate %s", duck.Name, food.Name())
}

モックインスタンスは以下のように作成します。

ctrl := gomock.NewController(t)
food := mock_foods.NewMockFood(ctrl)

作成したモックから EXPECT() に続いて関数を指定し、Return の引数で戻り値を指定することができます。

food.EXPECT().Name().Return("kougyoku")

上記の場合、 Name() が呼ばれたら "kougyoku" を返します。

animals/animals_07_test.go

package animals_test

import (
    "github.com/duck8823/sample-go-testing/animals"
    "github.com/duck8823/sample-go-testing/foods/mock_foods"
    "github.com/golang/mock/gomock"
    "testing"
)

func TestDuck_Eat_02(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    food := mock_foods.NewMockFood(ctrl)
    food.EXPECT().Name().Return("kougyoku")

    duck := animals.NewDuck("tarou")
    actual := duck.Eat(food)
    expected := "tarou ate kougyoku"
    if actual != expected {
        t.Errorf("got: %s\nwont: %s", actual, expected)
    }
}

上記の例でduck.Eat(food)では、FoodのName()を呼び出します。
Foodはインタフェースなので本来であればその実装に依存しますが、 モックを作成して特定の文字列を返却するように指定しているのでテスト実行時の依存を減らすことができます。

$ go test -v animals/animals_07_test.go animals/animal.go 
=== RUN   TestDuck_Eat_02
--- PASS: TestDuck_Eat_02 (0.00s)
PASS
ok      command-line-arguments  0.007s

この例ではName() の例では引数は必要ありませんでした。
戻り値を設定したい関数に引数が必要な場合は、呼び出される際の引数を指定して限定できます。
値が何でもいい場合は gomock.Any() を指定できます。

ここでは

type Hoge interface {
    Foo(foo string) string
}

というインタフェースを想定します。
モックを利用する場合は以下のようになります。

hogeMock.EXPECT().Foo(gomock.Any()).Return("bar")

フラグのテスト

コマンドライン引数やオプションなど、クライアントツールやサーバーアプリを作成した場合にフラグを利用することはよくあると思います。
フラグによって動作を変更する場合のテストを想定します。 標準の flag を利用してオプションを実現していた場合、設定する値を変えて複数回 flag.Parse() しようとすると flag redefined: ... とエラーになってしまいます。
そこで、フラグを設定する場合flag.Parse()は利用せずflag.NewFlagSet()で生成されたインスタンスを利用することで、テスタブルなコードを記述することができます。

フラグをパースする関数は以下のようにしました。

app/flag.go

package app

import (
    "flag"
    "os"
)

func ParseFlag(args ...string) string {
    flg := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
    optC := flg.String("c", "default value", "flag usage")
    flg.Parse(args)

    return *optC
}

上記の関数は以下のように利用することができます。

package main

import "github.com/duck8823/sample-go-testing/app"

func main() {
    optC := app.ParseFlag(os.Args[1:]...)
    fmt.Printf("option c: %s", optC)
}

関数ParseFlagをテストするコードは以下のようにしました。

app/flag_test.go

package app_test

import (
    "github.com/duck8823/sample-go-testing/app"
    "testing"
)

func TestParseFlag(t *testing.T) {
    t.Run("with argument, returns value", func(t *testing.T) {
        actual := app.ParseFlag("-c", "hello")
        expected := "hello"

        if actual != expected {
            t.Errorf("got: %s\nwont: %s", actual, expected)
        }
    })

    t.Run("with no argument, returns default value", func(t *testing.T) {
        actual := app.ParseFlag()
        expected := "default value"

        if actual != expected {
            t.Errorf("got: %s\nwont: %s", actual, expected)
        }
    })
}
$ go test -v app/flag_test.go
=== RUN   TestParseFlag
=== RUN   TestParseFlag/with_argument,_returns_value
=== RUN   TestParseFlag/with_no_argument,_returns_default_value
--- PASS: TestParseFlag (0.00s)
    --- PASS: TestParseFlag/with_argument,_returns_value (0.00s)
    --- PASS: TestParseFlag/with_no_argument,_returns_default_value (0.00s)
PASS
ok      command-line-arguments  0.013s

異なる値でフラグのパースを複数回行なっていますが、エラーにならず実行できています。

Webアプリケーションのroutingをテストする

ウェブアプリケーションを開発する場合は、実際にエンドポイントへアクセスして正しい結果が返ってくるかテストすることがあります。

アプリケーションを起動してテストする

利用するフレームワークがhttp.Handlerの実装となっている場合は後述のhttptestが利用できるので、そちらを利用した方がよいです。

ここでは、実際にアプリを起動してhttpクライアントからリクエストを投げ、ハンドラーの想定通りのメソッドが実行されているかどうかを確認します。
また、このサンプルではEchoフレームワークを利用しています。

ランダムで空いてるポートをListen

多くのウェブフレームワークではサーバー起動時にポートを指定しますが、指定したポートが利用されていた場合はテストが実行できません。
以下の関数はランダムで空いてるポートを取得します。

func randomAddress(t *testing.T) net.Addr {
    t.Helper()

    listener, err := net.Listen("tcp", ":0")
    listener.Close()

    if err != nil {
        t.Fatal(err)
    }
    return listener.Addr()
}

サーバーの起動

以下のソースコードについてのテストを想定します。
実際にサーバーを起動し、正しいレスポンスが返却されるかをチェックします。

app/server.go

package app

import (
    "fmt"
    "github.com/labstack/echo"
    "net/http"
)

func CreateServer() *echo.Echo {
    e := echo.New()
    e.GET("/hello", hello)
    return e
}

func hello(c echo.Context) error {
    message := fmt.Sprintf("Hello %s.", c.QueryParam("name"))
    return c.String(http.StatusOK, message)
}

利用するウェブフレームワークがフォアグラウンドでサーバーを起動する場合、テストケース内でサーバーを起動すると、そこでテストがストップしてしまいます。
別のターミナルセッションでサーバーを起動した後でテストを実行することもできますがテスト実行以外のプロセスに依存するのは避けた方がよいでしょう。

goルーチン内でサーバーを起動することで、テストケース内で完結するようにしてみます。

app/server_test.go

package app

import (
    "io/ioutil"
    "net"
    "net/http"
    "net/url"
    "testing"
)

func Test_RoutingWithStartServer(t *testing.T) {
    addr := randomAddress(t)

    s := CreateServer()
    go func() {
        s.Start(addr.String())
    }()

    reqUrl := &url.URL{
        Scheme:   "http",
        Host:     addr.String(),
        Path:     "hello",
        RawQuery: "name=duck",
    }
    resp, err := http.Get(reqUrl.String())
    if err != nil {
        t.Fatal(err)
    }

    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        t.Fatal(err)
    }

    actual := string(body)
    expected := "Hello duck."
    if actual != expected {
        t.Errorf("got: %s\nwont: %s", actual, expected)
    }
}

func randomAddress(t *testing.T) net.Addr {
    t.Helper()

    listener, err := net.Listen("tcp", ":0")
    listener.Close()

    if err != nil {
        t.Fatal(err)
    }
    return listener.Addr()
}

これを実行すると、

go test -v app/server_test.go app/server.go
=== RUN   Test_RoutingWithStartServer

   ____    __
  / __/___/ /  ___
 / _// __/ _ \/ _ \
/___/\__/_//_/\___/ v3.2.6
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
                                    O\
⇨ http server started on [::]:50985
--- PASS: Test_RoutingWithStartServer (0.00s)
PASS
ok      command-line-arguments  0.021s

ログからも実際にサーバーが起動しているのがわかります。

goルーチンを利用してサーバーを起動した場合の課題

上述の例では正しく動いているように見えました。
しかしサーバーの起動に時間がかかってしまう場合はどうでしょうか。

--- a/vendor/github.com/labstack/echo/echo.go
+++ b/vendor/github.com/labstack/echo/echo.go
@@ -587,6 +587,7 @@ func (e *Echo) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 // Start starts an HTTP server.
 func (e *Echo) Start(address string) error {
+       time.Sleep(30 * time.Second)
        e.Server.Addr = address
        return e.StartServer(e.Server)
 }

ウェブフレームワークechoのコードに手を加え、サーバーが起動する際にSleep処理を追加しました。

$ go test -v app/server_test.go app/server.go
=== RUN   Test_RoutingWithStartServer
--- FAIL: Test_RoutingWithStartServer (0.00s)
        server_test.go:27: Get http://[::]:64158/hello?name=duck: dial tcp [::]:64158: getsockopt: connection refused
FAIL
exit status 1
FAIL    command-line-arguments  0.013s

サーバーが立ち上がっていないのでconnection refusedのエラーが出てしまいました。
この場合、サーバーが立ち上がるまで待つといった工夫をしなければなりません。

サーバーの起動待ちを実装するのは面倒ですが、簡単に実現できる方法が用意されています。
ここではgoルーチンを使わない方法でテストを書き直してみましょう。

httptestを利用してテストを実行する

Golangでは、Webサーバーのテストをサポートするnet/http/httptestも用意されています。
このパッケージを利用することで、routingのテストも容易になります。

app/server_01_test.go

package app

import (
    "io/ioutil"
    "net/http/httptest"
    "testing"
)

func Test_RoutingWitHttpTest(t *testing.T) {
    s := CreateServer()
    server := httptest.NewServer(s)
    defer server.Close()

    client := server.Client()

    resp, err := client.Get(server.URL + "/hello?name=duck")
    if err != nil {
        t.Fatal(err)
    }

    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        t.Fatal(err)
    }

    actual := string(body)
    expected := "Hello duck."
    if string(actual) != expected {
        t.Errorf("got: %s\nwont: %s", actual, expected)
    }
}

空いているポートの検索・利用は httptest が行なってくれるので、 シンプルに記述することができます。

$ go test -v app/server_01_test.go app/server.go
=== RUN   Test_RoutingWitHttpTest
--- PASS: Test_RoutingWitHttpTest (0.00s)
PASS
ok      command-line-arguments  0.013s

httptest.NewServerすることでラップされたサーバーが立ち上がります。 ウェブフレームワークのStart()をコールしているわけではないので、Sleepされません。
また、ポートも自分で指定せずにランダムで空いているポートを利用してくれます。

リクエストをシミュレーションする

httptestではサーバーを起動するほか、リクエストをシミュレーションする方法を用意しています。

レスポンスはResponseRecorderを介して行われます。
Recorderを利用することでレスポンス内容の取得も楽になります。

package app

import (
    "net/http/httptest"
    "testing"
)

func Test_RoutingWitHttpTestSimulate(t *testing.T) {
    s := CreateServer()

    req := httptest.NewRequest("GET", "/hello?name=duck", nil)
    rec := httptest.NewRecorder()

    s.ServeHTTP(rec, req)

    actual := rec.Body.String()
    expected := "Hello duck."
    if actual != expected {
        t.Errorf("got: %s\nwont: %s", actual, expected)
    }
}

サーバーを立ち上げる方法ではレスポンスのBodyio.Reader型のため変換が必要でしたが、 レコーダーのBodyio.Readerにバッファ機能をつけたリッチなbytes.Budder型となっています。
そのためString()で変換することができ、テストもシンプルに記述できました。

$ go test -v app/server_02_test.go app/server.go
=== RUN   Test_RoutingWitHttpTestSimulate
--- PASS: Test_RoutingWitHttpTestSimulate (0.00s)
PASS
ok      command-line-arguments  0.011s

最後に

これまで3回に渡ってGolangが標準で提供しているテストの機能の一部を紹介しました。 言語としてテストを強くサポートしていることが感じられたのではないでしょうか。
まだまだ紹介しきれていない機能やオプションなどがたくさんあります。
今後も有益な機能について紹介できればと思っております。