テスト
Behaviour のロジックを、Unity を起動せずに実行して確認できます。アップロードも Play モードも必要ありません。
既定でテストを実行するのは、Udon のセマンティクスを再現したインタプリタです。通常の C# としてコンパイルし直したものではないので、整数があふれたときの値のように C# と Udon で結果が変わる部分も、Udon 側の値になります。ただし実行環境の API はモックが答えるため、そこは実機と同じではありません。
MCP サーバからは、素の C# として実行する道も選べます(素の C# としての実行)。
確認したい Behaviour を用意します。Bump を呼ぶと value が 1 増えるだけのものです。
using Tsukimi;
public partial class TestingCounter : TsukimiBehaviour{ public int value;
public void Bump() { value = value + 1; }
public void Reset() { value = 0; }}テストのファイル
Section titled “テストのファイル”テストは別のファイルに書きます。元のクラスとテスト側の両方に partial を付け、テスト側のファイル名を 名前.Tests.cs にします。この名前のファイルはコンパイルの対象から外れるので、テストが配布物に混ざることも、ワールドで動作することもありません。
using Tsukimi;
public partial class TestingCounter{ [TsukimiTest] public void 増やすと1つ増える() { Bump(); Assert.AreEqual(1, value); }
[TsukimiTest] public void 二回増やすと2になる() { Bump(); Bump(); Assert.AreEqual(2, value); Assert.IsTrue(value > 0, "増えていない"); }}private のフィールドやメソッドにもそのまま触れます。同じクラスの中なので、テストのために public へ開ける必要はありません。
テストを本体側に書いた場合
Section titled “テストを本体側に書いた場合”| 何が起きるか | Assert か Mimic を呼ぶテストは、その呼び出しがアップロードされるプログラムに残るので、Unity がビルドを止めます(test では実行されます)。どちらも呼ばないテストは動作しますが、そのメソッドはワールドにアップロードされるプログラムに含まれ、エントリポイントとして公開されます |
| エラーと警告 | inspect が TUKI0117 を出します。Assert か Mimic を呼ぶテストはエラー(compiles: false)、どちらも呼ばないテストは警告です(エラー) |
テストメソッドの宣言
Section titled “テストメソッドの宣言”[TsukimiTest] を付けた、引数の無い public void のメソッドが 1 本のテストになります(seeds を書いたメソッドだけは int の引数を 1 つ取ります。種ごとのケース)。属性の付いていないメソッドは実行されないので、テストから呼ぶだけの補助メソッドを同じファイルに置けます。
メソッド名の付け方に決まりはありません。上の例のように日本語で書いても構いませんが、C# の識別子なので数字から始めることはできません。
Assert
Section titled “Assert”確認は Tsukimi.Assert の静的メソッドで書きます。いま使えるのは 6 つです。
Assert.IsTrue / Assert.IsFalse
Section titled “Assert.IsTrue / Assert.IsFalse”bool を 1 つ受け取り、真か偽かを確認します。条件そのものを書けるので、比較や範囲の確認はここに集まります。
[TsukimiTest]public void 上限で止まる(){ charge = 120; Clamp();
Assert.IsTrue(charge <= 100); Assert.IsFalse(charge < 0);}結果に出るのは真偽の値だけです。何と何を比べたかは残らないので、値の一致を見たいときは Assert.AreEqual のほうが読みやすい結果になります。
Assert.AreEqual / Assert.AreNotEqual
Section titled “Assert.AreEqual / Assert.AreNotEqual”2 つの値を比べます。第 1 引数が期待した値、第 2 引数が実際の値です。この順序が結果の文面に出るので、逆にすると原因を追うときに読み違えます。
[TsukimiTest]public void 一度触ると1つ増える(){ Interact();
Assert.AreEqual(1, count); Assert.AreNotEqual(0, count);}引数の型は object なので、数値でも文字列でも参照でも渡せます。float を直接渡すと、計算のたびに出る誤差までそのまま比べることになるため、実数の比較には向きません。
Assert.IsNull / Assert.IsNotNull
Section titled “Assert.IsNull / Assert.IsNotNull”参照が null かどうかを確認します。Udon には例外がなく、null 参照にアクセスすると原因を記録せずにイベント全体が停止します。その前に検出するための Assert です。
[TsukimiTest]public void 初期化前は空のまま(){ Assert.IsNull(current);
Setup();
Assert.IsNotNull(current);}Unity のオブジェクトを渡すときは注意が必要です。破棄済みのオブジェクトは C# の null とは違う状態を取りうるので、この 2 つの結果が直感と食い違うことがあります。
メッセージつきのオーバーロード
Section titled “メッセージつきのオーバーロード”どの Assert にも、末尾に string message を追加したオーバーロードがあります。成り立たなかったとき、その文字列がそのまま結果に出ます。
Assert.AreEqual(80, charge, "1 回触ると 20 減るはず");Assert.IsTrue(charge > 0, "使い切っている");同じ型の値を何度も比べるときや、条件式が長いときのように、値だけでは何を確認していたのか分からない場面で有効です。
使える場所の制限
Section titled “使える場所の制限”Assert は [TsukimiTest] を付けたメソッドの中でしか書けません。外で使うとコンパイル時のエラーになります。
public void Interact(){ // ここでは書けない(TUKI0114)。 Assert.IsTrue(count >= 0);}Assert の呼び出しは、実行環境に存在しない専用の命令へ変換されます。ワールドにアップロードするプログラムに残ると、到達した時点で停止します。この制限は、そうなる前にコンパイル時に捕まえるためのものです。
カーネルの計算の確認
Section titled “カーネルの計算の確認”[Kernel] を付けたメソッドは GPU で実行されるため、テストから呼べません。Color4 や KernelId を引数や戻り値に含むメソッドも、テストからは呼べません。
確認したい計算は、float と int と bool と Vector2 だけを受け取る static のメソッドへ切り出します。カーネルとテストの両方から同じメソッドを呼べば、GPU で実行される計算そのものを Unity なしで確認できます。
public static float NextHeight(float now, float before, float around, float damping){ return (now * 2f - before + (around * 0.25f - now) * 0.9f) * damping;}切り出したメソッドがカーネルからしか呼ばれないあいだは、命令数は増えません。Behaviour 側からも呼ぶと、そのメソッドも Udon へ変換されるため命令数が増えます。
Tsukimi.Mimic を使うと、1 つのテストの中に複数のプレイヤーを置いて、同期の流れを確認できます。所有権の移動と送信は普段と同じ書き方のままで、Networking.SetOwner も RequestSerialization もそのまま書きます。Mimic が追加するのは、1 人で実行する限り書きようがない 7 つです。
| 書き方 | 説明 |
|---|---|
Mimic.Join() | プレイヤーが 1 人参加します。最初の 1 人が自分(マスター)になり、そのプレイヤーが所有者になります。後から参加したプレイヤーは、テストの本文に入った時点のフィールドの値(初期化子と接続した Behaviour・配列は自分の複製)から始まり、その後に所有者の今の値を受け取ります。実行環境と同じく、すでに居る全員で新しいプレイヤーについて、新しいプレイヤーで全員(自分を含む)について OnPlayerJoined を実行します(この Behaviour、次に同じ実行の他の Behaviour)。ここでは参加した順に実行し、新しいプレイヤーの OnPlayerJoined は所有者の値が届く前です |
Mimic.Leave(player) | そのプレイヤーが退出します。視点を置いているプレイヤーは指定できません。マスターだったなら先にマスターが移ります(残りが 1 人ならその人で、2 人以上なら決まらないので、その後の Networking.IsMaster・Networking.Master は「実行不可」)。その後、残った全員で OnPlayerLeft を実行します。所有者だったなら、残りが 1 人ならその人へ移り、2 人以上なら Networking.SetOwner で誰かが取るか 1 人になるまで決まりません。その間の所有者への問い(Networking.IsOwner・Networking.GetOwner)・RequestSerialization()・新しいプレイヤーの受信・連続同期の Behaviour の時間経過は「実行不可」です。OnOwnershipTransferred を宣言した Behaviour では Mimic.Leave 自体が、OnOwnershipRequest を宣言した Behaviour ではその SetOwner が「実行不可」になります。退出の後は Utilities.IsValid(player) が false です。退出したプレイヤーの displayName・playerId・isLocal を読む形と、OnPlayerLeft の中の Utilities.IsValid は「実行不可」になります(実行環境で -1・空の値・停止のどれもが見られているため) |
Mimic.Become(player) | これ以降を、そのプレイヤーの側で実行します。Networking.LocalPlayer はそのプレイヤーを返し、Networking.IsOwner もそのプレイヤーを基準に答えます。Behaviour の GameObject と Transform(active と位置)・InteractionText・同じ実行の他の Behaviour のフィールドも、同期しないのでプレイヤーごとです |
Mimic.Deliver() | 積まれている値が、送信したプレイヤー以外の全員に届きます |
Mimic.Deliver(player) | 積まれている値が、そのプレイヤーにだけ届きます。1 人だけが受信できないケースを再現できます |
Mimic.AdvanceFrames(n) | 時間が n フレーム進みます。参加しているプレイヤーの Update が、参加した順に実行されます(この Behaviour、次に同じ実行の他の Behaviour) |
Mimic.Explore() | そのテストを、順序の定まっていない箇所をすべて入れ替えて実行します |
gameObject.SetActive | active が変わると、Unity と同じくその場で OnEnable・OnDisable が実行されます(この Behaviour と、その GameObject を持つ同じ実行の他の Behaviour)。inactive の間は Mimic.AdvanceFrames で Update が実行されません。inactive の間に期限の来る遅延イベント・届く値・送られる送信は「実行不可」になります(そのときの実行環境の動きを測っていないため) |
Mimic.Join() の前 | まだ誰も居ないので、Networking.LocalPlayer・Networking.IsOwner・Networking.IsMaster・Networking.GetOwner・VRCPlayerApi.GetPlayerCount は null・false・0 を返さず「実行不可」になります(実行環境ではスクリプトが動く間、自分が必ず居るため)。先に Mimic.Join() を呼びます |
RequestSerialization() | 実行環境と同じく送信を頼むだけです。OnPreSerialization・値の送信・OnPostSerialization は、時間が進んだとき(Mimic.AdvanceFrames の次のフレームか Mimic.Deliver)に所有者の側で起きます。同じメソッドの中で呼んだ後に書いた値も送られ、その前に何度呼んでも 1 回です。呼んでから送るまでの間に所有権が移ると「実行不可」になります(実行環境はどちらの値が出るかを決めていないため) |
| 届いた値の書き込み | 1 回の受信の同期するフィールドは 1 つずつ書き込まれ、[FieldChangeCallback] はそのフィールドが書かれた時点で実行されます(残りはまだ前の値で、実行環境と同じ途中の状態です)。2 つの同期するフィールドにまたがる条件が成り立つのは OnDeserialization の時点だけです。[Reactive] の効果は、届いた値については実行環境と同じく OnDeserialization の終わりで 1 回実行されます(同期で届いた値) |
別のプレイヤーが書いた値
Section titled “別のプレイヤーが書いた値”送信側で書いた値が受信側に届くまでを、1 本のテストで確認します。
using Tsukimi;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]public partial class TestingSyncCounter : TsukimiBehaviour{ [UdonSynced] public int count;
public void Bump() { count = count + 1; RequestSerialization(); }}using Tsukimi;using VRC.SDKBase;
public partial class TestingSyncCounter{ [TsukimiTest] public void 別のプレイヤーが書いた値が届く() { VRCPlayerApi me = Mimic.Join(); VRCPlayerApi other = Mimic.Join(); Networking.SetOwner(other, gameObject);
Mimic.Become(other); count = 9; RequestSerialization(); Mimic.Become(me);
Assert.AreEqual(0, count); Mimic.Deliver(); Assert.AreEqual(9, count); }}Mimic.Deliver の前後で値が変わります。送信した時点では受信側の値は変わらず、届いた時点で変わります。
順序の入れ替え
Section titled “順序の入れ替え”Mimic.Explore() は、順序の定まっていない箇所をすべて入れ替えて、そのテストを繰り返し実行します。1 回の送信に含まれる複数の同期フィールドがどの順で書き込まれるかのように、実行環境が順序を保証していない箇所が対象です。
[TsukimiTest]public void どの順で届いても矛盾しない(){ Mimic.Explore();
// 以降は、順序を入れ替えた組み合わせのそれぞれで実行される。}| 書く位置 | テストの最初の文です。後ろに置くとコンパイル時のエラーになります |
| 成り立たなかったとき | どの順序で成り立たなかったかが結果に出ます |
| 実行時間 | すべての順序を実行するため、書かないテストより長くかかります |
| 入れ替える箇所が無いテスト | 確認できることが無いため、実行できません |
| 1 か所の上限 | 1 か所で入れ替える順序は 24 通りまでです。超える箇所にテストの実行が達した時点で、実行不可になります |
| 1 つのテストの上限 | 順序を変えた実行は 512 回までです。上限に達した時点で実行不可になり、detail に、順序がずれうる箇所ごとの通り数と、それを掛けた数が出ます |
| 通り数の数え方 | 同期する値が誰かに届くたびに(オーナーより後に参加したプレイヤーと、Mimic.Deliver が値を渡す各プレイヤー)、Behaviour の n 個の同期フィールドが n! 通りの順で書き込まれます。プレイヤーが 2 人以上なら、Mimic.AdvanceFrames の 1 フレームごとに、プレイヤーの実行順が(人数)! 通りあります。実行の回数は、これらの通り数をすべて掛けた数です |
| 上限を超える例 | 4 個の同期フィールドが 2 回届くと 24 × 24 = 576 回です。5 個の同期フィールドは最初に届いた時点で 120 通りになり、1 か所の上限を超えます。2 人で Mimic.AdvanceFrames(10) を呼ぶと 2^10 = 1024 回です |
確認できる範囲
Section titled “確認できる範囲”Mimic は実行環境の再現ではありません。確認できるのは記述した手順どおりに実行した結果までで、再現していない要素が 9 つあります。
| 再現していない要素 | 影響 |
|---|---|
| 通信の遅れ | 届くまでの時間は測れません |
| 送信の頻度 | 実行環境が実際に何回送るかは分かりません |
| 補間の途中の値 | Linear と Smooth の中間の値は出ません |
| ほかのプレイヤーへのネットワーク越しの送信 | この Behaviour へ送った SendCustomNetworkEvent は、実機と同じく送ったプレイヤーの手元でその場で実行されます(All と Self、テストが所有者として実行しているときの Owner。Others はここでは何も実行しません)。ほかのプレイヤーは受け取らないので、相手の側の到着も順序もありません。同期の方式が None の Behaviour・その名前の public メソッドが無い・_ で始まり [NetworkCallable] でない名前では実行されません。[NetworkCallable] でないメソッドへ値を渡す・メソッドの引数と数の違う値・2 つ以上の値を渡す送信は「実行不可」になります。ほかの Behaviour へ送ったものは記録されるだけです。遅延呼び出し(SendCustomEventDelayedFrames など)を他の Behaviour へ向けたものは「実行不可」になります |
| 送信の結果 | OnPostSerialization の result は ClientSim と同じ値です(success は常に true、byteCount は同期するフィールドの数の 4 倍)。実機が送るバイト数ではなく、送信の失敗も起きません |
| 実行環境が持つ順序 | Mimic.Explore() が入れ替えるのは、このツールが知っている箇所だけです。どの順序で起きても成り立つ、とはここでは言えません |
| サーバーの時刻 | 読むと「実行不可」になります(サーバーの時刻を読むコード) |
OnPlayerJoined・OnPlayerLeft の順 | プレイヤーをまたいだ順と、新しいプレイヤーでの値の到着との前後は、ここでは固定です(Mimic.Join() の行)。実行環境はどちらの順も決めていません |
OnMasterTransferred | 呼ばれません |
同じ実行に渡した他の Behaviour
Section titled “同じ実行に渡した他の Behaviour”test に渡したソースに他の Behaviour が含まれていると、同じ仮想シーンにインスタンスが 1 つずつ配置されます。フィールドの宣言した型が、そのうちのちょうど 1 つを指しているとき、そのフィールドはそのインスタンスへ接続されます。
using Tsukimi;
public class Lamp : TsukimiBehaviour{ public int lit;
public void TurnOn() { lit = 1; }}using Tsukimi;
public partial class Switch : TsukimiBehaviour{ public Lamp lamp; // 上の Lamp へ接続される
public void Press() { lamp.SendCustomEvent(nameof(Lamp.TurnOn)); }}using Tsukimi;
public partial class Switch{ [TsukimiTest] public void 押すと明かりが点く() { Press(); Assert.AreEqual(1, lamp.lit); // Lamp の側が実際に走った }}接続された相手に起きること
Section titled “接続された相手に起きること”| 呼んだ先 | その場で実行されます |
| 相手のフィールド | 相手自身のものです。読み書きは相手のヒープに対して行われます |
相手のコードの中の this・gameObject・transform | 相手を指します |
GetProgramVariable と SetProgramVariable | その時点の相手のヒープの値を読み書きします |
| 接続されなかったフィールド | 推測せずに「実行不可」になります |
接続されない書き方
Section titled “接続されない書き方”| 書き方 | 帰結 |
|---|---|
| 基底の型で宣言したフィールド | どの Behaviour を指すか特定できません |
| 同じ実行に 2 つ存在する型で宣言したフィールド | どちらを指すか特定できません |
| 配列で持った Behaviour | 接続されません |
| 同じ実行に渡していない Behaviour | インスタンスが存在しません |
| 相手が同期 API を呼ぶコード | 相手の中の RequestSerialization・Networking.SetOwner・Mimic は実行できません。相手の同期するフィールドは、通常のフィールドとして読み書きできるだけです |
実行のしくみ
Section titled “実行のしくみ”| テスト 1 本ごと | 新しいインスタンスとヒープで実行されます。前のテストが残した値は次へ持ち越されないので、結果が実行の順序に左右されません |
| フィールドの初期化子 | テストの本文へ入る前に一度実行されます。宣言に書いた値から始まるので、実行環境と同じ初期状態になります |
Start などの起動時のイベントの本体 | 自動では実行されません。実行したいときはテストの本文で Start() と書きます(テストのコードだけを読めば何が起きるか分かるようにするためです)。起動時に実行される [Effect] の効果は、その Start() の終わりで実行されます |
テストから直接呼んだメソッドの [Reactive] の効果 | テストのメソッドもエントリポイントなので、Bump(); のように直接呼んだメソッドが変えた値の効果は、テストのメソッドが終わるときに実行されます。Bump(); の次の行の Assert からは、効果はまだ実行されていません。ワールドが Bump を呼んだときを確認するなら、SendCustomEvent(nameof(Bump)); と書いて別のエントリポイントとして実行します。この呼び出しから戻った時点で、効果は実行されています |
Start を書いた Behaviour での null による停止 | 結果の detail に、Start がテストの前に実行されないことと、Start で用意する値ならテストの最初で Start() を呼べばよいことが添えられます。Start を書いていない Behaviour では添えられません |
| 最初に成り立たなかった時点 | そのテストは打ち切られ、同じテストの中の後ろの Assert は実行されません |
結果の読み方
Section titled “結果の読み方”結果は 4 つに分かれます。
| 結果 | 意味 |
|---|---|
| 成功 | 最後まで実行され、どの Assert も成り立った |
| 失敗 | Assert が成り立たなかった。期待した値・実際の値・ソースの位置が出る |
| 異常終了 | 実行の途中で停止した。ゼロ除算のように、実行環境でも同じ箇所で停止するケース |
| 実行不可 | 実行そのものができなかった。Assert の失敗とは別で、インタプリタがモックを持たない呼び出しを使ったときと、実行環境での答えがここでは分からないケース(当て推量しない答え)に出る。メッセージは 2 通りある。実行環境に存在する呼び出しなら、コードは正しいがここでは確認できないという意味。実行環境に存在しない呼び出しなら、その呼び出し自体を見直す。確認したい部分をその呼び出しから分離すれば実行できる場合がある |
「失敗」と「実行不可」を分けているのは、直す場所が違うからです(1 つにまとめると、モックが足りないだけのものが「テストが失敗した」に見えます)。
当て推量しない答え
Section titled “当て推量しない答え”実行環境での答えが 1 つに決まらないか、測っていないケースは、値の代わりに「実行不可」で返り、detail に理由が出ます。
| ケース | 補足 |
|---|---|
Mimic.Join() の前のプレイヤーへの問い | 上の同期の確認の表です |
| 所有者が去った後の所有者 | 残りが 2 人以上のとき(Mimic.Leave(player) の行) |
| サーバーの時刻 | Networking.GetServerTimeInMilliseconds()・GetServerTimeInSeconds()。実行環境では実行した時点で値が決まり、0 のような代わりの値は、0 を「まだ」の意味に使うコードを実行環境が通らない分岐へ進めるためです。Networking.GetNetworkDateTime()・Networking.SimulationTime・Networking.CalculateServerDeltaTime は模していないので、これも「実行不可」です |
| 64 段より深いイベントの入れ子 | イベントが自分の始めたイベントの中から SendCustomEvent で次を始める形です。実行環境がどの深さまで許すかを測っていません |
int に収まらない値と NaN の Mathf.FloorToInt・Mathf.CeilToInt・Mathf.RoundToInt | 答えが機械によって違います |
名前が null の SendCustomEvent・SendCustomEventDelayedFrames | |
コードが書く前の InteractionText | 最初の値は Inspector で設定する値です |
長さがちょうど 0.00001 のベクトルの Vector3.normalized | Unity が割り算と 0 を返すことを切り替える境目です |
サーバーの時刻を読むコード
Section titled “サーバーの時刻を読むコード”テストはサーバーの時刻を知り得ないので、時刻を読む箇所に達したケースは「実行不可」になります。時刻を 1 か所で読んで決める側のメソッドへ渡し、そのメソッドを好きな時刻でテストします。
using Tsukimi;using VRC.SDKBase;
public partial class TestingCooldown : TsukimiBehaviour{ public int lastUsedAt; // 0 は「まだ使っていない」 public int uses;
public void Use() { TryUse(Networking.GetServerTimeInMilliseconds()); }
// 時刻を引数で受け取るので、テストから好きな時刻を渡せる。 public bool TryUse(int now) { if (lastUsedAt != 0 && now - lastUsedAt < 1000) return false; lastUsedAt = now; uses = uses + 1; return true; }}using Tsukimi;
public partial class TestingCooldown{ [TsukimiTest] public void 一秒以内の二回目は使えない() { Assert.IsTrue(TryUse(50000)); Assert.IsFalse(TryUse(50400)); Assert.IsTrue(TryUse(51000)); }}テストから Use() を呼ぶと「実行不可」になります。時刻をフィールドに持つコードでは、テストが時計を読む代わりにそのフィールドへ書きます。
実行環境の規則に合わせている所
Section titled “実行環境の規則に合わせている所”| 変数に代入した名前で送るイベント | 実行環境が登録しているエントリポイント(public のメソッド・[NetworkCallable] のメソッド・イベントの登録名の "_interact"・"_update" など)にだけ届きます。name が "Interact" や private のメソッドの名前の SendCustomEvent(name) は、実行環境でもここでも何も実行しません。名前を定数で書いた同じ送信は、コンパイル時にエラーになります |
| 配列の要素の型 | 配列は作ったときの型を保ちます。基底の型の変数を通して別の型の値を書く形(object[] slots = names; slots[0] = 7;)と Array.SetValue は停止(異常終了)し、float[] から int[] への Array.Copy も停止します。int[] から float[] への Array.Copy は値を変換します |
object からの値型の取り出し | (int)o はコンパイルしたプログラムと同じく変換します。null は 0、2.6f は 3 です |
float・double から整数へのキャスト | 小数部を捨てます。結果が収まらないときと NaN は停止(異常終了)します |
| フィールドの初期化子 | プレイヤーごとに実行されるので、後から参加したプレイヤーも初期化子が作った値から始まり、ほかの Behaviour へ接続したフィールドも接続されています |
PC の実行環境の値に合わせる計算
Section titled “PC の実行環境の値に合わせる計算”| 値を合わせる呼び出し | Mathf.Lerp・Vector3.Dot・Vector3.magnitude・Vector3.sqrMagnitude・Vector3.Distance・Vector3.Cross は、PC の実行環境の値を返します。PC の実行環境は Unity の 1 回の呼び出しの中の途中の値を倍精度で持ち、最後に 1 回だけ float へ丸めるためです(Mathf.Lerp(1f, 0.1f, 1f) はここでも PC でも 0.1 で、素の .NET では 0.100000024 です) |
| Android(Quest) | 測っていないので、最後のビットが違うことがあります。そちらでも動かすワールドでは、AreEqual でなく差の大きさで比べます(Assert.IsTrue(Mathf.Abs(got - want) < 1e-5f)) |
| ほかの複数の段の計算 | Vector2・Vector3.Lerp・Mathf.LerpUnclamped・Vector3.Angle などは PC の実行環境で測っていないので、段ごとに丸め、最後のビットが PC と違うことがあります。これも差の大きさで比べます |
型の違う同じ値の AreEqual | 表示が同じで型が違う 2 つの値(0.5 と 0.5f)が一致しないときは、両方に型が付きます(expected 0.5 (double) / actual 0.5 (float)) |
テストの結果には、そのテストが実行した費用が付きます。上限を属性で書くと、上限を超えたテストは失敗になります。実行時間のバジェットを守りたいときに使います。
using Tsukimi;using UnityEngine;
public partial class TestingAbsSum : TsukimiBehaviour{ public int total;
public void Sum(int n) { total = 0; for (int i = -n; i <= n; i++) { total = total + Mathf.Abs(i); } }}using Tsukimi;
public partial class TestingAbsSum{ [TsukimiTest] [CostLimit(externs: 87, milliseconds: 0.0092)] public void 絶対値の和は110() { Sum(10); Assert.AreEqual(110, total); }
[TsukimiTest] [CallLimit("Abs", 21)] public void Absは21回まで() { Sum(10); Assert.AreEqual(110, total); }}結果に付く費用
Section titled “結果に付く費用”| キー | 意味 |
|---|---|
steps | 実行した命令の数 |
externCalls | extern を呼び出した回数 |
estimatedMs | 上の 2 つに 1 回あたりの単価を掛けた、実行時間の概算(ミリ秒) |
suggestedLimit | 実測値をそのまま引数にした CostLimit の 1 行。貼ると、そのテストの今の費用が上限になります。ミリ秒は小数 4 桁に切り上げてあり、それ以外の余裕は足されません |
上の例の 1 本目を上限なしで実行したときの結果です(MCP サーバの test ツールの出力から 1 件を抜き出したもの)。
{ "name": "絶対値の和は110", "source": "TestingAbsSum.Tests.cs", "steps": 544, "externCalls": 87, "estimatedMs": 0.0091668, "suggestedLimit": "[CostLimit(externs: 87, milliseconds: 0.0092)]", "outcome": "passed"}| キーが無いとき | その実行方法が費用を数えていないという意味です。費用が 0 という意味ではありません |
| 費用の範囲 | テストが通った経路の費用です。実際に遊んだときの費用ではありません |
| Unity のウィンドウ | 各テストの結果の左に、87 ext / 0.0092 ms の形で出ます |
上限の書き方
Section titled “上限の書き方”| 書き方 | 上限を置く数 |
|---|---|
[CostLimit(externs: 87)] | extern を呼び出した回数 |
[CostLimit(milliseconds: 0.0092)] | 実行時間の概算 |
[CostLimit(steps: 544)] | 実行した命令の数 |
[CallLimit("Abs", 21)] | 名前に指定した文字列を含む呼び出しの回数。1 つのテストに複数付けられます |
CostLimit で省いた項目 | その項目に上限はありません |
| 上限を超えたとき | 失敗になります。failureKind が overLimit になり、expected に上限、actual に実測値が入ります |
| Assert が先に成り立たなかったとき | Assert の失敗がそのまま出ます。上限は見ません |
steps にステップ数の上限以上を書いたとき | 超えることがない上限なので、実行不可になります(ステップ数の上限) |
| 概算が当てにならない呼び出し | 生成・型での検索・文字列・配列は概算より重くなります。ミリ秒でなく externs か CallLimit で上限を置きます |
| 素の C# としての実行 | CallLimit は同じように数えます。CostLimit を書いたテストと、この実行方法で数えられない呼び出しを指定した CallLimit は実行不可になります |
| フィールドの初期化子の中の数える呼び出し | 素の C# としての実行では、CallLimit で指定した呼び出しをフィールドの初期化子が 1 回でも呼ぶと、そのテストは実行不可になります。既定の道がコンパイルの時点で値を求める初期化子と、実行時に実行する初期化子を、この実行方法では区別できないためです |
ケースの上限と数
Section titled “ケースの上限と数”[TsukimiTest] のコンストラクタは、省略できる引数を 2 つ取ります。seeds を省いたテストは 1 つのケースとして、seeds を書いたテストは種(seed)の数だけのケースとして実行されます。
| 引数 | 既定値 | 書ける値 | 説明 |
|---|---|---|---|
maxSteps | 1,000,000 | 1 から 100,000,000 | 1 つのケースが実行できるステップ数の上限。書いた値は属性の MaxSteps プロパティに入ります(省くと 0) |
seeds | 省くとケースは 1 つ | 1 から 1,000 | ケースの数。int の引数を 1 つ取るメソッドに書きます。書いた値は属性の Seeds プロパティに入ります(省くと 0) |
ステップ数の上限
Section titled “ステップ数の上限”1 つのケースが実行できるのは 1,000,000 ステップまでです。1 ステップは、既定の道では実行した命令の 1 つで、素の C# としての実行ではループの先頭への戻りかメソッドの呼び出しの 1 回です。
| 上限を超えたケース | 途中で停止し、実行不可になります。detail に、上限の数と、maxSteps で上限を上げられることが出ます |
| 失敗にしない理由 | 終わらないループと、ステップ数が足りないだけのループを区別できないためです。成功にもなりません |
| 長い手順を実行するテスト | 呼び出し 1 回を 1 つのケースに分けます。手順の全体を 1 つのケースで実行すると、上限で停止したときに、どの手順が誤っているのか分かりません |
| 2 つの実行方法 | 既定の道と素の C# としての実行は、同じ数を上限にして、それぞれのステップを数えます |
[CostLimit(steps: …)] との違い | CostLimit は、超えたテストを失敗にします。maxSteps は、実行を打ち切る位置を動かすだけです |
[CostLimit(steps: …)] に書ける値 | そのテストのステップ数の上限(maxSteps、書かなければ 1,000,000)より小さい値です |
maxSteps を書くと、そのテストだけ上限を上げられます(次の例は、maxSteps を書かずに実行すると実行不可になり、書くと成功します)。
using Tsukimi;
public partial class TestingSearch : TsukimiBehaviour{ public int CountPairs(int n) { int found = 0; for (int a = 0; a < n; a++) { for (int b = 0; b < n; b++) { if ((a * b) % 7 == 1) found++; } } return found; }}using Tsukimi;
public partial class TestingSearch{ [TsukimiTest(maxSteps: 3000000)] public void 広い範囲でも組が見つかる() { Assert.IsTrue(CountPairs(300) > 0); }}種ごとのケース
Section titled “種ごとのケース”seeds を書くと、int の引数を 1 つ取るメソッドを、引数に 0 から順に種を渡して、種の数だけのケースとして実行します。
using Tsukimi;
public partial class TestingHand : TsukimiBehaviour{ public int[] cards = new int[5];
public int Highest() { int best = cards[0]; for (int i = 1; i < cards.Length; i++) { if (cards[i] > best) best = cards[i]; } return best; }}using Tsukimi;
public partial class TestingHand{ [TsukimiTest(seeds: 200)] public void 最大値はどの札より小さくない(int seed) { for (int i = 0; i < cards.Length; i++) { cards[i] = (seed * 31 + i * 17) % 52; } int top = Highest(); for (int i = 0; i < cards.Length; i++) { Assert.IsTrue(top >= cards[i]); } }}| ケースの名前 | メソッド名(seed=3) の形です。上の例は 最大値はどの札より小さくない(seed=0) から 最大値はどの札より小さくない(seed=199) までの 200 ケースになります |
| 各ケースの状態 | 1 本のテストと同じく、新しいインスタンスとヒープから始まります。ステップ数の上限もケースごとに数えます |
maxSteps との併用 | 書けます。上限は各ケースに適用されます |
| 入力の作り方 | 種から整数の演算で作ります。同じ種からは、2 つの実行方法でも、どの機械でも同じ入力が作られます |
| 成り立たなかったケース | ケースの名前に種が出るので、同じ入力をもう一度作れます |
| 書き方 | 説明 | エラー | 理由 | 代替 |
|---|---|---|---|---|
[TsukimiTest(maxSteps: 0)] | maxSteps が範囲の外 | TUKI0133 | 設計 | 範囲の中の値を書くか、引数を省く |
[TsukimiTest(seeds: 1001)] | seeds が範囲の外 | TUKI0133 | 設計 | 範囲の中の値を書くか、引数を省く |
[TsukimiTest(seeds: 10)] public void M() | seeds を書いたメソッドが int の引数を 1 つ取らない | TUKI0133 | 設計 | M(int seed) にするか、seeds を外す |
実行する方法は 2 つあります。どちらも同じ結果を同じ場所へ書くので、片方で実行した結果をもう片方で見られます。
Unity のウィンドウから
Section titled “Unity のウィンドウから”メニューの TsukimiCode から Tsukimi Tests を開き、すべて実行を押します。プロジェクトの中のプログラムがすべて対象になります。
結果はソースファイルごとにグループ化されて表示され、右端に 4 種類の結果のいずれかが出ます。成り立たなかったものには、期待した値・実際の値・位置がその下に付きます。

MCP サーバから
Section titled “MCP サーバから”test ツールにソースを渡すと、同じ内容が JSON で返ります。書き方は MCP サーバ のページを見てください。
projectPath に Unity プロジェクトのパスを渡すと、結果がそのプロジェクトにも保存されます。Unity へ戻ってウィンドウを開き直すと、そこに出ます。
素の C# としての実行
Section titled “素の C# としての実行”| 選び方 | test ツールに fast を渡します。MCP サーバからだけ選べます |
| 向いている場面 | 書いている途中の往復です。速いので、書いて実行して直すまでが短くなります |
| 答える問い | 書いた規則が正しいかです。コンパイルした形が正しいかには答えません(その形を作らないためです) |
| ほかの Behaviour を一緒に渡したとき | この道は同じ世界へ置かないので、Behaviour が 2 つ以上在るソースを渡すと、テストは全部「実行不可」になります。接続されるはずのフィールドが空のまま実行されることはありません |
| 模していない呼び出し | 実行不可で返ります。黙って既定の値を返すことはありません |
| フィールドの初期化子 | Behaviour を作るときに実行されます。new Vector3(1f, 2f, 3f) や Vector3.zero のように、模している呼び出しは既定の道と同じく実行されます |
| 模していない定数を読む初期化子 | Color.gray や Vector3.up のように、この道が模していない定数を初期化子で読むと、値を求められず、その Behaviour のテストは全部「実行不可」になります。detail に、フィールドの初期化子が原因であることと、既定の道で実行すればよいことが出ます |
| リアクティブの属性 | [Reactive]・[Computed]・[Effect]・[On] は、既定の道と同じエントリポイントで、同じ順序・同じ比べ方で実行されます。コンパイラが更新を置くエントリポイントを決められないソース(Behaviour が 2 つ以上在る・コンパイルが通らない)では、更新を省いて実行することはなく、テストは全部「実行不可」になります |
static のフィールド | テスト 1 本ごとに、初期化子が残した値から始まります(既定の道と同じです)。この道が戻せるのは数・文字列・列挙型と、それらだけを持つ struct と、それらの配列です。それ以外(Vector3 など)の値で始まる static のフィールドがあると、1 本目のテストだけが実行され、2 本目からはそのフィールドを名指して「実行不可」になります |
| 列挙型の実行時の型 | object へ代入した、自分で宣言した列挙型に実行時の型を聞くテストと、既定の道と答えが変わる型の照合(o is int など)をするテストは、この道では「実行不可」になります。既定の道では列挙型は整数として持たれ、型は整数の型(Int32)です |
async FrameTask のメソッド | async FrameTask のメソッドを宣言したソースでは、テストは全部「実行不可」になります。既定の道で実行します |
| 長い時間のかかるテスト | この道にもステップ数の上限があり、超えると実行不可になります。ただし数え方が既定の道と違うので、片方で実行不可・もう片方で成功になることがあります |
| 深すぎる呼び出しの入れ子 | 終わらない再帰・Start() を呼ぶ [Effect]・終わらずに互いを始めるイベントのように、この道に入る深さを超える入れ子は、どの機械で実行しても「実行不可」(This test nested too deeply)になります。既定の道は同じコードを停止(異常終了)にするか、イベントの入れ子が 64 段を超えた時点で「実行不可」にします |
| プレイヤーを渡すイベント | Mimic が OnPlayerJoined・OnPlayerLeft・OnOwnershipTransferred・OnOwnershipRequest を、それを宣言した Behaviour で実行することになるケースは「実行不可」になります。この道はテストが持つのと同じプレイヤーのオブジェクトを渡せず、player == me が false になるためです。OnPostSerialization は実行されます |
| 型の名前と ID | GetUdonTypeName と GetUdonTypeID を呼ぶテストは、この道では実行不可になります。既定の道では実行できます(型の名前と ID) |
| どちらの道だったか | 結果の origin に出ます。失敗を読む前にここを見ます |
結果の出力先
Section titled “結果の出力先”実行結果は、プロジェクトの Library/Tsukimi/last-test-run.json に保存されます。Library は Unity が作り直す場所なので、版管理にも配布物にも入りません。