Skip to content

Testing

You can run a Behaviour’s logic and check it without launching Unity. Neither uploading nor Play mode is needed.

Tests run by default on an interpreter that reproduces Udon’s semantics. Nothing is recompiled as ordinary C#, so anywhere C# and Udon disagree — the value an integer takes when it overflows, for instance — you get the Udon value. The runtime API is answered by mocks, though, so that part is not the same as the real thing.

The MCP server also offers a path that runs them as plain C# (Running as plain C#).

Prepare the Behaviour you want to check. Calling Bump just increments value by one.

using Tsukimi;
public partial class TestingCounter : TsukimiBehaviour
{
public int value;
public void Bump()
{
value = value + 1;
}
public void Reset()
{
value = 0;
}
}

Tests go in a separate file. Mark both the original class and the test side partial, and name the test file Name.Tests.cs. A file with this name is excluded from compilation, so tests never end up in what you ship and never run in a world.

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, "増えていない");
}
}

You can touch private fields and methods as they are. They are in the same class, so there is no need to open them up to public for the test.

What happensA test that calls Assert or Mimic keeps that call in the program that is uploaded, so Unity stops the build (test still runs it). A test that calls neither works, but the method goes into the program uploaded to the world and is exposed as an entry point
Error and warninginspect reports TUKI0117: an error (compiles: false) for a test that calls Assert or Mimic, and a warning for a test that calls neither (Error)

A public void method with no arguments and [TsukimiTest] attached becomes one test (only a method with seeds takes one int argument; see One case per seed). Methods without the attribute are not run, so helper methods that only get called from tests can sit in the same file.

There is no rule for how to name a method. Japanese is allowed, as in the example above, but the name is a C# identifier, so it cannot start with a digit.

Checks are written with the static methods on Tsukimi.Assert. Six are available today.

Takes a single bool and checks whether it is true or false. You can write the condition itself, so comparisons and range checks all gather here.

[TsukimiTest]
public void 上限で止まる()
{
charge = 120;
Clamp();
Assert.IsTrue(charge <= 100);
Assert.IsFalse(charge < 0);
}

Only the true/false value appears in the result. What was compared against what is not kept, so when you want to see that two values match, Assert.AreEqual gives a result that reads better.

Compares two values. The first argument is the expected value, the second is the actual value. This order appears in the result text, so reversing them makes you misread it when tracking down the cause.

[TsukimiTest]
public void 一度触ると1つ増える()
{
Interact();
Assert.AreEqual(1, count);
Assert.AreNotEqual(0, count);
}

The argument type is object, so a number, a string, or a reference can all be passed. Passing a float directly compares the error that comes out of every computation as well, so it does not suit comparing real numbers.

Checks whether a reference is null. Udon has no exceptions, and following a null reference halts the whole event without leaving a cause. This is the form that catches it beforehand.

[TsukimiTest]
public void 初期化前は空のまま()
{
Assert.IsNull(current);
Setup();
Assert.IsNotNull(current);
}

Passing a Unity object needs care. A destroyed object can be in a state different from C#‘s null, so the result of these two can run against intuition.

Every form has an overload with a trailing string message. When the check doesn’t hold, that string appears verbatim in the result.

Assert.AreEqual(80, charge, "1 回触ると 20 減るはず");
Assert.IsTrue(charge > 0, "使い切っている");

It helps when the values alone don’t say what was being checked, such as comparing values of the same type repeatedly or writing a long condition.

Assert can only be written inside a method marked [TsukimiTest]. Using it outside is a compile-time error.

public void Interact()
{
// Can't be written here (TUKI0114).
Assert.IsTrue(count >= 0);
}

An Assert call is converted into a dedicated instruction that doesn’t exist in the runtime. If one slips into a program you ship, it halts as soon as execution reaches it. This restriction is there to catch that at compile time first.

A method with [Kernel] runs on the GPU, so a test can’t call it. Methods that take or return Color4 or KernelId are also out of a test’s reach.

Move the computation you want to check into a static method that takes only float, int, bool, and Vector2. If both the kernel and the test call that same method, the very computation that runs on the GPU can be checked without Unity.

public static float NextHeight(float now, float before, float around, float damping)
{
return (now * 2f - before + (around * 0.25f - now) * 0.9f) * damping;
}

As long as the extracted method is called only from the kernel, the instruction count doesn’t grow. Once the behaviour side calls it too, that method is also converted to Udon and the instruction count grows.

With Tsukimi.Mimic, you can place several players inside one test and check how sync plays out. Ownership transfer and sending are written as usual; Networking.SetOwner and RequestSerialization stay as they are. What Mimic adds is the 7 things there is no way to write while running as a single player.

SyntaxDescription
Mimic.Join()One player joins. The first one is you (the master) and becomes the owner. A player who joins later starts from the field values as they were when the test body began (initializers and wired Behaviours; arrays as their own copy), then receives the owner’s current values. As in the runtime, OnPlayerJoined runs on everyone already there for the newcomer, and on the newcomer for everyone, themselves included (this Behaviour first, then the other Behaviours in the same run). Here it runs in join order, and the newcomer’s OnPlayerJoined comes before the owner’s values arrive
Mimic.Leave(player)That player leaves. The player you are viewing from cannot be passed. If they were the master, the master moves first (to the one player left; with two or more left it is not decided, so Networking.IsMaster and Networking.Master after that are “not runnable”). Then OnPlayerLeft runs on everyone left. If they were the owner, ownership moves to the one player left; with two or more left it is not decided until someone takes it with Networking.SetOwner or only one player remains. Until then, asking who owns it (Networking.IsOwner, Networking.GetOwner), RequestSerialization(), a new player receiving the values, and time passing on a Behaviour with continuous sync are “not runnable”. On a Behaviour that declares OnOwnershipTransferred the Mimic.Leave itself is “not runnable”, and so is that SetOwner on one that declares OnOwnershipRequest. After leaving, Utilities.IsValid(player) is false. Reading the player who left (displayName, playerId, isLocal) and Utilities.IsValid inside OnPlayerLeft are “not runnable” (the runtime has been seen to give -1, an empty value, or stop)
Mimic.Become(player)Runs the following lines on that player’s machine. Networking.LocalPlayer returns that player, and Networking.IsOwner answers about them. The Behaviour’s GameObject and Transform (active state and position), its InteractionText, and the fields of the other Behaviours in the same run are each player’s own as well, since none of them is synced
Mimic.Deliver()The queued values arrive at everyone except the player who sent them
Mimic.Deliver(player)The queued values arrive at that player only. This lets you write the case where a single player misses out
Mimic.AdvanceFrames(n)Time advances by n frames. The Update of each joined player runs in join order (this Behaviour first, then the other Behaviours in the same run)
Mimic.Explore()Runs the test with every place whose order isn’t fixed permuted
gameObject.SetActiveWhen the active state changes, OnEnable and OnDisable run there and then, as in Unity (on this Behaviour and on another Behaviour in the same run whose GameObject it is). While the object is inactive, Mimic.AdvanceFrames does not run its Update. A delayed event that comes due, values that arrive, or a send that would go out while it is inactive is “not runnable” (what the runtime does then has not been measured)
Before Mimic.Join()No one is there yet, so Networking.LocalPlayer, Networking.IsOwner, Networking.IsMaster, Networking.GetOwner and VRCPlayerApi.GetPlayerCount are “not runnable” rather than returning null, false or 0 (in the runtime, the local player is always there while a script runs). Call Mimic.Join() first
RequestSerialization()As in the runtime, it only asks for a send. OnPreSerialization, sending the values and OnPostSerialization happen on the owner’s machine when time passes (at the next frame of Mimic.AdvanceFrames, or at Mimic.Deliver). A value written after the call in the same method is sent too, and calling it several times before then sends once. If ownership moves between the call and the send, it is “not runnable” (the runtime does not say whose values go out then)
Writing the arrived valuesThe synced fields of one delivery are written one at a time, and a [FieldChangeCallback] runs as soon as its own field is written (the rest still hold their old values, the same half-applied state the runtime shows). A condition that spans two synced fields holds only at OnDeserialization. For a value that arrives, [Reactive] effects run once at the end of OnDeserialization, the same as in the runtime (Values that arrive by sync)

One test checks a value written on the sending side all the way to its arrival on the receiving side.

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 ValueWrittenByAnotherPlayerArrives()
{
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);
}
}

The value changes across Mimic.Deliver. At the moment of sending, the receiving side’s value hasn’t changed; it changes at the moment of arrival.

Mimic.Explore() runs the test repeatedly, permuting every place whose order isn’t fixed. The targets are places where the runtime guarantees no order, such as the order in which several synced fields carried in one payload arrive.

[TsukimiTest]
public void ConsistentInAnyArrivalOrder()
{
Mimic.Explore();
// Everything below runs once for each reordered combination.
}
Where to write itThe first statement of the test. Placing it later is a compile-time error
When it doesn’t holdThe result shows which ordering it failed to hold under
Running timeEvery ordering is run, so it takes longer than a test without it
Tests with nothing to reorderThere’s nothing to check, so it can’t be run
Limit per pointAt most 24 orders at one point. The test becomes not runnable as soon as it reaches a point with more.
Limit per testAt most 512 runs with different orders. The test becomes not runnable when it reaches the limit, and the detail lists the number of orders at each point where the order can differ, and their product
How orders are countedEach time synced values arrive at someone (a person who joined after the owner, and each person Mimic.Deliver hands values to), the behaviour’s n synced fields are written in n! orders. With two or more people, each frame of Mimic.AdvanceFrames runs the people in (number of people)! orders. The number of runs is the product of all these
Examples over the limit4 synced fields arriving twice is 24 × 24 = 576 runs. 5 synced fields reach 120 orders at the first arrival, over the limit per point. Two people and Mimic.AdvanceFrames(10) is 2^10 = 1024 runs

Mimic is not a reproduction of the runtime. What it can check goes as far as what happens along the path you wrote, and there are 9 things it doesn’t have.

What it does not reproduceWhat follows
Network delayThe time until arrival can’t be measured
Send frequencyHow many times the runtime actually sends is unknown
Values partway through interpolationThe intermediate values of Linear and Smooth don’t appear
Network sends to other playersA SendCustomNetworkEvent sent to this Behaviour runs on the spot on the sender’s machine, as in a real instance (All and Self, and Owner when the test is acting as the owner; Others runs nothing here). The other players never receive it, so there is no arrival on their side, let alone an order. It does not run when the Behaviour’s sync mode is None, when no public method has that name, or when the name starts with _ and the method is not [NetworkCallable]. A send that passes a value to a method that is not [NetworkCallable], a different number of values than the method takes, or two values or more is “not runnable”. A send to another Behaviour is only recorded. A delayed call (such as SendCustomEventDelayedFrames) aimed at another Behaviour is “not runnable”
The result of a sendThe result of OnPostSerialization holds the values ClientSim passes (success is always true, and byteCount is 4 times the number of synced fields). It is not the number of bytes a real instance sends, and a send never fails
Orderings the runtime hasMimic.Explore() permutes only the places this tool knows about. It can’t say here that the test holds under every order things might happen in
Server timeReading it is “not runnable” (Code that reads the server time)
Order of OnPlayerJoined and OnPlayerLeftThe order across players, and on a newcomer against the values arriving, is fixed here (the Mimic.Join() row). The runtime fixes neither
OnMasterTransferredNot raised

When the sources handed to test hold other Behaviours, one body of each is placed in the same world. A field is wired to one of those bodies when the type it is declared with names exactly one of them.

Lamp.cs
using Tsukimi;
public class Lamp : TsukimiBehaviour
{
public int lit;
public void TurnOn() { lit = 1; }
}
Switch.cs
using Tsukimi;
public partial class Switch : TsukimiBehaviour
{
public Lamp lamp; // wired to the Lamp above
public void Press() { lamp.SendCustomEvent(nameof(Lamp.TurnOn)); }
}
Switch.Tests.cs
using Tsukimi;
public partial class Switch
{
[TsukimiTest]
public void PressingItLightsTheLamp()
{
Press();
Assert.AreEqual(1, lamp.lit); // the Lamp really ran
}
}
The callRuns there and then
The other Behaviour’s fieldsThey are its own. Reads and writes reach its heap
this, gameObject and transform inside its codeMean that Behaviour
GetProgramVariable and SetProgramVariableRead and write the values on that Behaviour’s heap as they stand
A field that was not wiredComes back as not runnable rather than being guessed at
SyntaxConsequence
A field declared with the base typeThere is no way to tell which Behaviour it means
A field declared with a type that two Behaviours in the run shareWhich of the two it means is not decided
A Behaviour held in an arrayNot wired
A Behaviour not handed to the same runNo instance of it exists
Code in the other Behaviour that calls a synchronization APIRequestSerialization, Networking.SetOwner and Mimic inside it cannot run. Its synced fields can only be read and written as plain fields
Per testEach runs with a fresh instance and heap. Values left behind by a previous test don’t carry over, so results don’t depend on execution order
Field initializersThey run once before the body of the test. The test starts from the values written on the declarations, so the initial state matches the runtime
The bodies of startup events such as StartThey don’t run on their own. Write Start() in the body of the test when you want one to run (so that reading the test alone tells you what happens). The [Effect] effects that run at startup run at the end of that Start()
[Reactive] effects of a method the test calls directlyA test method is itself an entry point, so the effects of what a directly called method changed, as with Bump();, run when the test method returns. An Assert on the line after Bump(); sees the effects not yet run. To check what happens when the world calls Bump, write SendCustomEvent(nameof(Bump)); and run it as an entry point of its own. When that call returns, the effects have run
A stop on null in a behaviour that has StartThe result’s detail adds that Start does not run before a test, and that a value set up in Start can be had by calling Start() at the beginning of the test. It is not added for a behaviour without Start
The first check that doesn’t holdThat test is cut short, and later Asserts in the same test don’t run

Results fall into 4 kinds.

ResultMeaning
PassedRan to the end, and every Assert held
FailedAn Assert did not hold. The expected value, the actual value, and the source location are shown
HaltedIt halted partway through execution. A form such as division by zero that halts at the same place in the runtime too
Not runnableIt could not run at all. Separate from an Assert failure, it appears when the interpreter used a call it has no mock for, and in cases whose answer in the runtime is not known here (Answers it will not guess). The message comes in two forms. If the call exists in the runtime, the code is correct but cannot be checked here. If the call does not exist in the runtime, review the call itself. Separating the part you want to check from that call can make it runnable

Failure and not-runnable are kept apart because the fix belongs in different places (merge them and something that merely lacks a mock looks like a failing test).

A case whose answer in the runtime is not a single one, or has not been measured, comes back “not runnable” with the reason in detail instead of a value.

CaseNotes
Asking about players before Mimic.Join()The table under Checking sync above
The owner after the owner leftWith two or more players left (the Mimic.Leave(player) row)
Server timeNetworking.GetServerTimeInMilliseconds() and GetServerTimeInSeconds(). In the runtime the value depends on when the code runs, and a stand-in such as 0 would send code that uses 0 to mean “not yet” down a branch the runtime never takes. Networking.GetNetworkDateTime(), Networking.SimulationTime and Networking.CalculateServerDeltaTime are not modelled, so they are “not runnable” too
Events nested more than 64 deepAn event starting the next one with SendCustomEvent from inside an event it started. How deep the runtime allows has not been measured
Mathf.FloorToInt, Mathf.CeilToInt and Mathf.RoundToInt on a value too large for an int, or on NaNThe answer differs between machines
SendCustomEvent and SendCustomEventDelayedFrames with a null name
InteractionText read before the code writes itIts starting value is set in the Inspector
Vector3.normalized of a vector whose length is exactly 0.00001The point where Unity switches between dividing and returning zero

A test cannot know the server time, so a case that reaches the line reading it is “not runnable”. Read the time in one place and pass it to the method that decides, then test that method with times you choose.

using Tsukimi;
using VRC.SDKBase;
public partial class TestingCooldown : TsukimiBehaviour
{
public int lastUsedAt; // 0 means "never used"
public int uses;
public void Use()
{
TryUse(Networking.GetServerTimeInMilliseconds());
}
// The time comes in as a parameter, so a test can pass any time it likes.
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));
}
}

Calling Use() from the test is “not runnable”. When the code keeps the time in a field, have the test write that field instead of reading the clock.

An event sent by a name held in a variableReaches only an entry point the runtime registers (a public method, a [NetworkCallable] method, or an event’s registered name such as "_interact" or "_update"). SendCustomEvent(name) with name set to "Interact" or to the name of a private method runs nothing, in the runtime and here. The same send with the name written as a constant is an error at compile time
Element type of an arrayAn array keeps the type it was made with. Writing a value of another type through a variable of a base type (object[] slots = names; slots[0] = 7;) and Array.SetValue stop (“halted”), and so does Array.Copy from float[] into int[]. Array.Copy from int[] into float[] converts the values
Taking a value type out of an object(int)o converts the value, as the compiled program does: null gives 0, and 2.6f gives 3
Casting a float or double to an integerThe fraction is dropped. It stops (“halted”) when the result does not fit or the value is NaN
Field initializersIt runs on every player, so a player who joins later starts with what the initializer made, and a field wired to another Behaviour is wired for them too

Calculations that follow the PC runtime’s values

Section titled “Calculations that follow the PC runtime’s values”
Calls that match the PCMathf.Lerp, Vector3.Dot, Vector3.magnitude, Vector3.sqrMagnitude, Vector3.Distance and Vector3.Cross return the PC runtime’s value, because the PC runtime keeps the steps in between at double precision inside one Unity call and rounds to float once at the end (Mathf.Lerp(1f, 0.1f, 1f) is 0.1 here and on a PC, and 0.100000024 in plain .NET)
Android(Quest)Not measured, so the last bit can differ. In a world that also runs there, compare by the size of the difference rather than with AreEqual (Assert.IsTrue(Mathf.Abs(got - want) < 1e-5f))
Other calculations with several stepsOther Unity math with several steps
AreEqual on equal values of different typesWhen two values that print the same but have different types (0.5 and 0.5f) do not match, both carry their type (expected 0.5 (double) / actual 0.5 (float))

Every test result carries the cost that test ran up. Write a limit as an attribute and a test that goes over it fails. Use it to hold a frame-time budget.

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 SumOfAbsolutesIs110()
{
Sum(10);
Assert.AreEqual(110, total);
}
[TsukimiTest]
[CallLimit("Abs", 21)]
public void AbsAtMost21Calls()
{
Sum(10);
Assert.AreEqual(110, total);
}
}
KeyMeaning
stepsThe number of instructions executed
externCallsThe number of extern calls
estimatedMsAn estimate of the running time in milliseconds, from the two above times a unit price each
suggestedLimitA CostLimit line with the measured values as its arguments. Paste it and the test’s current cost becomes its limit. The milliseconds are rounded up to four decimals; no other headroom is added

The result of running the first example above with no limit (one entry taken from the output of the MCP server’s test tool).

{
"name": "SumOfAbsolutesIs110",
"source": "TestingAbsSum.Tests.cs",
"steps": 544,
"externCalls": 87,
"estimatedMs": 0.0091668,
"suggestedLimit": "[CostLimit(externs: 87, milliseconds: 0.0092)]",
"outcome": "passed"
}
When a key is absentIt means that way of running does not count cost. It does not mean the cost is zero
What the cost coversThe cost of the path the test took. Not the cost of actually playing
The Unity windowIt appears to the left of each test result, in the form 87 ext / 0.0092 ms
SyntaxThe number the limit is placed on
[CostLimit(externs: 87)]The number of extern calls
[CostLimit(milliseconds: 0.0092)]The estimated running time
[CostLimit(steps: 544)]The number of instructions executed
[CallLimit("Abs", 21)]The number of calls whose name contains the given string. A test can carry more than one
An item left out of CostLimitThat item has no limit
When a limit is exceededThe test fails. failureKind becomes overLimit, expected holds the limit and actual the measured value
When an Assert fails firstThe Assert failure is reported as it is. Limits are not looked at
When steps is given a value at or above the step limitIt is a limit that can never be exceeded, so the test cannot be run (Step limit)
Calls the estimate does not hold forCreating objects, looking up by type, strings and arrays run heavier than the estimate. Put the limit on externs or CallLimit rather than on milliseconds
Running as plain C#CallLimit counts the same way. A test that carries CostLimit, and a CallLimit naming a call this way of running cannot count, cannot be run
Counted calls in field initializersWhen run as plain C#, a test becomes not runnable if a field initializer makes even one call named in CallLimit. This path can’t tell the initializers the default path works out at compile time from the ones it runs at runtime

The [TsukimiTest] constructor takes 2 optional arguments. A test without seeds runs as 1 case, and a test with seeds runs as one case per seed.

ArgumentsDefault valueValues that can be writtenDescription
maxSteps1,000,0001 to 100,000,000The number of steps 1 case may run. The value you write goes into the attribute’s MaxSteps property (0 when left out)
seeds1 case when left out1 to 1,000The number of cases. Write it on a method that takes 1 int argument. The value you write goes into the attribute’s Seeds property (0 when left out)

A case can run up to 1,000,000 steps. A step is one executed instruction on the default path, and one jump back to the top of a loop or one method call when running as plain C#.

A case over the limitIt stops partway and comes back not runnable. The detail gives the limit and says that maxSteps can raise it
Why it is not a failureA loop that never ends cannot be told apart from one that only needs more steps. It is not a pass either
A test that runs a long sequenceSplit it into one case per call. When the whole sequence runs as 1 case and stops at the limit, you cannot tell which step is wrong
The 2 ways of runningThe default path and running as plain C# use the same number as the limit, and each counts its own kind of step
Difference from [CostLimit(steps: …)]CostLimit fails a test that goes past it. maxSteps only moves the point where the run is cut off
Values allowed in [CostLimit(steps: …)]A value below the test’s step limit (maxSteps, or 1,000,000 when it is not written)

Writing maxSteps raises the limit for that test only (the example below is not runnable without maxSteps, and passes with it).

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 FindsPairsOverAWideRange()
{
Assert.IsTrue(CountPairs(300) > 0);
}
}

With seeds, a method that takes 1 int argument runs as one case per seed, receiving the seeds in order from 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 HighestIsNeverBelowACard(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]);
}
}
}
Case namesMethodName(seed=3). The example above gives 200 cases, from HighestIsNeverBelowACard(seed=0) to HighestIsNeverBelowACard(seed=199)
State of each caseLike a test of its own, each case starts on a fresh instance and heap. The step limit is also counted per case
Together with maxStepsAllowed. The limit applies to each case
Building the inputBuild it from the seed with integer arithmetic. The same seed then gives the same input on both ways of running and on every machine
A case that failsThe case name carries the seed, so the same input can be built again
SyntaxDescriptionErrorReasonAlternative
[TsukimiTest(maxSteps: 0)]maxSteps is out of rangeTUKI0133by designWrite a value in range, or leave the argument out
[TsukimiTest(seeds: 1001)]seeds is out of rangeTUKI0133by designWrite a value in range, or leave the argument out
[TsukimiTest(seeds: 10)] public void M()The method with seeds does not take 1 int argumentTUKI0133by designMake it M(int seed), or remove seeds

There are 2 ways to run tests. Both write the same results to the same place, so a run started in one can be read in the other.

Open Tsukimi Tests under the TsukimiCode menu and press すべて実行 (Run all). Every program in the project is covered.

Results are grouped by source, with one of the 4 results at the right edge. For the ones that did not hold, the expected value, the actual value, and the location are shown underneath.

The test window. Results are grouped by source, with the result at the right edge

Passing source to the test tool returns the same content as JSON. For how to write it, see the MCP server page.

Passing the location of a Unity project in projectPath also writes the results into that project. Go back to Unity and reopen the window to see them there.

How to choose itPass fast to the test tool. It can only be chosen from the MCP server
When to reach for itWhile you are still iterating. It is fast, so the round of writing a test, running it and fixing what it caught is shorter
The question it answersWhether the rules you wrote are right. It does not answer whether the compiled form is right, because that form is never produced
When other behaviours are passed along with itThis path does not place them in the same world, so sources declaring more than one behaviour make every test not-runnable. A field that should have been connected is never left empty for the run
Calls it does not modelThey come back as not-runnable. It never quietly hands back a default value
Field initializersThey run when the behaviour is created. Calls this path models, such as new Vector3(1f, 2f, 3f) and Vector3.zero, run just as on the default path
Initializers that read constants not modelledA field initializer that reads a constant this path does not model, such as Color.gray or Vector3.up, can’t be worked out here, and every test of that behaviour comes back not runnable. The detail says that a field initializer is the cause and that the test can be run on the default path
Reactive attributes[Reactive], [Computed], [Effect] and [On] run at the same entry points as on the default path, in the same order, comparing values the same way. For sources where the compiler cannot work out which entry points get the updates (more than one Behaviour, or sources that do not compile), the tests never run without the updates; every one comes back “not runnable”
static fieldsEach test starts from the values the initializers left (as on the default path). This path can put back only numbers, strings, enums, structs holding only those, and arrays of them. When a static field starts out holding anything else (a Vector3, say), only the first test runs, and the rest come back “not runnable”, naming the field
The runtime type of an enumA test that asks an enum you declared, assigned to an object, for its runtime type, and a test that matches by type where the answer differs from the default path (o is int, for example), are “not runnable” on this path. On the default path the enum is held as an integer, and its type is the integer’s (Int32)
async FrameTask methodsWhen the sources declare an async FrameTask method, every test is “not runnable”. Run them on the default path
Tests that take a long timeThis path has a step limit of its own, and going past it makes the test not-runnable. It counts differently from the default path, so a test can be not-runnable on one path and pass on the other
Calls nested too deeplyA nesting deeper than this path has room for (a recursion that never ends, an [Effect] that calls Start(), events that start each other without end) is “not runnable” (This test nested too deeply) on any machine. The default path stops the same code (“halted”), or makes it “not runnable” once events nest more than 64 deep
Events that hand over a playerA case in which Mimic would run OnPlayerJoined, OnPlayerLeft, OnOwnershipTransferred or OnOwnershipRequest on a Behaviour that declares it is “not runnable”. This path cannot hand over the same player object the test holds, so player == me would be false. OnPostSerialization runs
Type name and IDA test that calls GetUdonTypeName or GetUdonTypeID is not runnable on this path. It runs on the default path (Type name and ID)
Which path ranThe result’s origin says so. Read it before reading a failure

The results of a run are placed in the project’s Library/Tsukimi/last-test-run.json. Library is a place Unity rebuilds, so it goes into neither version control nor what you ship.