Unityアプリを無差別タップで自動テストする

画面写真をクリックするとWebGLビルドに飛びます。ソースコードはGitHubに置いてあります。かつて製品で使ったものに著しい拡張を施した直後なため、 まだ実戦経験が浅いコードです。バグがあったら教えていただけると助かります。

こんにちは。技術部平山です。

今回はゲーム開発における耐久テストを行うための支援ツールについてお話します。 技術的には「EventSystemをコードからつついて勝手にタップさせる」だけのことですが、 単に技術的な問題と捉えるよりも、品質保証という広い問題の一部として捉える方が 実りが多いことと思います。

使い方

いきなりですが使い方から示します。

まず、GitHubから必要なファイル を持っていってください。debugTapMark.pngは便宜のために用意しただけで、別途好きな画像を使っていただいてもかまいません。 サイズもお好みで良いですが、16x16から32x32くらいで良いかと思います。

そして、DefaultDebugTapperをどこかのGameObjectにつけます。

f:id:hirasho0:20190729124855p:plain

MarkSpriteにテキトーなSpriteを指定し(debugTapMark.pngでも良いです)、 Auto Start Enabledをチェックします。

これで、実行開始と共に8つのポインタが暴れ回って画面中をタップ、ドラッグ、長押しするようになります。 どこかにEventSystemが存在している必要があるので、ご注意ください。

なお、起動と同時に乱打が始まるのはおそらく不便でしょうから、 コンポーネントのenabledはfalseにしておくのが良いかと思います。

除外オブジェクト設定

DefaultDebugTapperはGameObject名にDebugUiという文字列が入っているオブジェクトと、 その子孫は叩かないようにしてあります。このまま使ってもかまいませんが、 DefaultTapperを継承して、bool ToBeIgnored(GameObject)を実装すれば、 プロジェクトごとの事情に合わせられます。

どこを叩くかカスタマイズしたい

DefaultDebugTapperは全力で画面をランダムに叩きます。 しかし、場面によってカスタマイズしたい事もあるでしょう。

例えば、

  • 技選択モードでは技カードを優先的に叩きたい
  • 戦闘中に撤退ボタンは滅多に押したくない
  • キャッシュクリアボタンを押されるのは困る
  • ドラッグがメインの入力系なので、ランダムだとゲームが進まない

といったことが考えられます。 ランダムに任せておくよりも良いテストにするために、 カスタマイズするのは良い考えです。 ゲームにAIが実装されているのであれば、AIと組み合わせることで 「実際に人が画面をタップした場合と同じ経路を通して自動プレイさせる」 ということも可能になります。UIが複雑だったり、通信が絡んだり、 アニメーションが絡んだりする場合は、UIの状態制御が複雑になって どうしてもバグが出やすくなりますから、 人間が触る場合と極力同じ経路を通してテストすることが重要です。

この場合、DebugTapperを継承して、 void UpdateTap(int tapIndex)を実装すれば望みのことができます。 例えばDefaultDebugTapperの実装は以下のようになっています。

protected override void UpdateTap(int tapIndex)
{
    const float durationMedian = 0.1f;
    const float durationLog10Sigma = 0.5f; // 3SDで1.5==3.3秒
    const float distanceMedian = 0.01f;
    const float distanceLog10Sigma = 2; // 上下100倍
    var fromPosition = new Vector2(
        Random.Range(0f, (float)Screen.width),
        Random.Range(0f, (float)Screen.height));
    var distanceLog = NormalDistributionRandom();
    distanceLog *= distanceLog10Sigma;
    var distance = Mathf.Pow(10f, distanceLog) * distanceMedian * Mathf.Max(Screen.width, Screen.height);
    var rad = Mathf.PI * 2f * Random.value;
    var v = new Vector2(
        Mathf.Cos(rad) * distance,
        Mathf.Sin(rad) * distance);
    var toPosition = fromPosition + v;
    var durationLog = NormalDistributionRandom();
    durationLog *= durationLog10Sigma;
    var duration = Mathf.Pow(10f, durationLog) * durationMedian;
    Fire(tapIndex, fromPosition, toPosition, duration);
}

重要なのは最後の行で、つまり、「どこで押すか」「どこで離すか」「どれくらいの時間をかけるか」 の3つの情報を指定してFire()を呼びます。場所はスクリーン座標です。 場面に応じてきちんと分岐させれば、全画面を順番に移動するようなシナリオを実装することも可能ですし、 さらに凝って、そのシナリオを外部からスクリプトで与えることもできるでしょう。

なお、上のコードでは、 「ほとんどは短距離短時間でタップになるが、たまに長距離長時間のものが混ざる」 いう実装になっています。 ほどよく長押しやドラッグが発生する匙加減にしており対数正規分布を使っています。 非常に便利な分布なので、いずれ別の機会にブログにするかと思います。

デバグ用のオブジェクトをシーンに置きたくない

DebugTapperがついたgameObjectをシーンに置いてしまうのは楽ですが、 デバグ用のオブジェクトをシーンに置くと製品版にも入ってしまいます。 私は極力これを避けるために、デバグ用のオブジェクトはコードで動的生成するようにしています。 その場合、だいたい以下のようなコードを書くことになります。

var go = new GameObject("DebugTapper");
tapper = go.AddComponent<DefaultDebugTapper>();
tapper.ManualStart(8, tapMark);
tapper.enabled = false;

適当にgameObjectをnewして、DefaultDebugTapperをAddComponentし、 ManualStart()を呼びます。enabledをfalseにしているのは、 デバグ機能から有効化するまでは無効にしておきたいからです。 起動と同時に乱打させたいなら不要ですが、そういうことはないでしょう。

運用

さて、これをどう使うか?

私のおすすめは、夜会社を出る前に、PCのエディタで製品を実行して、自動タップを有効化し、 そのまま帰ることです。朝までエラーが出ないで生きていれば一安心、 死んでいれば慌てて直す、ということになります。 チームのプログラマ全員がこれを習慣にできれば、 ほとんどゼロコストで、10時間以上×人数分のテストが毎日行われることになります。 時給○○円でテストをお願いする、と仮定して計算してみてはいかがでしょう。 いくら節約できますか?

私の乏しい経験での話ですが、これを初めて実行したアプリは、 以下のようなエラーで数秒のうちに死ぬこともあります。

  • アニメーション中に想定外のものを叩いて予想外の挙動をする
  • オブジェクト破棄後にイベントが飛んでnull死する
  • シーン遷移直前、直後に想定外のところを叩かれて破棄後/初期化前でnull死
  • 通信中に入力を禁止するためのオブジェクトが出しっぱなしになって進行不能
  • 通信中の入力制限を忘れていてAPIを連打、あるいは結果が返る前にシーン遷移

マルチプレイの対戦中であれば、 通信タイミングとUI入力の絡み合いで不正な状態になるエラーも起きやすいでしょう。 人によるテストプレイがある程度行われて安心できている状況ですら、 連打やマルチタップ、特定タイミングでの入力、といったものをケアしたコードが書けていることは稀であり、 自動タップをつっこむと大抵はひどいことになります。

また、お客さんに出した後に「たまにクラッシュレポートが来るけど再現できる気がしない」 ということは結構あると思いますが、 自動テストであれば率が低いバグでも低コストで再現させられる可能性があります。

なお、運用に際しては、できるだけ大量にAssertを入れて、ログに溜めておくと良いかと思います。 かつての東京プリズンの開発では、1回のゲームで数百KBのログを吐き、 それが一晩でだいたい150回くらい回っていました。 毎朝数十MBのログから「Assert」という文字列を探して、 一つでもあれば異常として修正する、ということを繰り返しました。 開発末期にはAmazon Web Service(AWS)のEC2でマシンを6台借りて、3対3のマルチプレイ対戦を一晩自動で回し続け、 一度もエラーが出ないことを確認する、というところまでやりました。 さらにスマホ実機を並べて行えればさらに頑健になるでしょうが、 スマホの場合はビルドの手間、インストールの手間、電池の問題などが絡んで一気に面倒になりますので、 予算と相談ということになるかと思います。

なお、セキュリテイ上の理由から、PCを回しっぱなしで帰宅できないケースもおありかと思いますが、 可能であれば開発に普段使っているPCそのもので回して帰るのが理想です。 どうやってそれに近づけるかは、ご所属の組織によっても違ってくるでしょう。 「ボタン押して帰るだけ」という手軽さが失われれば、結果面倒くさくなって誰も回さなくなり、 「ほとんどタダで濃厚なテスト(QA)ができる」というコスト削減効果を捨てることになります。 別のPCを用意するだけでも、「最新をpullしてから実行」と手間が一つ増えてしまい、 途端に面倒くさくなりました。AWSのEC2はリモートログインの手間があるせいで さらに面倒でした。数秒手間が増えるだけで顕著にやる気が失せますので、 手間の問題は軽視しない方が良いと思います。

もう一つ工夫として、standaloneビルドを作って、1台で複数アプリを起動して並列テスト、 というのも有効です。スマホ用のアプリであれば、ノートPCでも4つくらいは同時に起動できるはずです。 そうすれば、テストの強度が4倍になります。 standaloneビルドが動作する状況を作っておくと良いでしょう。 standalone用のAssetBundleをビルドできるようにする、というのが一番面倒なところでしょうか。 エディタで実行する時と遜色なく情報が取れるように、 ログ機能は充実させておくことをおすすめします。 Slackを利用したデバグに関する記事に書いたように、 ログは自動でslack等に集積すると良いでしょう。

ログ機能

デバグ支援のために、ログ機能を用意してあります。 いつ、何番のポインタが、どのgameObjectに、何のイベントを発火させたか、 ということがログに溜まっており、LogItemsLogTextで取れます。

プロファイリング支援

大抵の場合、イベントが発火した先でやる処理は重くなりがちです。 「ボタンを押されたらポーズメニューのプレハブをInstantiate」 のような書き方はスパイクの原因になります。

スパイクを防ぐために、使うかどうかわからないデータも全部シーンに並べておく ようにすれば、今度はシーンの初期化が遅くなりますし、 余計にメモリも食います。ある程度は動的に初期化する作りの方が妥当です。

とはいえあまりスパイクが激しいのも辛いので、それを調べる助けになるように、 プロファイラで出るようにしておきました。 イベント関数の呼出しの前後でCustomSampler を使っているだけです。

DeepProfilingを有効にしなくても、 OnPointerClickやOnPointerDownの下に重い処理があるかないかくらいはわかります。

実装

DebugTapperのコードを見ていただければそれが全てなのですが、 残念ながら実装は結構面倒くさいので、多少は説明しておこうと思います。

概要

基本はEventSystemに乗ることです。そうすることでコード量を減らせますし、 実際に人がタップする時とできるだけ同じ経路を通すことができます (もちろんEventSystemで入力を取っているアプリでの話ですが)。

基本的な流れは、

となります。こう書くと簡単そうですが、PointerEventDataに何が入っているのか、 イベントの発火条件、発火順、などに関しては何も文書がありません。 公式のコード を見るしかない状態です。

公式を見て完全に再現すれば同じになるのでしょうが、全部忠実に作るのはあまりに面倒くさいので、 今回の実装は完全再現はしていません。

再現度

どれくらい再現しなければならないかは、アプリがどれくらい標準の挙動に依存しているかによります。 例えばアプリがPointerEventData.rawPointerPressを使っているなら、 公式と同じようにデータを入れねばなりません。 また、Clickよりも先にUpが発火することに依存しているのであれば、合わせないといけません。 そうしないとテストの価値が落ちてしまいます。

今回の実装で標準に合わせないで手抜きをしたのは、主に以下です。 必要なら標準に準拠させる作業をやっていただけると良いかと思います(是非私にください)。

  • EnterとExitは発火しない
  • Move,Scrollは発火しない
  • eligibleForClick、button、scrollDeltaを入れてない
  • worldPosition, worldNormalはobsoleteなこともあって入れてない
  • pointerPressとpointerDragの中身が微妙に違う
  • 公式だとdownのハンドラがなければupも来なくなるが、本実装はupだけでも発火する。

中には意図して合わせていないものもあります。 「公式の挙動が少し変わっただけで死ぬ潜在的な危険」も検出したいからです。 公式の挙動は文書化されておらず、あくまで「今の実装」にすぎません。 up、click、dragEndの順序や、各イベントのタイミングでPointerEventDataのフィールドがどうなっているかに関しては 何ら仕様がないのです。「EndDragでdraggingはまだtrueなのか?もうfalseなのか?」 みたいなことが多数あります。

また、EnterとExitですが、実装を見て初めて知ったのですが、 タッチによるドラッグ中には発火しません。押した時にEnter、離した時にExitです。 タッチでは事実上使い物にならないし、実装も面倒くさいので、そもそも発火させないことにしました。 「カードから指が外れた時にExitが来る」ことを期待するコードは危険です。 エディタでマウスで操作していると発火するので、そういうものだと思ってしまいます(過去の私)。 同様に「downのハンドラがないとupが来ない」も今回初めて知りました。

途中で破棄!

実装で一つ注意がいるのは、途中でオブジェクトが消える可能性を考えて書くことです。 Downが発火したオブジェクトが、Upまで生きている保証はありません。 途中でnullに化けた時のケアが必要です。

さらに厄介なことに、イベントは親に伝播します。 例えばボタンはImageの子にTextがある構成で、 Raycastが当たったのがTextでも、Textを持つgameObjectがイベントハンドラを持っていなければ、 上のImageに伝播します。

では、Downした直後にそれが破棄されたり、Drag中に破棄されたりしたらどうすべきでしょうか? Downの後のUpは親に送るべきでしょうか?Dragの続きやEndDragは親に送るべきでしょうか? それによって、ExecuteEvents.ExecuteHierarchy() を使うか、 ExecuteEvents.Execute() を使うかが変わってきます。 このあたりも意識が必要です。 公式ではUpはDownが発火したオブジェクトにしか発火しません。 これに合わせるならExecute()の方を使うことになります(nullチェックしてないけどExecuteの第一引数ってnullでも大丈夫なんですかね?)。

おわりに

一番お伝えしたいことは「面倒くさいことは機械にやらせよう」ということです。 この密度でタップを執拗に繰り返すテストは人間にはできません。 しかし、お客さんが何十万人もいて毎日触っていれば、 数人で数時間触るのとは比較にならないほどの合計時間になります。 少しでもそれに近い状態を前もって再現するためにも、機械化が必要なのです。

弊社のQA(品質保証)エンジニアにこの記事について感想を求めた所、

  • 落ちるまでの平均時間をグラフ化してKPIに盛り込む
  • 座標の統計データを取って、「このあたりが危険」というヒートマップを作る

といったツールの発展もありそうだ、とのこと。 自動テストを育てていくことを通して、チームの品質保証体制そのものを成長させて 行けると良いのでは?という話でした。

というわけで夢は広がるのですが、実装は結構面倒ですので多少の気合が必要です。 東京プリズンの時点ではDown,Up,Clickしか対応していなかったので楽でしたが、 その後ドラッグ必須の製品に導入することになり、 EventSystemの公式実装を読む羽目になってしまいました。 私は「コードを読んだら負け」だと思うのですが、他にやりようがないのです。 自分で参加したアプリであれば、Down,Up,Clickだけで足りると確信を持って言えますが、 自分が参加していない製品の場合「どういう組み方をしているかわからない」 という前提に立つ必要があります。最終的には完全再現が求められるのでしょう。

実のところ、InputModuleは自作した方がいいんじゃないかと思ったりします。 そうすれば正確な仕様を文書化してからアプリを作れます。 さらに言えば、EventSystemごと自作してしまえば、 PointerEventDataのような「実装を見ないといつ何が入っているかわからない型」 をアプリに渡さずに済みます。 さらに、欲しくて欲しくてたまらない 「このオブジェクトではイベントを消費しないで下のオブジェクトにイベントを流す」 という機能も足せます。

でも、やめておいた方がいいですね。だましだまし使うことにしましょう。 標準であることの利点は大きいですし、 もし自作が標準と混ぜて使われたりしたら、どんな事故が起こるかわかったものではありませんから。