テストでスクリーンショットを確認する環境を構築したので紹介します。
テストプログラム中で確認ができます。期待値画像の作成は手作業となりますが、その後のリグレッションテストは自動化が可能だと思います。
目次
スクリーンショットの確認の必要性
AndroidのインターフェイスはGUI(Graphical User Interface)です。画面の見た目や操作性も、アプリの大切な機能や品質の一つです。
例えば、画面上に40×30[ピクセル]のOKボタンとNGボタンが並んで配置されていたら、正しく押せるでしょうか?私のようなオジサンは老眼で小さい字は見えないし、大人の太い指で近接した小さなボタンを間違えることなく押すことは難しいです。
これを改善するためにボタンを大きくして間隔を離す、見た目と操作性の改善が必要です。
しかし、この改善を行ったとしても、改善後の状態になったことを確認する方法がありません。
EspressoはisDisplayed()というViewMatcherを用意していますが、これは表示の有無を判定するだけです。改善の前も後も「表示された」の判定は同じです。
ここで登場するのが「スクリーンショットを取得して目視で確認する」という方法です。
スクリーンショットならば「表示された」に加えて「どのように」が確認できるので上記の問題が解決できます。
スクリーンショットの確認を行う環境
スクリーンショットの確認を行う環境の概略を図に示します。
確認を行う前に期待値の作成が必要です。アプリを起動してスクリーンショットを撮り、目視で問題ないことを確認して、確認済みの画像を期待値にします。
スクリーンショットの確認はアサーション(TruthまたはHamcrest)を用いて行います。
(1)スクリーンショット(期待値)
アプリを起動してスクリーンショットを撮ります。
撮り方1:AVDのコントロールパネル
AVDのコントロールパネルからキャプチャアイコンをクリックして撮ります。
ただし、この方法は実機で行えません。
撮り方2:adb shell screencap
「adb shell screencap」コマンドを使ってスクリーンショットを撮ります。
コマンドをバッチ(Windowsのバッチ)化して、Android StudioのExternal Toolに登録し、KeyMapに割り当てて起動させるのが便利です。
【コマンドをバッチ化】
set yymmdd=%date:/=% set yymmdd=%yymmdd:~2,6% set hhmmss=%time::=% set hhmmss=%hhmmss:.=% set hhmmss=%hhmmss: =0% set filename=Capture_%yymmdd%%hhmmss%.png adb shell screencap /data/local/tmp/%filename% adb pull /data/local/tmp/%filename% adb shell rm /data/local/tmp/%filename% pause
【External Toolsに登録】
※File > Settings > Tools > External Tools
【KeyMapに割り当て】
※File > Settings > KeyMap > MyTools > スクリーンショット > Add Keyboard Shortcut
(2)確認済み画像
スクリーンショットを目視して期待通りの表示であることを確認します。問題が無ければ、この画像を期待値にします。
期待値データを格納するフォルダを作成し、Favoritesへ登録すると、Android Studioで管理が出来ます。
【フォルダを作成】
【Favoritesへ登録】
(3)期待値転送
テストでスクリーンショットの確認を行うためには、期待値画像がAndroid端末上に存在しなければなりません。
テストを始める前に、一度だけAndroid StudioからAndroid端末へ期待値の転送を行います。
期待値の格納場所
Android StudioのTerminalからAndroid端末へadb shellでアクセスする場合のユーザIDはshellです。
/data/local/tmp(Android端末内のフォルダ)の所有者はshellなのでAndroid Studioからアクセス可能です。ここに期待値を格納します。
# pwd /data/local/tmp # ls -la total 48 drwxrwx--x 6 shell shell 4096 2020-06-10 08:09 . ...adb shellからアクセス可能 drwxr-x--x 3 root root 4096 2020-04-04 00:00 .. drwxrwxrwx 6 shell shell 4096 2020-06-06 11:47 .studio drwxrwxrwx 3 shell shell 4096 2020-04-07 11:31 dalvik-cache drwxrwxr-x 2 shell shell 4096 2020-06-10 08:05 device-explorer drwxrwxrwx 3 shell shell 4096 2020-06-10 08:09 verification ...期待値データを格納
転送:adb push
「adb push」コマンドを使って期待しを転送します。
コマンドをバッチ(Windowsのバッチ)化して、Android StudioのExternal Toolに登録して使うのが便利です。
【コマンドをバッチ化】
adb push Expect/. /data/local/tmp/verification/PACKAGE NAME/ pause
※PACKAGE_NAMEの部分は自分の環境に合わせて下さい。
【External Toolsに登録】
※File > Settings > Tools > External Tools
(4)期待値データ
バッチ(pushExpectFolder.bat)を実行すると、パッケージ名(PANCKAGE NAME)以下に期待値データが配置されます。
パッケージ名以下に配置したのは、一台のAndroid端末で複数のアプリをテストする場合を想定したためです。
(5)期待値取得
期待値データから画像(PNGファイル)を取得してBitmapへ変換します。
private const val VERIFICATION_DATA = "/data/local/tmp/verification" fun getExpectFolderPath(): String { val _pkgName = InstrumentationRegistry .getInstrumentation().targetContext.packageName return "${VERIFICATION_DATA}/${_pkgName}" } fun getExpectFilePath(filename: String): String { val _filename = filename.format(Build.VERSION.SDK_INT) val _folderPath = getExpectFolderPath() return "${_folderPath}/${_filename}" } fun getExpectBitmap(filename: String): Bitmap { val _filePath = getExpectFilePath(filename) val _bitmap: Bitmap? = BitmapFactory.decodeFile(_filePath) if(_bitmap == null) throw TestException("期待値画像(${_filePath})が取得できません !") return _bitmap }
private static final String VERIFICATION_DATA = "/data/local/tmp/verification"; public static String getExpectFolderPath() { StringBuffer _Path = new StringBuffer(VERIFICATION_DATA); _Path.append("/"); _Path.append(getInstrumentation().getTargetContext().getPackageName()); return _Path.toString(); } public static String getExpectFilePath(String filename) { String _Filename = String.format(filename, Build.VERSION.SDK_INT); StringBuffer _Path = new StringBuffer(getExpectFolderPath()); _Path.append("/"); _Path.append(_Filename); return _Path.toString(); } public static Bitmap getExpectBitmap(String filename) { String _FilePath = getExpectFilePath(filename); Bitmap _Bitmap = BitmapFactory.decodeFile(_FilePath); if(_Bitmap == null) throw new TestException(String.format( "期待値画像(%s)が取得できません !", _FilePath)); return _Bitmap; }
Android端末のAPIが異なると、同じアプリの画面であってもスクリーンショットのピクセル値が微妙に異なる場合があります。描画のアルゴリズムの違いや、テーマによる表現の違いが原因と思われます。
そのため、Android端末のAPIに合わせて、APIごとの期待値を取得する仕組みを持たせています。
(6)スクリーンショット(実測値)
スクリーンショット(実測値)は期待値と同じ作成経路で取得する必要があります。なので、一旦PNGファイルに保存してからBitmapへ変換を行っています。
fun takeScreenShot(): Bitmap { val _uiDevice = UiDevice.getInstance( InstrumentationRegistry.getInstrumentation()) val _folder = TemporaryFolder() try { _folder.create() } catch (e: IOException) { throw TestException("スクリーンショットが撮影できません !", e) } val _file = File(_folder.root, "Actual.png") _uiDevice.takeScreenshot(_file) return BitmapFactory.decodeFile(_file.absolutePath) ?: throw TestException("スクリーンショットが撮影できません !") }
public static Bitmap takeScreenShot() { UiDevice _UiDevice = UiDevice.getInstance( InstrumentationRegistry.getInstrumentation()); TemporaryFolder _Folder = new TemporaryFolder(); try { _Folder.create(); } catch (IOException e) { throw new TestException_j("スクリーンショットが撮影できません !", e); } File _File = new File(_Folder.getRoot(), "Actual.png"); _UiDevice.takeScreenshot(_File); Bitmap _Bitmap = BitmapFactory.decodeFile(_File.getAbsolutePath()); if(_Bitmap == null) throw new TestException_j("スクリーンショットが撮影できません !"); return _Bitmap; }
(7)一致テスト(アサーション)
アサーション(TruthまたはHamcrest)を用いてスクリーンショットの確認を行います。
アサーションは「Bitmap画像の一致を確認するTruthのSubject」(または「Bitmap画像の一致を確認するHamcrestのMatcher」)を用いました。
“%d”の部分はAPI番号に置き換わります。
@Test fun ScreenShot_Truth_test() { val _shot = takeScreenShot() assertThat(_shot.width).isEqualTo(320) assertThat(_shot.height).isEqualTo(480) assertThat(_shot.config).isEqualTo(Bitmap.Config.ARGB_8888) onView(withId(R.id.btnNG)).perform(click()) ... クリックで"Sample"に変更 assertThat(takeScreenShot()) .isSameAs(getExpectBitmap("Sample_320x480_api%d.png")) onView(withId(R.id.btnOK)).perform(click()) ... クリックで"Test"に変更 assertThat(takeScreenShot()) .isSameAs(getExpectBitmap("Test_320x480_api%d.png")) } @Test fun ScreenShot_Hamcrest_test() { val _shot = takeScreenShot() assertThat(_shot.width, `is`(320)) assertThat(_shot.height, `is`(480)) assertThat(_shot.config, `is`(Bitmap.Config.ARGB_8888)) onView(withId(R.id.btnNG)).perform(click()) ... クリックで"Sample"に変更 assertThat(takeScreenShot(), IsSameAs(getExpectBitmap("Sample_320x480_api%d.png"))) onView(withId(R.id.btnOK)).perform(click()) ... クリックで"Test"に変更 assertThat(takeScreenShot(), IsSameAs(getExpectBitmap("Test_320x480_api%d.png"))) }
public void ScreenShot_Truth_test() { Bitmap _Shot = takeScreenShot(); assertThat(_Shot.getWidth()).isEqualTo(320); assertThat(_Shot.getHeight()).isEqualTo(480); assertThat(_Shot.getConfig()).isEqualTo(Bitmap.Config.ARGB_8888); onView(withId(R.id.btnNG)).perform(click()); ... クリックで"Sample"に変更 assertThat(takeScreenShot()) .isSameAs(getExpectBitmap("Sample_320x480_api%d.png")); onView(withId(R.id.btnOK)).perform(click()); ... クリックで"Test"に変更 assertThat(takeScreenShot()) .isSameAs(getExpectBitmap("Test_320x480_api%d.png")); } @Test public void ScreenShot_Hamcrest_test() { Bitmap _Shot = takeScreenShot(); assertThat(_Shot.getWidth(), is(320)); assertThat(_Shot.getHeight(), is(480)); assertThat(_Shot.getConfig(), is(Bitmap.Config.ARGB_8888)); onView(withId(R.id.btnNG)).perform(click()); ... クリックで"Sample"に変更 assertThat(takeScreenShot(), new IsSameAs_j(getExpectBitmap("Sample_320x480_api%d.png"))); onView(withId(R.id.btnOK)).perform(click()); ... クリックで"Test"に変更 assertThat(takeScreenShot(), new IsSameAs_j(getExpectBitmap("Test_320x480_api%d.png"))); }
不一致をデバッグ
画像の不一致はデバッグが難しいです。デバックを容易にしたければ、不一致の詳細な情報を残すしかありません。
不一致ピクセルのハイライト
不一致ピクセルをハイライトした画像を作成すると、不一致箇所が明確になってデバッグに役立ちます。
ハイライトした画像を残すプログラムは次のように書けます。これをアサーションのSubject(Matcher)に組み込みます。
private const val HIGHLIGHT = Color.MAGENTA private const val LOGNUMBER = 5 private fun createResult( act: Bitmap, exp: Bitmap, info: MutableList<String>): Bitmap { val _rst = Bitmap.createBitmap(act.width, act.height, act.config) if (act.width != exp.width || act.height != exp.height || act.config != exp.config) { val _canvas = Canvas(_rst) val _paint = Paint() _paint.color = HIGHLIGHT _canvas.drawRect(0f, 0f, _rst.width.toFloat(), _rst.height.toFloat(), _paint) info.add("サイズまたはピクセルフォーマットが異なっています") } else { var _pixelCount = 0 for (y in 0 until act.height) { for (x in 0 until act.width) { val _actPixel = act.getPixel(x, y) val _expPixel = exp.getPixel(x, y) if (_actPixel != _expPixel) { if (_pixelCount < LOGNUMBER) { info.add("Point(%d,%d) A(0x%08X) != E(0x%08X)" .format(x, y, _actPixel, _expPixel)) _pixelCount++ } _rst.setPixel(x, y, HIGHLIGHT) } else { _rst.setPixel(x, y, _actPixel) } } } } return _rst }
private static final int HIGHLIGHT = Color.MAGENTA; private static final int LOGNUMBER = 5; private Bitmap createResult(Bitmap act, Bitmap exp, List<String> info) { Bitmap _Rst = Bitmap.createBitmap( act.getWidth(), act.getHeight(), act.getConfig()); if(act.getWidth() != exp.getWidth() || act.getHeight() != exp.getHeight() || act.getConfig() != exp.getConfig()) { Canvas _Canvas = new Canvas(_Rst); Paint _Paint = new Paint(); _Paint.setColor(HIGHLIGHT); _Canvas.drawRect(0, 0, _Rst.getWidth(), _Rst.getHeight(), _Paint); info.add("サイズまたはピクセルフォーマットが異なっています"); } else { int _PixelCount = 0; for (int y = 0; y < act.getHeight(); y++) { for (int x = 0; x < act.getWidth(); x++) { int _ActPixel = act.getPixel(x, y); int _ExpPixel = exp.getPixel(x, y); if(_ActPixel != _ExpPixel) { if(_PixelCount < LOGNUMBER) { info.add(String.format( "Point(%d,%d) A(0x%08X) != E(0x%08X)", x, y, _ActPixel, _ExpPixel)); _PixelCount++; } _Rst.setPixel(x, y, HIGHLIGHT); } else { _Rst.setPixel(x, y, _ActPixel); } } } } return _Rst; }
APIによるスクリーンショットの違い
APIが違う実測値と期待値を比較すると不一致になる場合があります。
これは描画のアルゴリズムが違ったり、テーマによる表現(配置や文字のフォントなど)が変わるためであると思われます。
不一致箇所はだいたい境界部分に集中します。下図のような感じです。
このような不一致が発生したら、APIの違いを疑うとよいです。