Osy#betaRun it on Osyrin: osyrin.com

Reference / Testing

Stubbing your own functions in a test (fault injection)

`<Function>.Stub(λ)` makes one of the app's own functions answer a value, or throw, for the rest of a `[Test]` — wherever it is called from: the test body, another function, a workflow state, a durable step, a request the test caused. It is how a test reaches the failure paths nothing in the app's own data can trigger.

preview2 examples compiled by CItestingfunctionsreliability

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 nothing

Description#

Where does the stub apply?#

Everywhere the function runs, for the rest of the test:

called fromanswered by the stub
the test bodyyes
another function, however deepyes
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 serverno — 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 a string is refused.
  • A block needs a return, unless its last statement is a throw — { throw new X("…"); } is the whole of the fault-injection shape. A void function'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, and Assert.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#

Related

Outbound calls in a test

A `[Test]` answers an outbound call from a stub — `Http.<Verb>.Stub(url => …)` — or, when the test says so with…

[Test] / [TestFixture]

A test is an ordinary function marked [Test]. It runs against a throwaway clone of the app, so it may create rows and…

Testing (real app, real data, real rules)

A test in Osy# is not a unit test with the world mocked out. It is your app, running against its own throwaway…

Assert

The assertions a test makes. Beyond the usual equality and null checks there are comparisons (Greater, Less, InRange)…

Workflow.Once (run a step at most once)

Run something at most once per execution of its body, however many times a crash, a retry or a resume makes that…