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 testDescription#
An app that reads a value from outside has two halves, and they fail differently:
| half | what breaks | what catches it |
|---|---|---|
| the CALL | a dead URL, a moved path, a missing key, a rate limit | only a test that makes the call |
| the PARSE | a changed response shape, a field that is now nested | a 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, anApi.*— 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) orBLOCKED:(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
GetandDelete) and the request headers — one signature for every verb, soHttp.Post.Stub((url, body) => …)reads the same way asHttp.Get.Stub(url => …). Each parameter is optional from the right: take one, two or three. - It returns an
HttpResponse—new HttpResponse { StatusCode = 404, Body = "" }. IsSuccessis 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.Stublater 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:Httphas five of them and there is nothing to imply. - It is test-only. A
.Stuboutside 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 asauthorization. - A call that sent no headers hands an empty map, never null —
headers.Countis0, 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) => …)getsnullfor the body and the real headers, so aGetand aPostread 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, aDictionary, aclassinstance. 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/refparameters 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.Stubin a test wins. - A stubbed secret is SET:
Security.IsSecretSet("X")answerstrueunderSecret.X.Stub(…), so the usualSecurity.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#
- [Test] / [TestFixture] — declaring a
[Test]and what a fixture seeds - Http.* — the
Http.*surface itself - Testing (real app, real data, real rules) — the rest of the testing surface