Created
September 16, 2025 16:20
-
-
Save nowsprinting/05c6d6393363c175297c4405502cdfde to your computer and use it in GitHub Desktop.
E2Eだけがテスト自動化じゃない! Unity製ゲームの開発者テスト チュートリアル in CEDEC2025
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| --- | |
| presentationID: 1XcysMJK1NEwvCXX73SPp430Sqxvci7k9rZ0mzWEQFIw | |
| title: deck CEDEC2025 E2Eだけがテスト自動化じゃない! Unity製ゲームの開発者テスト チュートリアル | |
| breaks: true | |
| --- | |
| <!-- { "layout": "title", "freeze": true } --> | |
| # E2Eだけがテスト自動化じゃない! Unity製ゲームの開発者テスト チュートリアル | |
| 長谷川 孝二 / @nowsprinting | |
| 株式会社サイバーエージェント/ ハブシステムズ有限会社 | |
| --- | |
| <!-- { "layout": "body", "freeze": true } --> | |
| # 自己紹介 | |
| - 長谷川孝二 / @nowsprinting | |
| - プログラマー/ SET (Software Engineer in Test) | |
| - モバイルアプリ開発など | |
| → 2019年からゲーム領域のSET | |
| - Blog: <https://www.nowsprinting.com/> | |
|  | |
| <!-- SET, SWET, SDET など組織によって名称はさまざま --> | |
| --- | |
| <!-- { "layout": "body", "freeze": true } --> | |
| # 著書 | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
| © Unity Technologies Japan/UCL | |
| <!-- 『Unity Test Framework完全攻略ガイド』は第3版を夏コミで頒布予定 --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| # アジェンダ | |
| - テスト自動化==E2Eという先入観 | |
| - シフトレフト戦略 | |
| - 階層化テスト戦略 | |
| - ユニットテスト | |
| - 統合テスト | |
| - ゲームプレイのテスト | |
| - まとめ | |
| --- | |
| <!-- { "layout": "section_title" } --> | |
| # テスト自動化==E2Eという先入観 | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## End-to-End(E2E)テストとは | |
| - フロントエンドからバックエンドまで、本番環境と同等の環境で実施するテスト | |
| - フロントエンドは実機(端末) | |
| - バックエンドはステージング以上 | |
| - 主にQA担当者が実施 | |
| - 開発終盤の工程から着手 | |
| - **手動テストは必然的にE2Eテスト** | |
| --- | |
| <!-- { "layout": "image" } --> | |
| ## 画像:氷山のメタファ(1/2) | |
|  | |
| <!-- E2Eテストを行なう人たちのイメージ --> | |
| --- | |
| <!-- { "layout": "image" } --> | |
| ## 画像:氷山のメタファ(2/2) | |
|  | |
| <!-- 氷山の一角、海上に出ているところから全体をテストしようとしている。手動テストではデバッグメニューに強く依存。 --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## E2Eテスト自動化の何が問題なのか | |
| - E2Eテストの自動化は難しい | |
| - 合否判定が難しい(人類は優秀) | |
| - ゲームジャンルによっては操作も難しい | |
| - CI環境構築が難しい | |
| - 実行に時間がかかる | |
| - 不安定になりやすい(ネットワークなど) | |
| **→ 投資対効果(ROI)が低い** | |
| <!-- 合否判定:「進行不能にならなければいい」は比較的容易に実現可能だが、人間はもっとさまざまなことに気づける。マルチモーダルLLMなどでできることは増えるが、今はまだ「安いアイルランド兵を出せ」 --> | |
| <!-- 不安定:ネットワーク、サーマルスロットリング、OSのダイアログなど --> | |
| --- | |
| <!-- { "layout": "image", "ignore": true } --> | |
| ## 画像:It works! | |
|  | |
| <https://www.reddit.com/r/ProgrammerHumor/comments/sv37th/software_engineering_in_one_image/> | |
| <!-- 水が流れることは確認できるので無駄ではない。でもこれだけで「動いている」とは言えない --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## テストはE2Eだけではない | |
| - 手動テストがE2Eなのは**人間が実施する**ために生じた制約 | |
| - テストは**もっと自由になっていい** | |
| - 目的(検証)を達成する手段は複数ある | |
| - 最適な選択をするためには知識が必要 | |
| <!-- このセッションでお持ち帰りいただきたいこと --> | |
| --- | |
| <!-- { "layout": "section_title" } --> | |
| # シフトレフト戦略 | |
| Shift-left Strategy | |
| <!-- 03:30 / 16:44 --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## バグは発見が遅いほど修正コスト増 | |
| - コミュニケーションコスト | |
| - バグチケット起票 | |
| - 担当者アサイン | |
| - 再現確認 | |
| - 実装を思い出すコスト(プログラマは昨日書いたコードを覚えていない) | |
| - 影響範囲(バグを前提に書かれたコード) | |
| <!-- コミュニケーションコスト:AI (LLM) の利用で下げられるが、ゼロにはならない --> | |
| --- | |
| <!-- { "layout": "image" } --> | |
| ## 図:バグは発見が遅いほど修正コスト増 | |
|  | |
| Applied Software Measurement, Capers Jones, 1996 | |
| [Building Security into The Software Life Cycle](https://www.blackhat.com/presentations/bh-usa-06/bh-us-06-Morana-R3.0.pdf), Marco M. Morana, 2006 | |
| <!-- 赤がコスト、横軸は時間経過、工程。Codingで発見 1 に対して、QA(Function Test)発見では 10x。図の左端がCodingだが、仕様や要件まで遡れる。データは古いかつゲーム領域では数字は異なるはずだが、傾向は同じはず --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## シフトレフト戦略とは | |
| - バグの発見をより早く → **シフトレフト**(タイムラインの左方向) | |
| - **開発者テスト** | |
| - 静的解析(Roslynアナライザー、インスペクションツール) | |
| - 形式手法による仕様の検証 | |
| - 発見よりも**バグの混入を防ぐ**のが最善 | |
| <!-- 静的解析、形式手法については、本セッションでは触れません。開発者テストにフォーカス --> | |
| --- | |
| <!-- { "layout": "image" } --> | |
| ## 図:Rare社のバグ数グラフ | |
|  | |
| GDC2019 [Automated Testing of Gameplay Features in 'Sea of Thieves'](https://www.gdcvault.com/play/1026366/Automated-Testing-of-Gameplay-Features), Robert Masella, Rare, Ltd., 2019 | |
| <!-- "Sea of Thieves"と、同スタジオの前作とのバグ数の比較。ただしテストを書いただけで実現されたのではなく、運用も頑張っている。詳しくはスライド参照。動画もあります: https://www.youtube.com/watch?v=X673tOi8pU8 --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## 開発者テスト | |
| - QA向けビルド以前に開発者が行なうテスト | |
| - ユニットテスト | |
| - 統合テスト | |
| - 必然的に自動テスト | |
| - CIで繰り返し実行 | |
| - Unityでは Unity Test Framework パッケージを使用して記述・実行できる | |
| <!-- 「テスト駆動開発(TDD)」と混同されがちですが、TDDは設計・開発手法であり、開発者テストのことは指しません。本セッションではTDDの話はしません --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## 開発者テストのPros(1/2) | |
| **実装時点** | |
| - メソッド単位で実行できる(UI不要) | |
| - 何度も繰り返し実行可能 | |
| - ドキュメントとしての価値(例示による仕様) | |
| - PRレビューで最初に見る対象に | |
| - Agentic Codingのガードレールとして利用 | |
| <!-- PRレビュー:テスト観点がokなら少なくとも仕様は満たしコーナーケースも考慮されていると判断できる --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## 開発者テストのPros(2/2) | |
| **実装以降**(イテレーティブ開発の場合) | |
| - リグレッションから保護 | |
| - 変更に対するハードルが下がる | |
| - 内部品質(≒ 保守性)の向上 | |
| - 理解容易性(解析性) | |
| - 変更容易性(修正性) | |
| - テスタビリティ(テスト容易性、試験性) | |
| <!-- リグレッション:退行、デグレード --> | |
| <!-- 内部品質の高いコードはLLMフレンドリーなので、Agentic Codingとも相性がよい --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## 開発者テストのCons | |
| - テストを書くのにコストがかかる | |
| - テストをメンテナンスするコストがかかる | |
| - 運用コストがかかる | |
| - ナイトリー実行の失敗ハンドリング | |
| - 不安定なテストの監視と除外 | |
| <!-- 運用コスト:軽視されがちだが重要。適切に運用されない自動テストは壊れて放置されて使われなくなる --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## 品質とスピードはトレードオフか | |
| **「開発速度を優先するためテストは書いてない」** | |
| **「途中までは書いていたが書かなくなった」** | |
| 🤔🤔🤔 | |
| - 「コストがかかる」は真 | |
| - イテレーティブ開発において、低い内部品質は開発速度を鈍化させる → **トレードオフではない** | |
| --- | |
| <!-- { "layout": "image" } --> | |
| ## 図:内部品質とスピード | |
|  | |
| [Is High Quality Software Worth the Cost?](https://martinfowler.com/articles/is-quality-worth-cost.html), Martin Fowler, 2019 | |
| <!-- 内部品質を重視(青)ははじめ遅いが、数週間で逆転して早くなる。なお、この図は定量的なデータに基づいたものではないので注意。 --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## テスト実装コストは一定ではない | |
| - 初期コスト:**テスタビリティ**が低いコードに対してテストを書くのは高コスト | |
| - メンテナンスコスト:テスタビリティ+**テストを書くスキル**が低いと保守性の低いテストになる | |
| - テスト完全に理解したで止まっている人 | |
| - AI (LLM) が書いたテスト | |
| **→ 改善できる!** | |
| <!-- 前ページのグラフに違和感がある場合、テスト実装コストが過剰にかかりすぎているのかも。でも改善できる。教育にコストを払う --> | |
| <!-- 完全に理解した:ダニング=クルーガー効果のあれ --> | |
| --- | |
| <!-- { "layout": "section_title" } --> | |
| # 階層化テスト戦略 | |
| Layered-testing Strategy | |
| <!-- 14:00 / 16:54 --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## 階層化テスト戦略とは | |
| - テストを階層に分けて考える | |
| - 異なる観点 | |
| - 相互補完(スイスチーズモデル) | |
| - 一般的な分類 | |
| - ISTQBのテストレベル | |
| - DevOpsバグフィルター | |
| - Googleのテストサイズ(階層の意識は弱い) | |
| <!-- テストレベルは工程・担当者による階層化。テストサイズはテストの特性に基づく分類で階層意識は弱い --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## Unityにおける結合要素 | |
| - Pure C#メソッド < C#クラス < コンポーネント(MonoBehaviour) < GameObject < Prefab < Scene < エディタ内ゲームプレイ < プレイヤービルド < リリースビルド | |
| - マスターデータ | |
| - アセット | |
| - ネイティブプラグイン | |
| --- | |
| <!-- { "layout": "image" } --> | |
| ## 図:テスト自動化ピラミッド | |
|  | |
| <!-- テスト自動化ピラミッド:縦軸は上にいくほど実行に時間がかかる。横軸はテストケースの数。統合テストとユニットテストの境界は一例。実際はゲームの構造次第 --> | |
| <!-- 忠実性:リリースビルドとの差。テストダブル、開発サーバ、デバッグメニュー、最適化オプションなど --> | |
| <!-- 網羅性:低レイヤほど組み合わせが少ないので網羅的なテストが現実的に行える --> | |
| <!-- 決定性:低レイヤほどランダム要素なく毎回固定値で検証できる。高レイヤのテストはあいまいな検証になりがち --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## 相互補完:足りない「観点」を補う | |
| - ユニットテスト | |
| - 部品ごとに網羅的なテスト | |
| - 統合テスト | |
| - 各部品が機能しているか | |
| - 引数や戻り値を誤解していないか | |
| - E2Eテスト | |
| - プラットフォームOSの振る舞い | |
| - 最適化オプション | |
| <!-- 上の階層で実施すべきテストは自ずと減っていくはず --> | |
| --- | |
| <!-- { "layout": "image", "ignore": true } --> | |
| ## 動画:Unit Testing v/s Integration Testing | |
| <https://www.reddit.com/r/ProgrammerHumor/comments/isidkn/unit_testing_vs_integration_testing/> | |
| <!-- 挿入 > 動画 > Googleドライブにアップロードした動画を貼る https://drive.google.com/file/d/1urSjIsPAkDWqlVSH-ZmV_j4oy_DGP8i2/view?usp=sharing --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## ユニットと統合をどこで分けるのか | |
| 明確に線を引く必要はないが、CIでの実行を分けることはある。たとえば | |
| - PRでは実行の早いユニットテストだけ実行し、時間のかかる統合テストは定期的に実行 | |
| - 決定性の高い検証を行っているユニットテストはコードカバレッジを採取し、あいまいな統合テストでは採取しない | |
| <!-- 決定性の低いテストでコードカバレッジが水増しされて意味をなさなくなることを避ける --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## ユニットと統合をどう分けるのか | |
| 実行契機を分ける方法 3パターン | |
| - ユニットテストはEdit Modeテスト、統合はPlay Modeテスト(非推奨) | |
| - ユニットテストのカバー範囲が狭くなる、プレイヤーで実行できないなどconsが大きい | |
| - Play Modeテストアセンブリを2つに分ける | |
| - `Category` 属性を使用する | |
| --- | |
| <!-- { "layout": "section_title" } --> | |
| # ユニットテスト | |
| Unit Testing | |
| <!-- 20:00 / 17:00 --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## ユニットテストとは | |
| - コードの最小単位に対するテスト | |
| - 末端のメソッドをすべて個々にテストするというわけではなく、意味のある粒度を1つのテスト対象と扱えばOK | |
| - Unityでは Unity Test Framework パッケージを使用して記述・実行できる | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## Unity Test Framework パッケージ | |
| - NUnit 3.5 ベースのテストフレームワーク | |
| - UnityのプレイヤーループやVector2/3型を扱うためなどの拡張を含む | |
| - Unity 2019.2 からUPMパッケージ化 | |
| - 最新(最終)は v1.4.5 | |
| - Unity 6.2 からはビルトインパッケージに変更 | |
| - v1.5.x | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## Test Runnerウィンドウ | |
| **Window > General > Test Runner** | |
|  | |
| <!-- テストケースがツリー表示され、右下のボタンでテストを実行できる --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## Tips: プレイヤーでも実行できる | |
| - **Player**タブを選択すると、Play Modeテストをプレイヤー(端末)で実行できる | |
| - ネイティブプラグイン、浮動小数点の丸め誤差などの検証が(E2Eでなく)ユニットテストで可能 | |
|  | |
| <!-- Playerタブ(UI)はUTF v1.4で追加されたが、プレイヤー実行はv1.0.xからある機能。タブUIは不評。 --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## テストコードの例 | |
| ```cs | |
| public class CharacterStatusTest | |
| { | |
| [Test] | |
| public void TakeDamage_防御力2に対して攻撃力3_HPが1減少() | |
| { | |
| // Arrange: 事前条件を整える | |
| var beforeHp = 5; | |
| var sut = new CharacterStatus(hp: beforeHp, defense: 2); | |
| // Act: テスト対象メソッドを実行 | |
| sut.TakeDamage(attackPower: 3); | |
| // Assert: 事後条件を観測・検証 | |
| var actual = sut.HitPoint - beforeHp; // delta HP | |
| Assert.That(actual, Is.EqualTo(-1)); | |
| } | |
| } | |
| ``` | |
| <!-- テストクラス:テスト対象クラスと1:1で作り、名前は テスト対象クラス名 + "Test" --> | |
| <!-- テストメソッド:Test属性をつけ、名前は テスト対象メソッド名 + 条件 + 結果 --> | |
| <!-- Arrange, Act, Assert の3フェーズで構成--> | |
| <!-- sut:テスト対象、actual:実測値、expected:期待値 --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## テスト実行結果の例(失敗) | |
| ``` | |
| TakeDamage_防御力2に対して攻撃力3_HPが1減少 (0.019s) | |
| --- | |
| Expected: -1 | |
| But was: 0 | |
| --- | |
| ``` | |
| <!-- テストに失敗したときのメッセージ例。期待値-1に対し、実測値が0だった(減ってない)。制約の書きかた次第で、人間にもLLMにも役立つメッセージを出力できる --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## テストメソッドの構成要素 | |
| ### 3フェーズ(もしくは3A) | |
| 1. Arrange: 事前条件(入力)を整える | |
| 2. Act: テスト対象だけを実行する | |
| 3. Assert: 事後条件(出力)を観測・検証 | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## テストメソッドの構成要素 | |
| ### 3フェーズ(もしくは3A) | |
| 1. Arrange: 事前条件(入力)を整える | |
| 2. Act: テスト対象だけを実行する | |
| 3. Assert: 事後条件(出力)を観測・検証 | |
| → これらを**満たせない**対象をテストするのは**困難** | |
| <!-- 入力:シングルトンや依存オブジェクトを内部で生成しているなど、入力を操作できない --> | |
| <!-- 実行:Updateの中に書かれているロジックだけをテストするのは難しい。実行範囲を限定できないと、ほかのロジックの影響を受ける恐れもある --> | |
| <!-- 出力:直接画面に表示される内容を観測するのは難しい --> | |
| <!-- 困難:不可能ではないが、壊れやすいテストにつながる。たとえばリフレクションの使用 --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## テスタビリティの低いコード | |
| - 巨大なクラス、メソッド | |
| - 責務が分離されていないことが多い | |
| - 抽象でなく実装への依存 | |
| - 依存オブジェクトを内部でnewしている | |
| - 静的クラス/ メソッドへの依存 | |
| - 例:`UnityEngine.Random`, `Input` | |
| - 必要なアクセサが無い、もしくは private | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## テスタビリティを高めるには | |
| - **SOLID原則**に従う | |
| - サクイロマティック or **コグニティブ複雑度** | |
| - Riderプラグインで常に診断、低く保つ | |
| - 必要なアクセサを追加する | |
| - 安易に public にしたくない場合、`InternalsVisibleTo` 属性や `UNITY_INCLUDE_TESTS` シンボルを利用 | |
| <!-- 特別なことではなく、よい設計であればテスタビリティは高。テスタビリティの高いコードは品質がよい、バグも少ない --> | |
| --- | |
| <!-- { "layout": "image" } --> | |
| ## 画像:ルンバビリティ | |
|  | |
| <!-- テスタビリティは、よく自動掃除機が機能する部屋かに例えられます --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## 保守性の高いテストコードを書くには | |
| ゲームのテストにおいて特に有効なパターンとTipsを紹介します | |
| - クリエイションメソッド | |
| - カスタムアサーション | |
| - パラメタライズドテスト | |
| - 状態遷移テストのTips | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## クリエイションメソッド | |
| - テスト対象や前提条件となる依存オブジェクトの整備をメソッドに抽出するパターン | |
| - テストに関係する最小限の引数で初期化できるようにする | |
| - テストに関係しないフィールド/プロパティは極力 `0` や `null` にしてノイズを排除 | |
| - オプション引数もしくはビルダーパターン | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## クリエイションメソッドの実装例 | |
| ```cs | |
| private static CharacterStatus CreateCharacterStatus( | |
| Element element = Element.None, | |
| int hp = 0, | |
| int defense = 0, | |
| int attack = 0, | |
| List<Equipment> equipments = null, | |
| List<Buff> buffs = null) | |
| { | |
| return new CharacterStatus(element, hp, defense, attack, | |
| equipments ?? new List<CharacterStatus.Equipment>(), | |
| buffs ?? new List<Buff>()); | |
| } | |
| ``` | |
| <!-- 対象のコンストラクタが同様の作りになっていれば必要性は低い。それでも引数の定義が変更されたときの影響範囲を一箇所にする効果はある --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## カスタムアサーション | |
| - ゲームタイトル固有のクラス同士の比較などをメソッドに抽出してAssertするパターン | |
| - **カスタムComparer**、**カスタム制約**が便利 | |
| - 各プロパティを検証する複数のAssertを並べて書くのではなく、1つのAssertに集約できる | |
| - Assert漏れ防止 | |
| - 判定基準の統一 | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## カスタムComparerを使用する例 | |
| ```cs | |
| private CharacterStatusComparer _comparer = new CharacterStatusComparer(); | |
| [Test] | |
| public void TakeDamage_防御力2に対して攻撃力3_HPが1減少() | |
| { | |
| var actual = CreateCharacterStatus(hp: 5, defense: 2) | |
| .TakeDamage(attackPower: 3); | |
| var expected = CreateCharacterStatus(hp: 4, defense: 2); | |
| Assert.That(actual, Is.EqualTo(expected).Using(_comparer)); | |
| } | |
| ``` | |
| <!-- 通常クラスインスタンスの比較はEqualsメソッドで行われるが、Usingで指定したComparerで判定される --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## カスタムComparerの実装例 | |
| ```cs | |
| public class CharacterStatusComparer : IComparer<CharacterStatus> | |
| { | |
| public int Compare(CharacterStatus x, CharacterStatus y) | |
| { | |
| var elementComparison = x.Element.CompareTo(y.Element); | |
| if (elementComparison != 0) return elementComparison; | |
| var hpComparison = x.HitPoint.CompareTo(y.HitPoint); | |
| if (hpComparison != 0) return hpComparison; | |
| var defenseComparison = x.Defense.CompareTo(y.Defense); | |
| if (defenseComparison != 0) return defenseComparison; | |
| return x.Attack.CompareTo(y.Attack); | |
| } | |
| } | |
| ``` | |
| <!-- structや、すでに同様のEqualsオーバーライドメソッドで同じ基準で比較しているクラスであれば不要 --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## パラメタライズドテスト | |
| - 与える値だけが異なるテストは、`TestCase` 属性、`Values` 属性などによりパラメタライズドテストにできる | |
| - `TestCaseSource` 属性、`ValueSource` 属性を使えばパラメタをメソッドで動的に生成できる(アセットのバリデーションなどに便利) | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## TestCase属性の使用例 1 | |
| ```cs | |
| [TestCase(2, 3)] | |
| [TestCase(5, 7)] | |
| public void TakeDamage_防御力より攻撃力が大きい_HPが差分だけ減少( | |
| int defence, int attackPower) | |
| { | |
| var beforeHp = 5; | |
| var sut = CreateCharacterStatus(hp: beforeHp, defense: defence); | |
| sut.TakeDamage(element: Element.None, attackPower: attackPower); | |
| var actual = sut.HitPoint - beforeHp; // delta HP | |
| var expected = defence - attackPower; | |
| Assert.That(actual, Is.EqualTo(expected)); | |
| } | |
| ``` | |
| <!-- 先のテストをパラメタ化し(2, 3)、別のテストケース(5, 7)を追加したもの。2件のテストケースとして実行される --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## TestCase属性の使用例 2 | |
| ```cs | |
| [TestCase(Element.Wood, Element.Fire)] | |
| [TestCase(Element.Fire, Element.Water)] | |
| [TestCase(Element.Water, Element.Wood)] | |
| public void GetDamageMultiplier_弱点属性からの攻撃_ダメージ2倍( | |
| Element defence, Element attack) | |
| { | |
| var actual = defence.GetDamageMultiplier(attack); | |
| Assert.That(actual, Is.EqualTo(2.0f)); | |
| } | |
| ``` | |
| <!-- ありがちな3すくみのダメージ倍率を計算するメソッドのテスト例 --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## Values属性の使用例 | |
| ```cs | |
| [Test] | |
| [ParametrizedIgnore(Element.Wood, Element.Fire)] | |
| [ParametrizedIgnore(Element.Fire, Element.Water)] | |
| [ParametrizedIgnore(Element.Water, Element.Wood)] | |
| public void GetDamageMultiplier_相性なし_ダメージは等倍( | |
| [Values] Element defence, | |
| [Values] Element attack) | |
| { | |
| var actual = defence.GetDamageMultiplier(attack); | |
| Assert.That(actual, Is.EqualTo(1.0f)); | |
| } | |
| ``` | |
| <!-- Values属性には使用する値を指定できるが、enum型で指定を省略すると網羅できる --> | |
| <!-- ParametrizedIgnore属性で指定した組み合わせはスキップされる(テストケースとしては作られる)。スペルがイギリス英語なので注意 --> | |
| <!-- ダメージ半減はない仕様という前提 --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## パラメタライズドテストのTips | |
| 期待値をパラメタに含む、if 文を使うなど、さらにまとめる手段はあるが、逆に保守性を落とす | |
| → **出力(結果)の同値パーティション**単位を推奨 | |
| - 属性ダメージの場合:2倍、等倍の2メソッド | |
| - Fizz Buzzの場合:数字、Fizz、Buzz、Fizz Buzzの4メソッド(無効値を加えるなら5) | |
| <!-- 同値パーティション:テスト技法のひとつ。結果単位にすることで、テストメソッド名もつけやすい --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## 状態遷移テストのTips | |
| `A -> B -> C` といったシナリオ(1スイッチカバレッジ)を書きがち。でも網羅はできていない | |
| → **0スイッチカバレッジ**に絞る | |
| `A -> B` と `B -> C` のように、中間に状態を挟まないテストで十分。ただし | |
| * 状態遷移図(表)を描いて網羅する | |
| * 事後条件・不変条件の検証を密に行なう | |
| <!-- カスタムアサーション パターンを使う --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## サポートライブラリの紹介 | |
| Unity公式 | |
| - Code Coverageパッケージ | |
| - Performance testing API パッケージ | |
| - Graphics Test Frameworkパッケージ | |
| OSS | |
| - Test Helperパッケージ | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## Code Coverageパッケージ | |
| `com.unity.testtools.codecoverage` | |
| - ステートメントカバレッジ(C0)を採取できる | |
| - HTMLレポートやLCOV形式などをサポート | |
| - あくまで**めやす**として使う。目標値にしない | |
| - PRごとに差分を見るのがおすすめ(全体の数字を判断するのは難しい) | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## Code Coverage: HTMLレポート例 | |
|  | |
| <!-- テストで通過していない行が可視化される。エラー系はROIが低いので無理にテストしないことが多い(Googleでも80%) --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## Code Coverage: PR差分の例 | |
|  | |
| <!-- 網羅しているはずなのにカバレッジが下がっている場合、なにかを見落としていると気づける --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## Performance testing API パッケージ | |
| `com.unity.test-framework.performance` | |
| - メソッドの実行速度、GCメモリアロケーションの回数(量ではない)を計測できる | |
| - E2Eでプロファイラを使うのでなく、メソッド単位に計測できる | |
| - アサート機能はない(v3.1.0時点) | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## パフォーマンスレポートの例 | |
|  | |
|  | |
| <!-- 実行時間の計測例。初回が突出しているが、WarmupCountを設定して除外することもできる --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## Graphics Test Frameworkパッケージ | |
| `com.unity.testframework.graphics` | |
| - ビジュアルリグレッションテスト:スクショを正解画像と比較して合否判定 | |
| - ゲームでは使いどころが難しいが、シェーダーのテストに使う事例も | |
| - 『Unity Graphics Test Frameworkを使ったビジュアルリグレッションテスト』清原 隆行 7/22 第8会場 18:30-18:55 | |
| <!-- Web系ではE2Eテストで使われるが、ゲームではそれは難しく、むしろUI要素などの単位(つまりユニットテスト)で使うのがよさそう? --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## Test Helperパッケージ | |
| <https://github.com/nowsprinting/test-helper> | |
| 属性、Comparer、制約などの汎用的な拡張を詰め合わせたパッケージ。代表的なもの: | |
| - `LoadScene` 属性 | |
| - `TakeScreenshot` 属性 | |
| - `XmlComparer` | |
| - JUnit XML format report | |
| <!-- LoadScene属性:テスト用Sceneをプレイヤー実行で使用するときに一時的にビルドに含めるなどの処理をやってくれる、相対パスで指定可能 --> | |
| <!-- TakeScreenshot属性:テストに属性をつけるとスクショを撮ってくれる --> | |
| <!-- XmlComparer:文字列をXMLとしてユルく比較 --> | |
| <!-- JUnit XML format report:CI用にJUnit形式のXMLレポートを出力 --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## Test Helper: ランダム要素の検証API | |
|  | |
|  | |
| <!-- 統計サマリ、ピクセルプロットを出力できる。サマリは正規分布の例。ピクセルプロットの左はループのない一様分布、右は短いループがある。ループ周期のアサーションなども追加予定 --> | |
| --- | |
| <!-- { "layout": "section_title" } --> | |
| # 統合テスト | |
| Integration Testing | |
| <!-- 40:00 / 17:20 --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## 統合テストとは | |
| - ユニットテストで足りない「観点」を補う | |
| - 各部品が機能しているか | |
| - 引数や戻り値を誤解していないか | |
| - マスターデータ、アセットとの結合 | |
| - Unityでは Unity Test Framework パッケージを使用して記述・実行できる | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## 統合テストで扱うことになる要素 | |
| - プレイヤーループ | |
| - `UnityTest` 属性+コルーチン書式のテスト | |
| - `async` キーワードで非同期テスト | |
| - フレーム単位で `await` する機能は無いので UniTask は必要になる | |
| - Scene | |
| - **UI操作** ← 本セッションではこれにフォーカス | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## UI自動操作のステップ | |
| 1. 操作対象のUI要素(GameObject)を見つける | |
| 2. EventSystemで操作イベントを送る | |
| → **かんたんですね!!** | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## テスタビリティの低いUI(1/2) | |
| ### 操作対象のUI要素(GameObject)を見つける 編 | |
| - GameObjectの名前やヒエラルキーのパスが**ユニークでない** → 操作対象が特定できない | |
| - **表示されていないのにActive**なGameObject(人間からは見えない・操作できない) → 検索にヒットしてしまう | |
| <!-- 操作対象を特定できない:GameObject.Findを使用する場合、最初にヒットしたGameObjectが返る --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## テスタビリティの低いUI(2/2) | |
| ### EventSystemで操作イベントを送る 編 | |
| - **uGUI準拠でない**カスタムUIフレームワーク → Selectable, Interactableなどの作法が通じない、どの操作を受け取るのかわからない | |
| - 入力を受け付けないのに**常にInteractable**なUI要素 → 操作できるが、無視される | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## UIテストのアンチパターン | |
| - 表示(ロード)を**固定時間で待つ** | |
| - 十分な時間は決められない(テストが不安定になる要因が残る) | |
| - 時間を大きめに設定すると、テスト実行速度が落ちていく | |
| → **ポーリング**して待つのが定石 | |
| <!-- ゲームタイトル側に表示完了をイベントで通知する機能があるなら使用できる --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## UIテストに不利な設計 | |
| - **常にBoot sceneから**しか起動できない → 毎回テスト対象Sceneへの遷移が必要、テスト用のSceneを使用できない | |
| - ログに**例外・エラーを垂れ流し** → 自動テストが異常を検知する手段を1つ潰してしまう | |
| - PC**1台ごとに1インスタンス**しか起動できない → CIでテストを並列実行できない | |
| <!-- UIの問題ではないですがUIテストということで紹介 --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## UI Test Helperパッケージ | |
| <https://github.com/nowsprinting/test-helper.ui> | |
| - 元々モンキーテスト用のOSSライブラリ(旧称 Monkey Test Helper) | |
| - 汎用UIテスト向けAPIを増やして改名 | |
| → このライブラリにおける、低テスタビリティUI対応を紹介します | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## GameObjectFinderの使用例 | |
| ```cs | |
| [Test] | |
| [LoadScene("Title.unity")] | |
| public async Task タイトル画面でスタートボタンを押す_インゲームに遷移() | |
| { | |
| var finder = new GameObjectFinder(5.0d); // 引数はタイムアウト秒 | |
| var button = await finder.FindByPathAsync("**/Path/To/StartButton", | |
| reachable: true, // レイキャストが通る状態まで待つ | |
| interactable: true); // 操作可能な状態まで待つ | |
| var clickOperator = new UguiClickOperator(); | |
| await clickOperator.OperateAsync(button.GameObject); // クリック | |
| await finder.FindByNameAsync("Hero", reachable: true); | |
| // インゲームに遷移したと判断 | |
| } | |
| ``` | |
| <!-- GameObjectFinder: 引数にタイムアウト時間を指定。指定時間ポーリング --> | |
| <!-- FindByPathAsync: Nameのほか、globのワイルドカードが使えるPath、UI要素固有のMatcher(次ページに例)がある。 | |
| reachable: trueの場合、カメラからレイキャストしてブロックするオブジェクトがないことを確認。 | |
| interactable: trueの場合、interactable==true であることを確認。 | |
| タイムアウトまで条件を満たすGameObjectが見つからない場合、例外。条件を満たすGameObjectが複数あるときも例外。 | |
| --> | |
| <!-- UguiClickOperator: EventSystem経由でOnClickイベントを発行。カスタムUI要素向けに拡張可能 --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## GameObjectFinderのコンストラクタ | |
| ```cs | |
| public GameObjectFinder(double timeoutSeconds = 1.0d, | |
| IReachableStrategy reachableStrategy = null, | |
| Func<Component, bool> isInteractable = null) | |
| { | |
| _timeoutSeconds = timeoutSeconds; | |
| _reachableStrategy = reachableStrategy ?? | |
| new DefaultReachableStrategy(); | |
| _isInteractable = isInteractable ?? | |
| DefaultComponentInteractableStrategy.IsInteractable; | |
| } | |
| ``` | |
| <!-- uGUIに準拠していないUIフレームワークの場合、操作可否を返す関数をストラテジパターンで拡張できる --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## Buttonのtextで検索する例 | |
| ```cs | |
| [Test] | |
| [LoadScene("Title.unity")] | |
| public async Task タイトル画面でClickMeと書かれたボタンを押す() | |
| { | |
| var finder = new GameObjectFinder(5.0d); | |
| var matcher = new ButtonMatcher(text: "Click Me"); // textで検索 | |
| var button = await finder.FindByMatcherAsync(matcher, | |
| reachable: true, | |
| interactable: true); | |
| // (省略) | |
| } | |
| ``` | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## 課題は克服できたのか | |
| - ⚠️GameObjectがユニークでない | |
| - ✅️表示されていないのにActive | |
| - ✅️uGUI準拠でないカスタムUIフレームワーク | |
| - ❌️常にInteractable → 固定時間で待つしかない | |
| - ✅️表示(ロード)をポーリングで待つ | |
| - ❌️常にBoot sceneから起動 | |
| - ❌️例外・エラーを垂れ流し | |
| - ❌️PC1台ごとに1インスタンス | |
| <!-- ❌️はライブラリで解決できる問題ではない --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## 保守性の高いUIテストを書くには | |
| - GameObjectの名前やパスなど構造に強く関連付いたコードと、シナリオを分離する | |
| - 構造が変更されたときの影響を1箇所に | |
| - シナリオをシンプルに | |
| → **ページオブジェクト**パターン | |
| <!-- Web系が発祥なので「ページ」。Sceneより小さい単位の、画面、ダイアログなど --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## ページオブジェクトパターンの使用例 | |
| ```cs | |
| [Test] | |
| public async Task タイトル画面でスタートボタンを押す_インゲームに遷移() | |
| { | |
| var title = await Title.LoadSceneAsync(); // 最初はロード | |
| var ingame = await title.ClickStartAsync(); // スタートボタン | |
| var hero = await ingame.FindHeroAsync(); | |
| // インゲームに遷移したと判断 | |
| } | |
| ``` | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## ページオブジェクト側の実装例 | |
| ```cs | |
| public async Task<Ingame> ClickStartAsync() | |
| { | |
| var button = await _finder.FindByPathAsync("**/Path/To/StartButton"); | |
| await _clickOperator.OperateAsync(button.GameObject); | |
| return new Ingame(); | |
| // 遷移先のモデルのインスタンスを返すだけ。ステートを持たない | |
| } | |
| ``` | |
| <!-- LLMにUIテストを書かせるとパスを捏造するので、ページオブジェクトに隠蔽することでシナリオをLLMに書かせやすくなる --> | |
| --- | |
| <!-- { "layout": "section_title" } --> | |
| # ゲームプレイのテスト | |
| Gameplay Testing | |
| <!-- 50:00 / 17:30 --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## ゲームプレイのテストとは | |
| - 一般的なテスト用語ではなく、便宜的な言葉 | |
| - テストランナーによるテストではなく、ゲームを**自動プレイして行なうテスト**の意 | |
| - E2Eテストは必然的にこれ | |
| - Unityではエディタ内の**再生モード**で実行できる → **統合テスト**の範疇で実施できる | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## 統合テストとして実施するPros(1/2) | |
| - プレイヤー(実機・端末)でなくても検証できるテスト観点は多い → **シフトレフト** | |
| - **ビルドを待たずに実行**できる | |
| - 開発者が手元で手軽に実行できる | |
| - **CI環境**の構築コストが低い | |
| - 自動テストはCIに載せなければROIが上がらない | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## 統合テストとして実施するPros(2/2) | |
| **「このテスタビリティでも入れられる自動テストがあるんですか?」** | |
| - テスタビリティが低く、下層のテストが十分に書けないタイトルでも広く粗くカバー | |
| - 「常にBoot sceneからしか起動できない」制約は回避できる | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## ツール/ フレームワークの選択肢 | |
| - Automated QAパッケージ (2021/4〜2021/12) | |
| - テストランナーを使う統合テスト向け機能が主だが、再生モードで実行する機能も | |
| - 開発ホールド | |
| - **Anjin** (2023/3〜) | |
| <!-- このレイヤーを指す言葉がないくらいなのでツールもない --> | |
| --- | |
| <!-- { "layout": "image" } --> | |
| ## Anjinロゴ | |
|  | |
| Copyright DeNA Co., Ltd. | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## Anjin(あんじん)とは | |
| - 株式会社ディー・エヌ・エーが公開しているオートパイロットフレームワーク | |
| - <https://github.com/DeNA/Anjin> | |
| - MITライセンス | |
| - 名前の由来は、パイロット → 航海士 → 按針 | |
| <!-- DeNA在籍時に作ってオープンソース化したもの。退職後もコントリビューターとして貢献している。以降、作者目線でお送りします --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## Anjinの開発コンセプト(1/2) | |
| - 統合テスト向けフレームワーク | |
| - E2Eでも動作はするが便利ではない | |
| - テストシナリオのメンテナンスを最小限に | |
| - **モンキーテスト**の活用 | |
| - シナリオ操作は小分けにして組み合わせる → Scene単位に**Agent**を割り当て | |
| - ノーコードで設定・実行 | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## Anjinの開発コンセプト(2/2) | |
| - テスト機能は外付け | |
| - 検証を行なうAgentを**拡張**して使用 | |
| - Unity Test Frameworkのテストランナーからも実行可能 | |
| - ゲーム操作も**拡張**が前提 | |
| - ビルトイン機能だけでもuGUIで構成されたアウトゲームは動かせる | |
| <!-- 「テスト」でなく「オートパイロット」、「ツール」でなく「フレームワーク」を名乗っている所以 --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## モンキーテストの活用 | |
| - モンキーテスト:お猿さんのように**でたらめ**に操作するテスト | |
| - **メンテナンス不要** | |
| - 進行不能やアセットの欠損を検知できる(例外/ エラー/ アサートのログを検知) | |
| - `ZELDA_ERROR`, `DRAGON_ASSERT` 的なものをモンキーに踏ませる | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## 効果的なモンキーテストの工夫(1/2) | |
| - 画面座標でなくUI要素ベースで効率化 | |
| - レイキャストで操作可能なUI要素を判別 | |
| - アノテーションコンポーネント | |
| - 特定UIを無視させる | |
| - `InputField` に指定文字列を入力させる | |
| - 対象Sceneまでシナリオ操作で送り届けてからモンキーテスト | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## 効果的なモンキーテストの工夫(2/2) | |
| - 操作できるUI要素がない状態を検知 | |
| - 2D/ 3Dフィールドであれば一定時間移動していないことを検知 | |
| - 繰り返し操作の検出 | |
| - 擬似乱数シード値を指定することで同じ操作を再現可能(ゲーム側も固定できなければ再現にならないケースも) | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## モンキーテストの応用 | |
| **チュートリアルの突破** | |
| - ユーザーが操作できるUI要素が常に制限される → **モンキーテストだけで進行できる** | |
| - チュートリアル突破の判断はログメッセージなどで監視して成功判定 | |
| - 一定時間経過しても突破できなければ失敗判定 | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## シナリオテスト | |
| - Automated QAパッケージの**キャプチャ/ プレイバック**(リプレイ)機能を利用 | |
| - キャプチャ/ プレイバックはメンテナンスが破綻しやすくアンチパターンと言われるが、逆に考えるんだ「**捨てちゃってもいいさ**」と考えるんだ | |
| - 操作対象のUI要素が見つからない → 失敗と判断 | |
| <!-- UI Test Helperの機能追加で改善予定 --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## 合否判定を拡張する | |
| ゲームタイトル固有の合否判定を行なうカスタムAgentの実装例 | |
| - ヒエラルキー上のコンポーネントのプロパティを見て判定 | |
| - マルチモーダルLLMにスクリーンショットを画像解析させ、写っている内容で判定(PoC) | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## Reporterを拡張する | |
| ビルトインではSlackに通知するReporterが提供されているが、ゲームタイトル固有のカスタムReporterも実装可能 | |
| - Discordに通知するReporter | |
| - Wrikeに直接バグチケットを登録するReporter | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## Anjinの使いどころ | |
| - 頻繁にリグレッションテストされないが、壊れやすいところ | |
| - チュートリアル | |
| - マルチプレイヤー | |
| - QA向けビルドのスモークテスト(ビルドと並走させる) | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## CEDEC2025のAnjin関連セッション | |
| - 『Pokémon TCG Pocketの品質をシフトレフトで守る!自動モンキーテストによる早期バグ発見と品質向上』 DeNA 7/22 第3会場 11:10-12:10 | |
| - 『モバイルゲームで自動テストが効果を発揮するまで ~自動テストを「運用」するまでの組織のアプローチ~』 QualiArts 7/24 第5会場 11:10-12:10 | |
| --- | |
| <!-- { "layout": "section_title" } --> | |
| # まとめ | |
| <!-- 60:00 / 17:40 --> | |
| --- | |
| <!-- { "layout": "body" } --> | |
| ## まとめ | |
| - テスト自動化==E2Eという先入観を払拭して、シフトレフト! | |
| - 開発者テストはコードを保護するバフ効果! | |
| - 階層化テストで相互補完! | |
| - テスタビリティ大事! | |
| - テストも保守性が大事! | |
| - Anjinはいいぞ! |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment