Osy#betaRun it on Osyrin: osyrin.com

Reference / Testing

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 `[AllowNetwork("host")]`, makes it for real against the live endpoint, which is how a test proves the one thing a green suite otherwise cannot: that the call your app depends on actually works. A call to another machine with neither is refused; calls to localhost always go.

preview6 examples compiled by CItestinghttpintegration

Summary#

A [Test] runs with the same capabilities the app has, with one rule added: it calls no other machine unless it says so. An Http.Get(...) — or a method on a typed client class, which is only Http.* one level down — is answered by a stub when the test registered one, made for real when the test carries [AllowNetwork("host")] naming its host ([AllowNetwork] — a test that calls another machine), and otherwise REFUSED with a sentence naming the URL and both ways out. A call to localhost always goes. So a test marked [AllowNetwork] that calls your fetch function and reads back what it stored is telling you about the real endpoint.

That matters more than it sounds, because it is the one thing the rest of the suite cannot tell you. Every other check — compile, lint, render, assert — passes just as happily when the URL is dead, the API key is missing or the response shape changed. A green suite is not evidence that the endpoint works unless a test called it.

When you want a call answered from inside the test instead — offline, deterministic, or shaped like a failure the endpoint will not produce on demand — register a stub for that verb: Http.Get.Stub(url => …).

Signature#

Http.<Verb>.Stub(url => <HttpResponse>);                     // Get · Post · Put · Patch · Delete
Http.Post.Stub((url, body) => <HttpResponse>);               // the second parameter is the REQUEST body
Http.Post.Stub((url, body, headers) => <HttpResponse>);      // the third is what the call SENT
Secret.<Name>.Stub("<value>");                               // a declared secret's value, for this test

Description#

An app that reads a value from outside has two halves, and they fail differently:

halfwhat breakswhat catches it
the CALLa dead URL, a moved path, a missing key, a rate limitonly a test that makes the call
the PARSEa changed response shape, a field that is now nesteda test with a canned body, or the real one

The second half can be tested without the network: call the function that CONSUMES a response, handing it a body you wrote — or stub the fetch and let your own fetch function run over an answer you chose. The first half cannot be faked, and it is the half that fails silently in production, because a fetch that never arrives usually leaves the app showing whatever it had before.

⚠ A non-2xx is a normal return, not an exception. response.IsSuccess goes false and the body holds whatever the server sent; nothing throws, so a try/catch around the call will not see it. Assert on IsSuccess, and remember that some APIs answer HTTP 200 carrying an error document — a status check alone will not catch those, but reading back what your app STORED will.

Every outbound call is logged whatever happens — a 2xx at Information, anything else at Warning, with the method, the URL (query redacted), the status and the duration. osy logs is where a failing call explains itself.

What a failing test says about the network#

Every outbound call the test caused is also collected for the duration of that ONE test and attached to its report when it fails — a green verdict stays one line, because nobody reads the network trace of a test that passed. It is on the red one that "the fetch 404'd" and "the fetch has not arrived yet" are otherwise the same sentence.

  • Both sides of the wire are in it. A call your test body makes and a call the SERVER makes answering a request your test caused — a Ui.Click, an Api.* — land in the same journal. Most of what a UI test does is press buttons, so this is usually the half that has the answer.
  • A call that never got an answer is in it too, marked FAILED: (DNS, connect, TLS, a timeout, a body over the cap) or BLOCKED: (a target the egress guard refuses — a loopback or internal address is the common one). Those are the outcomes most easily mistaken for "it has not finished yet".
  • Addresses are redacted: scheme, host, port and path only. A query string is the routine carrier of an API key and a verdict gets quoted, pasted and committed.

Stubbing one verb#

Http.<Verb>.Stub(λ) registers the lambda as the deterministic implementation of that verb for the rest of the test. It is written as a statement, like any other stub:

  • The lambda takes the URL, and optionally the request body (null for Get and Delete) and the request headers — one signature for every verb, so Http.Post.Stub((url, body) => …) reads the same way as Http.Get.Stub(url => …). Each parameter is optional from the right: take one, two or three.
  • It returns an HttpResponse — new HttpResponse { StatusCode = 404, Body = "" }.
  • IsSuccess is derived from the status you set, not taken from the object. A stub cannot claim a response the real client could never produce, so a test cannot assert that a 500 succeeded and pass.
  • A registration in a [TestFixture] reaches every test that declares it, and a .Stub later in a test body overrides it — last registration wins.
  • The stubbed call still lands in the run's outbound journal, marked (stubbed). A suite whose calls are all answered inside it must still be able to say what it would have called, or the stub becomes the new place a wrong URL hides.
  • It is per verb. Http.Stub(…) is refused: Http has five of them and there is nothing to imply.
  • It is test-only. A .Stub outside a [Test] or [TestFixture] is refused at compile, and the runtime refuses to register one on a run that is not a test. In app code the call would never leave the process, and the app would act on a reply nobody sent. A model call's stub is refused the same way.

What did my code actually send?#

A stub pins what the endpoint answers. The other half of the same test is what your code sent — and for an outbound call that is mostly its headers: the Authorization you built from a secret, an Idempotency-Key you derived, the content negotiation you asked for. The third lambda parameter is that, as a Dictionary<string, string>:

using Osysharp.Http;

[Test]
void The_charge_call_carries_the_key_and_asks_for_json() {
  Http.Post.Stub((url, body, headers) => {
    Assert.Equal("https://pay.test/charge", url);
    Assert.Equal("Bearer sk-test", headers["Authorization"]);
    Assert.Equal("application/json", headers["Accept"]);
    return new HttpResponse { StatusCode = 200, Body = "{\"id\":\"ch_1\"}" };
  });

  var headers = new Dictionary<string, string>();
  headers.Add("Authorization", "Bearer sk-test");
  headers.Add("Accept", "application/json");
  var r = Http.Post("https://pay.test/charge", "{\"amount\":100}", "application/json", headers);

  Assert.Equal(200, r.StatusCode);
}
  • Header names match whatever their case, because HTTP header names are case-insensitive everywhere else: the client sends the spelling you wrote and a server matches it without regard to case. headers["AUTHORIZATION"] finds a header you sent as authorization.
  • A call that sent no headers hands an empty map, never null — headers.Count is 0, and asking is a reasonable question about a request that carried none.
  • A bodyless verb still reaches its headers. Http.Get.Stub((url, body, headers) => …) gets null for the body and the real headers, so a Get and a Post read the same way.

⚠ This is the assertion that catches the bug the response half cannot. A wrong Authorization is not subtle — it is every call failing in production — and a test that only pins the answer will be green the whole time.

⭐ A typed client class needs nothing extra, and that is the point of it being a class. Its methods call Http.*, so stubbing the verb answers every one of them at once — there is no second stub surface, and no way for the two to disagree about what a call returned. What you answer is the response BODY the client will parse, which is also what makes the stub a test of your own parsing rather than of a mock. A stub body may be a block when it needs statements: Http.Post.Stub((url, body) => { … return new HttpResponse { StatusCode = 200, Body = "…" }; }). It works from inside a workflow body as it does anywhere else.

What carries out of a stub body#

A stub body captures by value. The names it can see from the enclosing test are snapshotted when the stub is registered, and the frame the body runs in is thrown away when it returns — so rebinding one of those names does not reach the test:

var calls = 0;
var seen  = new List<string>();

Mailer.SendEmail.Stub((key, body) => {
  calls = calls + 1;      // ⛔ REFUSED — this write is discarded; `calls` is still 0 afterwards
  seen.Add(key);          // ✅ the LIST is the same object, so the test sees this
  new SendLog { Key = key };   // ✅ a ROW is durable — count it afterwards
  return new SendEmailResponse { MessageId = "stub-" + key };
});
  • Mutating a captured object works — a List, a Dictionary, a class instance. The snapshot holds the same object, so what the stub does to it is what the test reads back.
  • Writing a row works, and is the sturdiest of the three: assert the row.
  • Assigning to a captured variable does not, and the compiler refuses it rather than letting the write vanish. Osy# cannot capture by reference: a call may suspend and resume in a later deployment, where the frame being written back into no longer exists — the same reason out/ref parameters are refused.
  • A local the stub body declares itself is ordinary and unaffected.
  • Assigning a captured variable AFTER the stub is registered does not reach the stub either, for the same reason, and is refused too. To walk one call through several answers, stub it again after each change — the fresh stub captures the new value:
Http.Get.Stub((url, headers) => Answer("CREATED"));
Assert.Equal(PaymentState.Pending, Check());
Http.Get.Stub((url, headers) => Answer("PAID"));      // not `status = "PAID";` — the first stub would never see it
Assert.Equal(PaymentState.Paid, Check());

Best of all, where the shape allows it, assert the OUTCOME the call produced rather than the number of calls — the row that changed, the field that was set. A call count is a fact about the plumbing; the outcome is the thing the test is for.

⛔ A STUB REPLACES THE TRANSPORT, SO NOTHING A RETRY WOULD DO HAPPENS AGAINST ONE. A stub answers the verb directly: there is no request to fail, so a stubbed call is made ONCE however the caller retries, and a stub that throws propagates on the first attempt.

That is the contract, and it decides what a test of a retrying caller should assert. A test cannot prove that a retry happened by stubbing it, and one shaped to look as if it did is asserting the stub. Assert instead the thing a retry can actually break — that a repeated request is SAFE: send a stable idempotency key, stub the send, and assert the KEY. A retry that a workflow performs is a durable step, and Backoff (retry policy) is its policy.

⚑ A suite whose every call is stubbed cannot fail when the endpoint does — which is the failure this page exists to name. Stub the calls whose subject is the PARSE, and leave at least one test that really calls out, marked [AllowNetwork("host")] ([AllowNetwork] — a test that calls another machine). The mark is the point: a real call is now something a test says it makes, rather than something any test might do by accident.

Giving a secret its value#

The call a test stubs usually carries a credential, and a test app has none: .secrets is never committed, so Secret.X in a test reads nothing and fails ("has no value in this application"). Secret.X.Stub("…") gives a declared secret its value for the rest of the test — the same shape as Http.Post.Stub, and a value rather than a lambda, because a secret is read, not called:

using Osysharp.Http;

app.Secrets = [ new Secret("LedgerKey") ];

HttpResponse Balance() {
  var headers = new Dictionary<string, string>();
  headers.Add("Authorization", "Bearer " + Secret.LedgerKey);
  return Http.Get("https://ledger.test/balance", headers);
}

[Test]
void the_balance_call_carries_the_key() {
  Secret.LedgerKey.Stub("lk-test");
  Http.Get.Stub((url, body, headers) => {
    Assert.Equal("Bearer lk-test", headers["Authorization"]);
    return new HttpResponse { StatusCode = 200, Body = "{\"balance\":12}" };
  });

  Assert.Equal(200, Balance().StatusCode);
}
  • It is what lets a kit that needs a credential test itself — a warehouse, a payment provider: its test app stubs the secret and the API, and the whole conversation runs offline (the Databricks and Snowflake kits' tests do this).
  • A package's own code reads it only if the app granted it — use P { secret "X"; } — exactly as in production; a stub is the app's credential, not a way round the grant.
  • A [TestFixture] stub reaches its tests, and a later .Stub in a test wins.
  • A stubbed secret is SET: Security.IsSecretSet("X") answers true under Secret.X.Stub(…), so the usual Security.IsSecretSet("X") ? Secret.X : "" takes the stubbed value — the same answer the read gives.
  • It is test-only, by the same two gates as every stub: refused at compile outside a [Test] or [TestFixture], and refused by the runtime on a run that is not a test. A secret the app does not declare is refused naming the line that declares it.

Examples#

Prove the whole path — the call, the parse and the storage — by running your own fetch and reading back the row:

using Osysharp.Http;

entity ExchangeRate {
  [Required] decimal GbpToEur;
  security { allow read, create when IsAnonymous; }
}

void RefreshExchangeRate() {
  var r = Http.Get("https://open.er-api.com/v6/latest/GBP");
  new ExchangeRate { GbpToEur = 1.17m };      // …parsed from `r.Body` in a real app
  UnitOfWork.Commit();
}

[Test]
void the_rate_fetch_stores_a_real_rate() {
  RefreshExchangeRate();                       // the app's own function: it calls out, parses, and writes
  var rate = ExchangeRate.FirstOrDefault();
  Assert.NotNull(rate);
  Assert.True(rate.GbpToEur > 0m, "a stored rate must be a real number");
}

Ask the endpoint what it actually answers, when you are not sure it is alive or keyless:

using Osysharp.Http;

[Test]
void the_endpoint_is_reachable_and_keyless() {
  var r = Http.Get("https://open.er-api.com/v6/latest/GBP");
  Assert.True(r.IsSuccess, "expected a 2xx");
  Assert.Contains("\"rates\"", r.Body);        // a 200 carrying an error document would fail HERE, not above
}

Pin the response so the PARSE is asserted offline — and so the failures the endpoint will not perform on demand can be tested at all:

using Osysharp.Http;

entity ExchangeRate {
  [Required] decimal GbpToEur;
  security { allow read, create when IsAnonymous; }
}

void RefreshExchangeRate() {
  var r = Http.Get("https://open.er-api.com/v6/latest/GBP");
  if (!r.IsSuccess) { Log.Warning("rate fetch failed with {Status}", r.StatusCode); return; }
  new ExchangeRate { GbpToEur = 1.17m };      // …parsed from `r.Body` in a real app
  UnitOfWork.Commit();
}

[Test]
void a_good_response_is_parsed_and_stored() {
  Http.Get.Stub(url => new HttpResponse { StatusCode = 200, Body = "{\"rates\":{\"EUR\":1.17}}" });
  RefreshExchangeRate();
  Assert.NotNull(ExchangeRate.FirstOrDefault());
}

[Test]
void a_rate_limit_stores_nothing() {
  Http.Get.Stub(url => new HttpResponse { StatusCode = 429, Body = "slow down" });
  RefreshExchangeRate();
  Assert.Null(ExchangeRate.FirstOrDefault());   // the guard held: no half-written row
}

⚠ A test that calls out depends on somebody else's uptime. That is the right trade for the one or two tests that exist to prove the integration, and the wrong one for a suite of thirty. Keep the network in the tests whose subject IS the network, stub the rest, and test everything downstream of the response against a body you supply.

A typed client's method stubs identically from inside a workflow step — an enter { }, an on <Event> handler, a plain function either one calls. ⭐ Because a client is an ordinary class over Http.*, there is nothing extra to stub: you stub the VERB, and every client method built on it is answered at once. Assert through a DURABLE FIELD (the row a workflow body actually writes), not a captured closure: a workflow's enter { } is not a function anybody called, so there is no caller frame for a captured-variable rebind to reach even in principle — see [[#what-carries-out]] above for why a captured counter never works and a captured object or a written row always does.

using Osysharp.Workflow;

using Osysharp.Http;

// `use` is a MANIFEST declaration — it belongs in your app.osy, not in a model file.
app Conversions {
  model "model/**/*.osy";
  use Osysharp.Http;
  use Osysharp.Workflow;
}

class RateResult { public decimal Rate; }

public class Rates {
  public string BaseUrl = "https://rates.example";

  public RateResult GetRate(string from, string to) {
    var r = Http.Get(BaseUrl + "/rate?from=" + Uri.EscapeDataString(from) + "&to=" + Uri.EscapeDataString(to));
    if (!r.IsSuccess) { throw new ExternalServiceException("rate lookup failed: " + r.StatusCode); }
    return JsonSerializer.Deserialize<RateResult>(r.Body);
  }
}

enum ConversionState { Pending, Done }

entity Conversion {
  [MaxLength(10)] string FromCurrency;
  [MaxLength(10)] string ToCurrency;
  decimal? Rate;
  ConversionState Status;
  security { allow read, create, update when IsAnonymous || IsAuthenticated; }
}

workflow ConvertFlow {
  Tracks    = Conversion.Status;
  Autostart = true;
  Initial   = Pending;

  state Pending {
    enter {
      var r = new Rates().GetRate(this.Item.FromCurrency, this.Item.ToCurrency);
      this.Item.Rate = r.Rate;
      goto Done;
    }
  }

  terminal success Done { }
}

[Test]
void the_stub_answers_the_workflow_steps_call_and_the_result_lands_on_the_row() {
  // Registered BEFORE the row exists: an Autostart workflow's Initial `enter { }` runs synchronously, in the
  // SAME commit that creates the tracked row (`workflow-tracks#autostart-timing`) — so the stub must already be
  // in place by the time `UnitOfWork.Commit()` returns, not merely by the time the test body next reads.
  // ⭐ THE STUB IS ON THE VERB, not on `GetRate` — a client is a class over `Http.*`, so there is no second thing
  //    to stub and no way for the two to disagree. What you answer is the BODY the client will parse.
  Http.Get.Stub(url => new HttpResponse { StatusCode = 200, Body = "{\"rate\":0.85}" });
  new Conversion { FromCurrency = "USD", ToCurrency = "EUR" };
  UnitOfWork.Commit();
  var c = Conversion.Single(x => x.FromCurrency == "USD");
  Assert.Equal(0.85m, c.Rate);   // the ROW the step wrote, not a call count
}

See also#

Related

[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…

Http.*

Make an outbound HTTP call to a URL you build at runtime — a webhook, a third-party API, a discovered endpoint…

a typed HTTP client (a class over Http.*)

A typed HTTP client is an **ordinary class** that wraps an external API: a `BaseUrl` field, a credential field, one…

[AllowNetwork] — a test that calls another machine

A test calls no other machine unless it says so. An outbound call with no stub — `Http.*`, a typed client built on it…