Summary#
Most of what an app does wrong it does in a catch: the rollback that did not roll back, the failure that was logged
and never shown to anyone, the workflow that stayed in Running for ever. Those paths are the hardest to test, because
nothing in the app's own data makes the function they guard fail on demand.
<Function>.Stub(λ) replaces one of the app's own functions for the rest of the test. The lambda takes the
function's parameters and answers its return type — or throws, which is the point:
CountLines.Stub(() => { throw new ValidationException("the archive is offline"); });It is the same verb as a client or Http.* stub, pointed at code you wrote.
Signature#
<Function>.Stub(<parameters> => <value>); // answer a value
<Function>.Stub(<parameters> => { throw new X("…"); }); // throw
<Function>.Stub(<parameters> => throw new X("…")); // the same, as C#'s throw expression
<Function>.Stub(<parameters> => { …; return <value>; }); // a block: locals, `if`, `throw`, `return`
<VoidFunction>.Stub(<parameters> => { }); // a `void` function: do nothingDescription#
Where does the stub apply?#
Everywhere the function runs, for the rest of the test:
| called from | answered by the stub |
|---|---|
| the test body | yes |
| another function, however deep | yes |
a workflow state (enter, on) | yes |
a durable step — Workflow.Once("…", () => F()) | yes |
a query clause — items.Select(x => F(x)) | yes, once per element |
a request the test caused — Ui.Click, Api.* | yes |
| a call the page answers in the browser, without asking the server | no — stub what it reads on the server instead |
That last row is why it lives where the other stubs live: a click is answered on a different interpreter from the one running your test, and a stub that bit for a direct call but missed a click would be a trap.
Register it before the thing that calls it. An Autostart workflow's first state runs in the same commit that
creates its row, so a stub registered after UnitOfWork.Commit() is too late for it.
What does the lambda receive, and what must it answer?#
- The function's own parameters, in the order the function declares them — whatever order the caller named them in. A lambda with a different number of parameters is refused, quoting the signature it has to match.
- The function's return type. A stub for
decimal Price(string sku)that answers astringis refused. - A block needs a
return, unless its last statement is athrow—{ throw new X("…"); }is the whole of the fault-injection shape. Avoidfunction's stub needs neither. - A thrown exception reaches the caller as the real one would: its kind, its chain and its message. A
catch (ValidationException e)catches it, andAssert.Throws<ValidationException>(…)sees it.
How long does it last?#
Until the end of the test that registered it. Every test runs on its own branch with its own stub table, so a test
that stubs nothing runs the real function whatever ran before it. A later .Stub for the same function replaces the
earlier one, and a stub in a [TestFixture] reaches every test that declares the fixture — which can override it.
Can the stub call the real function?#
Yes: a call to the function made from inside its own stub reaches the real one. That is what makes the most common
fault-injection story — the first attempt fails, the retry works — a single if, rather than something a test cannot
say:
Rate.Stub(currency => {
if (failures.Count == 0) { failures.Add(currency); throw new ValidationException("the feed timed out"); }
return Rate(currency); // the real Rate
});A stub body remembers between calls through a captured OBJECT — a List declared outside it — never a captured
counter: assigning to an outer variable inside a stub is refused, because a stub captures by value
([[testing-outbound-calls#what-carries-out]]).
How many times does the body run?#
Once for each call it answers. A stub body may note the call in a captured list, call the app's own functions —
ones that write and commit included — and test what it noted earlier; each line runs once per answered call, in order.
The same holds for the block of an Assert.Throws(() => { … }).
Why is it refused outside a test?#
A function stub changes what a function does for everything that calls it. In app code that would make an
authorization helper answer true, or a charge answer "paid", in one line. So .Stub on a function is a compile error
anywhere but test code — a [Test], a [TestFixture], or any other function declared in a .test.osy file — and the
runtime refuses to register one on a run that a test did not start.
A world several tests share ("the price list answers 42 from now") is therefore written once, as a plain function in the test file, and each test calls it; the stub it registers lasts for the calling test, exactly as one written inline.
Also refused, each by name: a stub for a function that does not exist (with the nearest one that does), a [Test]
function, a generic function, and a function taking a Query<T> — that one is inlined into each caller at compile
time, so there is no call left to answer.
⚠ Only top-level functions. A method on a class and a component member are not stubbable; stub the top-level
function that calls them, or that they call.
Examples#
A nightly report that must not stay stuck when its counting fails — the failure path, proven:
using Osysharp.Workflow;
enum ReportState { Running, Done, Failed }
entity NightlyReport {
int Lines = 0;
[MaxLength(400)] string? FailureReason;
ReportState State;
security { allow read, create, update when IsAnonymous || IsAuthenticated; }
}
int CountLines() {
return 12;
}
workflow NightlyReportFlow {
Tracks = NightlyReport.State;
Autostart = true;
Initial = Running;
state Running {
enter {
try {
this.Item.Lines = CountLines();
} catch (Exception e) {
this.Item.FailureReason = e.Message;
goto Failed;
}
goto Done;
}
}
terminal success Done { }
terminal error Failed { }
}
[Test]
void a_report_that_cannot_count_records_why_and_fails() {
CountLines.Stub(() => { throw new ValidationException("the archive is offline"); }); // before the row: Autostart
new NightlyReport { };
UnitOfWork.Commit();
var report = NightlyReport.Single();
Assert.Equal(ReportState.Failed, report.State);
Assert.Contains("offline", report.FailureReason);
}
[Test]
void without_a_stub_the_real_count_runs() {
new NightlyReport { };
UnitOfWork.Commit();
Assert.Equal(12, NightlyReport.Single().Lines);
}Answer a value for every caller, and fail once before recovering:
decimal Rate(string currency) {
return 1.10m;
}
decimal ToEuros(decimal amount, string currency) {
return amount * Rate(currency);
}
[Test]
void a_stub_answers_for_every_caller() {
Rate.Stub(currency => 2m);
Assert.Equal(20m, ToEuros(10m, "GBP"));
}
[Test]
void the_first_call_fails_and_the_next_reaches_the_real_rate() {
var failures = new List<string>();
Rate.Stub(currency => {
if (failures.Count == 0) {
failures.Add(currency);
throw new ValidationException("the feed timed out");
}
return Rate(currency);
});
Assert.Throws<ValidationException>(() => ToEuros(10m, "GBP"));
Assert.Equal(11m, ToEuros(10m, "GBP"));
}See also#
- Outbound calls in a test — the same verb for what leaves the process:
Http.<Verb>.Stub, which answers every typed client method built on that verb at once - [Test] / [TestFixture] — declaring a
[Test], and what a[TestFixture]carries into it - Assert —
Assert.Throws<T>for asserting the fault reaches the caller - Workflow.Once (run a step at most once) — a durable step, which a function stub reaches like any other call
- Testing (real app, real data, real rules) — the rest of the testing surface