今回はZenjectを使って、ゲームで使いそうなケースのテストを作ってみます。
生成&初期化テスト
テストを利用するクラス
何か戦うゲームのユニットを想定して作ってみます。
HPと攻撃力のあるシンプルな作りです。
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Zenject;
/// <summary>
/// 戦闘ユニット
/// </summary>
public class BattleUnit : MonoBehaviour {
public struct Params {
public int Health;
public int Attack;
}
[InjectOptional]
public Params selfParam = new Params () { Health = 10, Attack = 5 };
/// <summary>
/// 体力
/// </summary>
public int HP { get { return selfParam.Health; } }
/// <summary>
/// 攻撃力
/// </summary>
public int ATK { get { return selfParam.Attack; } }
}
文中にある[InjectOptional]というのはこのパラメータをZenjectから注入する可能性があることを示します。
使い方は後述します。
[InjectOptional]
public Params selfParam = new Params () { Health = 10, Attack = 5 };
ちなみに初期値をHP=10, ATK=5で指定しています。乱暴な書き方ですがあとでこれが関わってきます。
生成テスト
上述のクラスのテストがうまく使えるかのテストをします。
using System.Collections;
using System.Collections.Generic;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using Zenject;
[TestFixture]
public class TestBattleUnit : ZenjectIntegrationTestFixture {
public const int PlayerID = 1;
public const int PlayerHealth = 20;
public const int PlayerAttack = 5;
public const int EnemyID = 2;
public const int EnemyHealth = 8;
public const int EnemyAttack = 3;
[SetUp]
public void CommonInstall () {
PreInstall ();
//PlayerはID1で作る
Container.Bind<BattleUnit> ().WithId (PlayerID)
.FromNewComponentOnNewGameObject ().AsTransient ()
.WithArguments (new BattleUnit.Params () { Health = PlayerHealth, Attack = PlayerAttack });
//敵はID2で作る
Container.Bind<BattleUnit> ().WithId (EnemyID)
.FromNewComponentOnNewGameObject ().AsTransient ()
.WithArguments (new BattleUnit.Params () { Health = EnemyHealth, Attack = EnemyAttack });
PostInstall ();
}
[Test]
/// <summary>
/// バインディングのテスト
/// </summary>
public void TestCreate () {
var player = Container.ResolveId<BattleUnit> (PlayerID);
Assert.IsNotNull (player);
var enemy = Container.ResolveId<BattleUnit> (EnemyID);
Assert.IsNotNull (enemy);
player.selfParam.Health = 0;
enemy.selfParam.Health = 0;
}
/// <summary>
/// 初期値が入っているかのテスト
/// </summary>
[Test]
public void TestInitialParams () {
var player = Container.ResolveId<BattleUnit> (PlayerID);
var enemy = Container.ResolveId<BattleUnit> (EnemyID);
Assert.AreEqual (PlayerHealth, player.HP);
Assert.AreEqual (PlayerAttack, player.ATK);
Assert.AreEqual (EnemyHealth, enemy.HP);
Assert.AreEqual (EnemyAttack, enemy.ATK);
}
}
ZenjectIntegrationTestFixtureを継承したクラスにガシガシ書いていきます。このテストではプレイヤーキャラと敵キャラ、2体のユニットを使ってテストするため、WithId()とAsTransient()で作ります。それだけなら
Container.Bind<BattleUnit> ()
.WithId (1)
.FromNewComponentOnNewGameObject ()
.AsTransient ()
だけで十分です。それに加えて今回はプレイヤーと敵でHPなどの設定を変えたいため、それぞれの設定を.WithArguments()で突っ込みます。
WithIdで突っ込んだのが各ResolveIdで取れるかテストします
var player = Container.ResolveId<BattleUnit> (PlayerID);
Assert.IsNotNull (player);
初期値テスト
もう一つのテストでは初期値を上書きするように
.WithArguments (new BattleUnit.Params () { Health = EnemyHealth, Attack = EnemyAttack });
で注入した値が入っているかを確認します。
var enemy = Container.ResolveId<BattleUnit> (EnemyID);
Assert.AreEqual (EnemyHealth, enemy.HP);
Assert.AreEqual (EnemyAttack, enemy.ATK);
クラス側で指定した初期値はHP=10,ATK=5です。その値ではなくInstallでWithArguments()で注入した値が入っていることが確認できます。
ちょっとゲームっぽいテスト
ダメージ処理のテスト
/// <summary>
/// 与ダメ、被ダメのテスト
/// </summary>
[Test]
public void TestDamage () {
var player = Container.ResolveId<BattleUnit> (PlayerID);
var enemy = Container.ResolveId<BattleUnit> (EnemyID);
//最初に異常値のテスト
Assert.Throws<System.ArgumentException> (() => {
player.Damage (-1);
});
//正常にダメージが入っているかのテスト
enemy.Damage (player.ATK);
Assert.AreEqual (enemy.HP, EnemyHealth - PlayerAttack);
}
基本はダメージを受けるとその分HPが減る、それだけのテストです。
ただよくやってしまいがちな負の値を突っ込まれて回復してしまうパターンを例外としてチェックできるようにします。
0は微妙なところですが0はミスとして別の処理を通すようにすることをプランナーに確認しておきましょう。
ダメージ処理の実装
テストで定義できたので実装として以下のように実装します。
/// <summary>
/// 被ダメージ処理
/// </summary>
/// <param name="attack">受けるダメージ量,1以下の値を与えた場合Exception</param>
public void Damage (int attack) {
if (attack < 1) {
throw new ArgumentException ();
}
selfParam.Health = Mathf.Max (0, selfParam.Health - attack);
}
なんでattack<1で確認してるのにMathf.Maxを書いてしまうのか謎ですが、きっと上のifブロックを#if DEVELOPで括るように変更した場合の予防策として書いているんでしょう。
死亡処理のテスト
死亡した場合も書いておきます。テストを先に書くことで、オーバーキルとか死んだユニットのHP増えたらどうするの?みたいなことが先に意識できます。
/// <summary>
/// 死亡のテスト
/// </summary>
[Test]
public void TestDead () {
var player = Container.ResolveId<BattleUnit> (PlayerID);
var enemy = Container.ResolveId<BattleUnit> (EnemyID);
//HP相当のダメージを与えるとしぬテスト
player.Damage (PlayerHealth);
Assert.IsTrue (player.IsDead ());
//オーバーキルした際のHPのテスト
player.Damage (enemy.ATK);
Assert.AreEqual (0, player.HP);
//死亡後にHPを増やした際のテスト
player.selfParam.Health = 1;
//本来死亡したままが望ましそうだが現仕様では復活してしまっていいことにする
Assert.IsFalse (player.IsDead ());
}
死亡処理の実装
復活処理とかのフラグがいらないなら単にこれだけで良さそうです。
/// <summary>
/// 死亡しているか
/// </summary>
/// <returns>true:死亡している</returns>
public bool IsDead () { return selfParam.Health < 1; }
めんどくさそうな処理のテスト
プランナーから状態異常:毒を作ってくれと言われたので作ります。
BattleUnit.csにif(毒)みたいなのを書くのは嫌なのでStateとして作ることにします。
さらにBattleUnitからUpdateを読んでやるのを作るのが面倒なので、Behaviorで作ってBattleUnitにAddComponentしてやることにします。
後からUpdateをBattleUnitから呼ぶだけにするのは簡単な変更ですし。
状態異常:毒のテスト
/// <summary>
/// 中毒ユニットが時間経過で死ぬかテスト
/// </summary>
/// <returns></returns>
[UnityTest]
public IEnumerator TestPoisoned () {
//var player = Container.ResolveId<BattleUnit> (PlayerID);
var player = new GameObject ().AddComponent<BattleUnit> ();
//生存確認
Assert.IsNotNull (player);
// //毒にする
var stat = player.gameObject.AddComponent<StatusPoison> ();
Assert.IsNotNull (stat);
こんな感じですかね。
Zenjectから借りたObjectにAddComponentしています。
これ大丈夫なの?ってのは後で説明します。
var rate = 0.2f;
stat.SetPoison (rate);
//毒の強さを確認する
var p = StatusPoison.CalcPoison (player.HP, rate);
Assert.Greater (p, 1);
yield return null;
//何ターンで死ぬか確認する
var deadlyCount = Mathf.FloorToInt ((float) player.HP / (float) p);
for (int i = 0; i < deadlyCount - 1; ++i) {
//毒が回るのを待つ
yield return null;
}
//このフレームでは生きてる
Assert.IsFalse (player.IsDead ());
yield return null;
//このフレームでは力尽きてる
Assert.IsTrue (player.IsDead ());
yield return null;
}
毒の強さ計算式は外に出して単体でテストできた方が良さそうなのでstaticな奴が良さそうだと見通しを立てておきます。
本当は強さを確認するだけのテストを別に作るのが良いですがここにまとめて書いてしまっています。
PlayModeで実行できる[UnityTest]なテストなのでyield return nullで1フレームごとに処理を確認できます。これかなり便利です。
ぶっちゃけ実装はどうでもいいので後回しにするとして飛ばします。
本当に単体テストなの?
最後にすごく気になって思わず書いたのを書いておきます。
/// <summary>
/// 本当に単体テストなのかのテスト
/// これに通らないということは他のテストの内容によって汚染されている可能性がある
/// </summary>
[Test]
public void TestZZZ () {
var player = Container.ResolveId<BattleUnit> (PlayerID);
var enemy = Container.ResolveId<BattleUnit> (EnemyID);
Assert.AreEqual (PlayerHealth, player.HP);
Assert.AreEqual (EnemyHealth, enemy.HP);
var go = player.gameObject;
Assert.IsNotNull (go);
var poison = go.GetComponent<StatusPoison> ();
Assert.IsNull (poison);
}
大丈夫です。他のテストと連続して書いてもこいつは通ります。Zenjectから借りたものを弄るっても大丈夫です。
まとめ
単体テストのさいにいちいちnewGameObjectしてAddComponentしたり、それ用のPrefabを作ってCloneしたりってすごく面倒なんですが、そこをZenjectが肩代わりしてくれることでテストがかなり書きやすかったと感じます。
どうでもいいクラス全文記載
テスト
using System.Collections;
using System.Collections.Generic;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using Zenject;
[TestFixture]
public class TestBattleUnit : ZenjectIntegrationTestFixture {
public const int PlayerID = 1;
public const int PlayerHealth = 20;
public const int PlayerAttack = 5;
public const int EnemyID = 2;
public const int EnemyHealth = 8;
public const int EnemyAttack = 3;
[SetUp]
public void CommonInstall () {
PreInstall ();
//PlayerはID1で作る
Container.Bind<BattleUnit> ().WithId (PlayerID)
.FromNewComponentOnNewGameObject ().AsTransient ()
.WithArguments (new BattleUnit.Params () { Health = PlayerHealth, Attack = PlayerAttack });
//敵はID2で作る
Container.Bind<BattleUnit> ().WithId (EnemyID)
.FromNewComponentOnNewGameObject ().AsTransient ()
.WithArguments (new BattleUnit.Params () { Health = EnemyHealth, Attack = EnemyAttack });
PostInstall ();
}
[Test]
/// <summary>
/// バインディングのテスト
/// </summary>
public void TestCreate () {
var player = Container.ResolveId<BattleUnit> (PlayerID);
Assert.IsNotNull (player);
var enemy = Container.ResolveId<BattleUnit> (EnemyID);
Assert.IsNotNull (enemy);
player.selfParam.Health = 0;
enemy.selfParam.Health = 0;
}
/// <summary>
/// 初期値が入っているかのテスト
/// </summary>
[Test]
public void TestInitialParams () {
var player = Container.ResolveId<BattleUnit> (PlayerID);
var enemy = Container.ResolveId<BattleUnit> (EnemyID);
Assert.AreEqual (PlayerHealth, player.HP);
Assert.AreEqual (PlayerAttack, player.ATK);
Assert.AreEqual (EnemyHealth, enemy.HP);
Assert.AreEqual (EnemyAttack, enemy.ATK);
}
/// <summary>
/// 与ダメ、被ダメのテスト
/// </summary>
[Test]
public void TestDamage () {
var player = Container.ResolveId<BattleUnit> (PlayerID);
var enemy = Container.ResolveId<BattleUnit> (EnemyID);
//最初に異常値のテスト
Assert.Throws<System.ArgumentException> (() => {
player.Damage (-1);
});
//正常にダメージが入っているかのテスト
enemy.Damage (player.ATK);
player.Damage (EnemyAttack);
Assert.AreEqual (enemy.HP, EnemyHealth - PlayerAttack);
Assert.AreEqual (player.HP, PlayerHealth - EnemyAttack);
}
/// <summary>
/// 死亡のテスト
/// </summary>
[Test]
public void TestDead () {
var player = Container.ResolveId<BattleUnit> (PlayerID);
var enemy = Container.ResolveId<BattleUnit> (EnemyID);
//HP相当のダメージを与えるとしぬテスト
player.Damage (PlayerHealth);
Assert.IsTrue (player.IsDead ());
//オーバーキルした際のHPのテスト
player.Damage (enemy.ATK);
Assert.AreEqual (0, player.HP);
//死亡後にHPを増やした際のテスト
player.selfParam.Health = 1;
//本来死亡したままが望ましそうだが現仕様では復活してしまっていいことにする
Assert.IsFalse (player.IsDead ());
}
/// <summary>
/// 中毒ユニットが時間経過で死ぬかテスト
/// </summary>
/// <returns></returns>
[UnityTest]
public IEnumerator TestPoisoned () {
//var player = Container.ResolveId<BattleUnit> (PlayerID);
var player = new GameObject ().AddComponent<BattleUnit> ();
//生存確認
Assert.IsNotNull (player);
// //毒にする
var stat = player.gameObject.AddComponent<StatusPoison> ();
Assert.IsNotNull (stat);
var rate = 0.2f;
stat.SetPoison (rate);
//毒の強さを確認する
var p = StatusPoison.CalcPoison (player.HP, rate);
Assert.Greater (p, 1);
yield return null;
//何ターンで死ぬか確認する
var deadlyCount = Mathf.FloorToInt ((float) player.HP / (float) p);
for (int i = 0; i < deadlyCount - 1; ++i) {
//毒が回るのを待つ
yield return null;
}
//このフレームでは生きてる
Assert.IsFalse (player.IsDead ());
yield return null;
//このフレームでは力尽きてる
Assert.IsTrue (player.IsDead ());
yield return null;
}
/// <summary>
/// 本当に単体テストなのかのテスト
/// これに通らないということは他のテストの内容によって汚染されている可能性がある
/// </summary>
[Test]
public void TestZZZ () {
var player = Container.ResolveId<BattleUnit> (PlayerID);
var enemy = Container.ResolveId<BattleUnit> (EnemyID);
Assert.AreEqual (PlayerHealth, player.HP);
Assert.AreEqual (EnemyHealth, enemy.HP);
var go = player.gameObject;
Assert.IsNotNull (go);
var poison = go.GetComponent<StatusPoison> ();
Assert.IsNull (poison);
}
}
戦闘ユニット
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Zenject;
/// <summary>
/// 戦闘ユニット
/// </summary>
public class BattleUnit : MonoBehaviour {
public struct Params {
public int Health;
public int Attack;
}
[InjectOptional]
public Params selfParam = new Params () { Health = 10, Attack = 5 };
/// <summary>
/// 体力
/// </summary>
public int HP { get { return selfParam.Health; } }
/// <summary>
/// 攻撃力
/// </summary>
public int ATK { get { return selfParam.Attack; } }
/// <summary>
/// 被ダメージ処理
/// </summary>
/// <param name="attack">受けるダメージ量,1以下の値を与えた場合Exception</param>
public void Damage (int attack) {
if (attack < 1) {
throw new ArgumentException ();
}
selfParam.Health = Mathf.Max (0, selfParam.Health - attack);
}
/// <summary>
/// 死亡しているか
/// </summary>
/// <returns>true:死亡している</returns>
public bool IsDead () { return selfParam.Health < 1; }
}
状態異常:毒
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Zenject;
/// <summary>
/// BattleUnitの毒状態
/// </summary>
public class StatusPoison : MonoBehaviour {
int poison;
public int Poison { get { return poison; } }
BattleUnit target = null;
bool isValid = false;
const int MinimumPoison = 1;
void Awake () {
target = GetComponent<BattleUnit> ();
if (null != target) {
isValid = true;
}
SetPoison (0.0f);
}
/// <summary>
/// 毒の強さをセットする
/// </summary>
/// <param name="rate">HPに対する毒の割合</param>
public void SetPoison (float rate) {
if (!isValid) {
return;
}
poison = CalcPoison (target.HP, rate, MinimumPoison);
}
/// <summary>
/// 毒の強さを計算する式
/// </summary>
/// <param name="hp">対象の体力</param>
/// <param name="rate">毒の割合</param>
/// <param name="minimum">最低保証値。省略時はクラスデフォルト値</param>
/// <returns>1回あたりの毒の量</returns>
public static int CalcPoison (int hp, float rate, int minimum = MinimumPoison) {
var hs = (float) Mathf.Max (0, hp);
var r = Mathf.Max (0, rate);
return Mathf.Max (minimum, Mathf.CeilToInt (hs * r));
}
/// <summary>
///毒を与える毎時処理
/// </summary>
void Update () {
if (!isValid) {
return;
}
target.Damage (poison);
}
}