Edited at

Unityでちゃんとテストを書きたい人のためのまとめ

More than 1 year has passed since last update.


前書き

前の記事 ではアプリケーションのテストコードを書くかは別として、Unity使い全員に「トライ&エラー環境としてUnity Test Runnerを使え!」というメッセージを送った。

今回の記事では「ちゃんとテストコード書きたいけどNUnitとか知らない」という人向けに、あらためてテストコードの書き方について説明する。

実際テストコードを書いて動かしてみるとUnity Test Runnerの💩な仕様がいくつか浮かび上がってくるので、その対策についても述べる。


そもそもなぜテストを書くのか

「Unityテストを完全に理解した」の動画とスライドが公開 で紹介されてるスライドを読めば


  • 実際の現場でどうやって導入していったか(Zenject交えつつ)

  • テスト区分やテストコードを書くことによって得られる利益

  • テストしやすい設計

について濃い目に紹介されてるので熟読してほしいが、簡単にメリットを上げるとすればこんな感じ↓


  • 満たすべき仕様がテストコードとして残る


    • リファクタリングしやすい

    • というかTDDしよう



  • 密結合なコードが浮き彫りになる


    • テストコードが書きづらい≒密結合



ただ、テストをちゃんと書くとプロジェクト全体の工数が倍くらいに膨れるみたいな話もあるので、プロジェクトの規模/進捗と要相談。


NUnitについて

「nunit cheatsheet」とかでググったら「Most Complete NUnit Unit Testing Framework Cheat Sheet」という英語の記事に必要な情報がほとんど載っていたのでリンクを張って終わりにしたくなったが、頑張ってサンプルコードを書いた。

せっかくなので手元のUnityで実行してみてほしい。

この記事を読んでもよくわからないことがあれば以下を熟読すること。


属性いろいろ

NUnitには属性がたくさん用意されており、メソッドや引数に指定することで柔軟かつ大量にテストを記述できる。

テスト前後のコールバックを指定


  • [OneTimeSetUp]

  • [OneTimeTearDown]

  • [SetUp]

  • [TearDown]

テストメソッドとパラメータの指定


  • [Test]

  • [TestCase]

  • [TestCaseSource]

  • [Range]

  • [Values]

  • [Random]

その他メタデータ付与


  • [Category]

  • [Timeout]

  • [Desctiption]

  • [Ignore]

  • [Order]

サンプルコードと合わせて使い方を順番にみていこう。


テスト前後のコールバックを指定

コールバックの挙動がわかればいいのでMyClassの処理内容自体に特に意味はない。


Assets/Tests/Editor/MyClassTest.cs

using System;

using NUnit.Framework;
using UnityEngine;

public class MyClassTest
{
private MyClass _myClass;

// クラス内の最初のテストが実行される前に一度だけ実行される
[OneTimeSetUp]
public void OneTimeSetUp()
{
Debug.LogWarning("OnetimeSetUp");
MyClass.StaticInitialize();
}

// 各テスト実行前にそれぞれ実行される
[SetUp]
public void SetUp()
{
Debug.Log("SetUp");
_myClass = new MyClass();
}

[Test]
public void AdditionTest()
{
Assert.AreEqual(3, _myClass.Addition(1, 2));
}

[Test]
public void MultiplicationTest()
{
Assert.AreEqual(6, _myClass.Multiplication(2, 3));
}

// 各テスト実行後にそれぞれ実行される
[TearDown]
public void TearDown()
{
Debug.Log("TearDown");
_myClass.Dispose();
}

// クラス内の最後のテストが実行された後に一度だけ実行される
[OneTimeTearDown]
public void OneTimeTearDown()
{
Debug.LogWarning("OnetimeTearDown");
MyClass.StaticReset();
}
}

public class MyClass : IDisposable
{
public static void StaticInitialize(){}
public static void StaticReset(){}

public int Addition(int a, int b) => a + b;
public int Multiplication(int a, int b) => a * b;
public void Dispose(){}
}


なお、いずれのコールバックもasync/awaitは使えないうえに同期的に処理が行われるので非同期処理を待つことができない。EditMode/PlayModeとかは関係なく待てない。

つまり、テストの前処理と後処理で非同期処理を待つ必要がある場合、PlayModeテストで[UnityTest]を使い、[SetUp]などは使わずにテストメソッドすべてにいちいち前後処理を書く必要がある。辛い。


テストメソッドとパラメータの指定

テストメソッドの指定には[Test]以外にも[TestCase][TestCaseSource]などが使え、うまく使えばテストケースを楽に量産できる。


Assets/Tests/EditMode/Editor/MathfTest.cs

using System.Collections.Generic;

using NUnit.Framework;
using UnityEngine;
using Assert = UnityEngine.Assertions.Assert;

public class MathfTest
{
// 単純なテスト
[Test]
public void PITest()
{
// 値がほぼ同じかどうか
Assert.AreApproximatelyEqual(Mathf.PI, 3.141593f);
}

// テストメソッドに引数を渡してテストケースを量産
[TestCase(1, -1)]
[TestCase(2, -2)]
[TestCase(3, -3)]
public void AbsTest(int expected, int input)
{
Assert.AreEqual(expected, Mathf.Abs(input));
}

// 引数はたくさん渡せる
[TestCase(9, 1, 2, 3, 4, 5, 6, 7, 8, 9)]
public void MaxTest(int expected, int a, int b, int c, int d, int e, int f, int g, int h, int i)
{
Assert.AreEqual(expected, Mathf.Max(a, b, c, d, e, f, g, h, i));
}

// 戻り値があるテスト(Assertを使わず、doubleを返している)
[TestCase(123.456f, ExpectedResult = 123)]
public double FloorTest(float input)
{
return Mathf.Floor(input);
}

// -5,-3,-1,1,3,5でテスト
[Test]
public void ClampTest([NUnit.Framework.Range(-5, 5, 2)] float value)
{
Assert.IsTrue(0 <= Mathf.Clamp(value, 0, 3));
Assert.IsTrue(Mathf.Clamp(value, 0, 3) <= 10);
}

// -2~2のランダムな値で10回テスト
[Test]
public void Clamp01Test([Random(-2, 2f, 10)] float value)
{
Assert.IsTrue(0 <= Mathf.Clamp01(value));
Assert.IsTrue(Mathf.Clamp01(value) <= 1);
}

// あらかじめテストケースを定義しておく
private static IEnumerable<TestCaseData> SqrtTestCases
{
get
{
yield return new TestCaseData(1).Returns(1);
yield return new TestCaseData(4).Returns(2);
yield return new TestCaseData(9).Returns(3);
yield return new TestCaseData(-1).Returns(double.NaN);
}
}

// 定義しておいたテストケースを使う(引数/戻り値が定数でない場合は[TestCase]が使えない)
[TestCaseSource(nameof(SqrtTestCases))]
public double SqrtTest(float input)
{
return Mathf.Sqrt(input);
}

// テストケース生成用クラス
public static class TestCaseFactory
{
public static IEnumerable<TestCaseData> MinTestCases
{
get { yield return new TestCaseData(1, 2, 3, 4, 5).Returns(1); }
}
}

// 別のクラスからテストケースを取得して使う
[TestCaseSource(typeof(TestCaseFactory), nameof(TestCaseFactory.MinTestCases))]
public int MinTest(int a, int b, int c, int d, int e)
{
return Mathf.Min(a, b, c, d, e);
}
}



その他メタデータ付与

以下もすべてメソッドに対して指定する属性。


[Category(string)]

カテゴリ名を指定すると、Unity Test RunnerのUI上で「指定したカテゴリのテストだけをすべて実行」みたいなことができる。カテゴリ名は好きにつけることができ、「正常系」「異常系」「通信」「IO」などでカテゴリ分けするといいかもしれない。

ただ、カテゴリで分けたとしてもUnity Test Runnerのツリーの表示はあくまで

 (プロジェクト名)▶(dll名)▶(namespace)▶(class)▶(method)

のまま。


[Timeout(int)]

テストのタイムアウトまでの時間を指定する(単位はミリ秒)。デフォルトだと30秒で、そのままだとPlayModeテストがタイムアウトしまくったりする。


[Description(string)]

テストに対して説明文を書ける。テスト項目選択時にUnity Test Runnerの下のほうに表示される。


[Ignore(string)]

テストが無視され実行されなくなる(pendingにする)。引数には「未実装」などpendingになっている理由を渡す。


[Order(int)]

まとめて実行する際の実行順を指定できるが、Unity Test Runner内の表示順はアルファベット順のままなので紛らわしい。どうにかならんのかしら。

 

他にも [Pairwise], [Sequential], [Combinatorial] などがあり、引数に直接指定する属性[Values],[Range]と合わせて複数の引数の組み合わせ方を指定できる。

全ては紹介しきれていないが他にもあるので、興味があれば 公式ドキュメントの属性一覧(英語)を参照。


ディレクトリ構成/namespace/クラス名/メソッド名

EditModeテストを/Editor以下に置く以外は特に決まりや制限はないが、個人的には以下のようにしている。


  • ディレクトリ:


    • Assets/Tests/EditMode/Editor/(EditModeテスト).cs

    • Assets/Tests/PlayMode/(PlayModeテスト).cs



  • テストするクラスとされるクラスはファイルを分ける


  • namespace(プロジェクト名).Tests

  • クラス名:(テスト対象クラス名)Test

  • テスト対象クラスのpublicメソッドごとにテストメソッドを定義

  • メソッド名:(テスト対象メソッド名)Test

広く知られた指針とかがあればぜひ教えてください。


UnityEngine.Assertions.AssertNUnit.Framework.Assert

前回の記事では「基本的にUnityのものを使う」と書いたが、少なくともエディター上ではNUnitのものを使っても問題ないようだ(ビルドしたプレイヤー上の実行は未確認)。

NUnitのAssertのほうが圧倒的にメソッドが多く柔軟にテストを書けるので、問題なさそうであればNUnitのものを使ったほうがいいかもしれない。→ NUnitのAssertのチートシート

Assertのメソッドはたくさんあるが、Assert.AreEqual()だけ覚えればテストは書けるのでたくさん覚える必要はあまりない。


UnityEngine.TestTools.LogAssert

LogAssert を使うと「Debug.Log("ほげ")の実行を期待する」みたいなテストが書ける(もちろんLogWarning(),LogError()も)。

いちいち例外を吐かずにDebug.LogWarning()を呼ぶようにしている実装の場合も、これで対象のコード行を通過したかどうかテストできる。


Assets/Tests/EditMode/Editor/DebugLogTest.cs

using NUnit.Framework;

using UnityEngine;
using UnityEngine.TestTools;

public class DebugLogTest
{
[Test]
public void LogAssertTest()
{
LogAssert.Expect(LogType.Log, "ログ");
Debug.Log("ログ");

LogAssert.Expect(LogType.Warning, "ワーニング");
// Debug.LogWarning("ワーニング"); 呼ばれなければテスト失敗となる
}
}



[UnityTest]について

あとで書く


Miyamasu

実機上でテストコードを実行してくれるツール。謎の技術が使われていて、よくわからないがなんかすごい(小並感)。

ちゃんと触れてないのでよくわかってない😅

MiyamasuTestRunnerを継承することで[MSetup][MTeardown]が使えるようになり、テスト前後のコールバックで非同期処理が待てる。

using System;

using System.Collections;
using UnityEngine;
using Miyamasu;

public class SuccessSample : MiyamasuTestRunner {
// MSetup is annotation for setup.
// method should return void or IEnumerator.
[MSetup] public void Setup () {
Debug.Log("setup!");
}

// MTeardown is annotation for setup.
// method should return void or IEnumerator.
[MTeardown] public void Teardown () {
Debug.Log("Teardown!");
}

// MTest is annotation for test case.
// method should return IEnumerator.
[MTest] public IEnumerator Same () {
AreEqual("a", "a");
yield return null;
}

[MTest] public IEnumerator DoneInTime () {
var obj = new GameObject("runner");
IsNotNull(obj);

var runner = obj.AddComponent<Runner>();

// WaitUntil method can wait that some condition is achieved.
// 1st func<bool> is the condition of this waiting. when returns true, finish waiting.
// 2nd action is for throw timeout exception. you can set original message for fail by timeout.
// 3rd double parameter is time limit in sec. default is 5sec.
yield return WaitUntil(
() => runner.n == 10,// enough small.
() => {throw new TimeoutException("not yet. runner.n:" + runner.n);},
1.0//sec
);
}
}

ただ、MiyamasuTestRunnerを継承したクラスがあれば対応するテストコードを自動生成、ということをしているので通常の[OneTimeSetUp]などは使えなくなる。

また、[MOneTimeSetUp][MOneTimeTearDown]は実装されていない。ちょっと残念。


まとめ

あとで書く


参考