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#).
Syntax
Section titled “Syntax”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; }}The test file
Section titled “The test file”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.
Tests left in the world-bound file
Section titled “Tests left in the world-bound file”| What happens | A 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 warning | inspect reports TUKI0117: an error (compiles: false) for a test that calls Assert or Mimic, and a warning for a test that calls neither (Error) |
Declaring a test method
Section titled “Declaring a test method”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.
Assert
Section titled “Assert”Checks are written with the static methods on Tsukimi.Assert. Six are available today.
Assert.IsTrue / Assert.IsFalse
Section titled “Assert.IsTrue / Assert.IsFalse”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.
Assert.AreEqual / Assert.AreNotEqual
Section titled “Assert.AreEqual / Assert.AreNotEqual”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.
Assert.IsNull / Assert.IsNotNull
Section titled “Assert.IsNull / Assert.IsNotNull”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.
Forms with a message
Section titled “Forms with a message”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.
Restriction on where it can be used
Section titled “Restriction on where it can be used”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.
Checking kernel computations
Section titled “Checking kernel computations”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.
Checking sync
Section titled “Checking sync”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.
| Syntax | Description |
|---|---|
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.SetActive | When 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 values | The 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) |
A value written by another player
Section titled “A value written by another player”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.
Reordering
Section titled “Reordering”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 it | The first statement of the test. Placing it later is a compile-time error |
| When it doesn’t hold | The result shows which ordering it failed to hold under |
| Running time | Every ordering is run, so it takes longer than a test without it |
| Tests with nothing to reorder | There’s nothing to check, so it can’t be run |
| Limit per point | At most 24 orders at one point. The test becomes not runnable as soon as it reaches a point with more. |
| Limit per test | At 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 counted | Each 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 limit | 4 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 |
What it can check
Section titled “What it can check”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 reproduce | What follows |
|---|---|
| Network delay | The time until arrival can’t be measured |
| Send frequency | How many times the runtime actually sends is unknown |
| Values partway through interpolation | The intermediate values of Linear and Smooth don’t appear |
| Network sends to other players | A 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 send | The 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 has | Mimic.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 time | Reading it is “not runnable” (Code that reads the server time) |
Order of OnPlayerJoined and OnPlayerLeft | The order across players, and on a newcomer against the values arriving, is fixed here (the Mimic.Join() row). The runtime fixes neither |
OnMasterTransferred | Not raised |
Other Behaviours passed to the same run
Section titled “Other Behaviours passed to the same run”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.
using Tsukimi;
public class Lamp : TsukimiBehaviour{ public int lit;
public void TurnOn() { lit = 1; }}using Tsukimi;
public partial class Switch : TsukimiBehaviour{ public Lamp lamp; // wired to the Lamp above
public void Press() { lamp.SendCustomEvent(nameof(Lamp.TurnOn)); }}using Tsukimi;
public partial class Switch{ [TsukimiTest] public void PressingItLightsTheLamp() { Press(); Assert.AreEqual(1, lamp.lit); // the Lamp really ran }}What happens on a wired Behaviour
Section titled “What happens on a wired Behaviour”| The call | Runs there and then |
| The other Behaviour’s fields | They are its own. Reads and writes reach its heap |
this, gameObject and transform inside its code | Mean that Behaviour |
GetProgramVariable and SetProgramVariable | Read and write the values on that Behaviour’s heap as they stand |
| A field that was not wired | Comes back as not runnable rather than being guessed at |
Ways that are not wired
Section titled “Ways that are not wired”| Syntax | Consequence |
|---|---|
| A field declared with the base type | There is no way to tell which Behaviour it means |
| A field declared with a type that two Behaviours in the run share | Which of the two it means is not decided |
| A Behaviour held in an array | Not wired |
| A Behaviour not handed to the same run | No instance of it exists |
| Code in the other Behaviour that calls a synchronization API | RequestSerialization, Networking.SetOwner and Mimic inside it cannot run. Its synced fields can only be read and written as plain fields |
How execution works
Section titled “How execution works”| Per test | Each 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 initializers | They 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 Start | They 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 directly | A 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 Start | The 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 hold | That test is cut short, and later Asserts in the same test don’t run |
Reading the results
Section titled “Reading the results”Results fall into 4 kinds.
| Result | Meaning |
|---|---|
| Passed | Ran to the end, and every Assert held |
| Failed | An Assert did not hold. The expected value, the actual value, and the source location are shown |
| Halted | It halted partway through execution. A form such as division by zero that halts at the same place in the runtime too |
| Not runnable | It 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).
Answers it will not guess
Section titled “Answers it will not guess”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.
| Case | Notes |
|---|---|
Asking about players before Mimic.Join() | The table under Checking sync above |
| The owner after the owner left | With two or more players left (the Mimic.Leave(player) row) |
| Server time | Networking.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 deep | An 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 NaN | The answer differs between machines |
SendCustomEvent and SendCustomEventDelayedFrames with a null name | |
InteractionText read before the code writes it | Its starting value is set in the Inspector |
Vector3.normalized of a vector whose length is exactly 0.00001 | The point where Unity switches between dividing and returning zero |
Code that reads the server time
Section titled “Code that reads the server time”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.
Where it follows the runtime’s rules
Section titled “Where it follows the runtime’s rules”| An event sent by a name held in a variable | Reaches 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 array | An 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 integer | The fraction is dropped. It stops (“halted”) when the result does not fit or the value is NaN |
| Field initializers | It 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 PC | Mathf.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 steps | Other Unity math with several steps |
AreEqual on equal values of different types | When 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)) |
Cost limits
Section titled “Cost limits”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); }}What the result reports
Section titled “What the result reports”| Key | Meaning |
|---|---|
steps | The number of instructions executed |
externCalls | The number of extern calls |
estimatedMs | An estimate of the running time in milliseconds, from the two above times a unit price each |
suggestedLimit | A 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 absent | It means that way of running does not count cost. It does not mean the cost is zero |
| What the cost covers | The cost of the path the test took. Not the cost of actually playing |
| The Unity window | It appears to the left of each test result, in the form 87 ext / 0.0092 ms |
How to write a limit
Section titled “How to write a limit”| Syntax | The 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 CostLimit | That item has no limit |
| When a limit is exceeded | The test fails. failureKind becomes overLimit, expected holds the limit and actual the measured value |
| When an Assert fails first | The Assert failure is reported as it is. Limits are not looked at |
When steps is given a value at or above the step limit | It is a limit that can never be exceeded, so the test cannot be run (Step limit) |
| Calls the estimate does not hold for | Creating 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 initializers | When 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 |
Case limit and number of cases
Section titled “Case limit and number of cases”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.
| Arguments | Default value | Values that can be written | Description |
|---|---|---|---|
maxSteps | 1,000,000 | 1 to 100,000,000 | The number of steps 1 case may run. The value you write goes into the attribute’s MaxSteps property (0 when left out) |
seeds | 1 case when left out | 1 to 1,000 | The 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) |
Step limit
Section titled “Step limit”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 limit | It stops partway and comes back not runnable. The detail gives the limit and says that maxSteps can raise it |
| Why it is not a failure | A 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 sequence | Split 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 running | The 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) |
Setting the limit
Section titled “Setting the limit”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); }}One case per seed
Section titled “One case per seed”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 names | MethodName(seed=3). The example above gives 200 cases, from HighestIsNeverBelowACard(seed=0) to HighestIsNeverBelowACard(seed=199) |
| State of each case | Like a test of its own, each case starts on a fresh instance and heap. The step limit is also counted per case |
Together with maxSteps | Allowed. The limit applies to each case |
| Building the input | Build 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 fails | The case name carries the seed, so the same input can be built again |
Argument errors
Section titled “Argument errors”| Syntax | Description | Error | Reason | Alternative |
|---|---|---|---|---|
[TsukimiTest(maxSteps: 0)] | maxSteps is out of range | TUKI0133 | by design | Write a value in range, or leave the argument out |
[TsukimiTest(seeds: 1001)] | seeds is out of range | TUKI0133 | by design | Write a value in range, or leave the argument out |
[TsukimiTest(seeds: 10)] public void M() | The method with seeds does not take 1 int argument | TUKI0133 | by design | Make it M(int seed), or remove seeds |
How to run
Section titled “How to run”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.
From the Unity window
Section titled “From the Unity window”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.

From the MCP server
Section titled “From the MCP server”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.
Running as plain C#
Section titled “Running as plain C#”| How to choose it | Pass fast to the test tool. It can only be chosen from the MCP server |
| When to reach for it | While 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 answers | Whether 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 it | This 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 model | They come back as not-runnable. It never quietly hands back a default value |
| Field initializers | They 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 modelled | A 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 fields | Each 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 enum | A 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 methods | When the sources declare an async FrameTask method, every test is “not runnable”. Run them on the default path |
| Tests that take a long time | This 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 deeply | A 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 player | A 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 ID | A test that calls GetUdonTypeName or GetUdonTypeID is not runnable on this path. It runs on the default path (Type name and ID) |
| Which path ran | The result’s origin says so. Read it before reading a failure |
Where the results are kept
Section titled “Where the results are kept”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.