Skip to content

Testing with rifttest

rifttest wires Rift into go test. It optimises for the common case: a test needs a mock API, wants it torn down automatically, and wants a failed assertion to say what actually arrived.

func TestUserLookup(t *testing.T) {
    users := rifttest.Imposter(t, rift.NewImposter("users").
        Stub(rift.OnGet("/api/users/1").
            Return(rift.OKJSON(map[string]rift.JSON{"id": 1}))))

    callSUT(t, users.BaseURL())

    rifttest.AssertReceived(t, users, rift.OnGet("/api/users/1"), rift.Once())
}

No defer, no cleanup, no port bookkeeping. The imposter is destroyed at t.Cleanup.

One engine per test binary

Starting an engine costs a runtime and its threads. Doing that per test would dominate a large suite, so the engine is created once, lazily, and shared.

Isolation comes from each test getting its own imposter on its own port, not from a fresh engine. Two imposters in one binary cannot see each other's traffic.

a := rifttest.Imposter(t, rift.NewImposter("a"))
b := rifttest.Imposter(t, rift.NewImposter("b"))
// a.Port() != b.Port(); their journals are independent

Recording is on by default

rifttest.Imposter enables request recording unless the definition already set it. An imposter you cannot assert against is rarely what a test wants, and forgetting .Record() produces a confusing empty journal rather than an error.

Assertions

Counting happens in the engine, through the same predicate evaluator that serves requests. An assertion therefore means exactly what an equivalent stub would have matched — including xpath, jsonpath and inject predicates, which a client-side reimplementation could not honour.

rifttest.AssertReceived(t, h, rift.OnGet("/health"), rift.Times(3))
rifttest.AssertReceived(t, h, rift.OnPost("/orders"), rift.AtLeast(1))
rifttest.AssertReceived(t, h, rift.OnGet("/x"), rift.Between(1, 5))
rifttest.AssertNotReceived(t, h, rift.OnDelete("/orders/1"))

Count matchers: Times(n), Once(), Never(), AtLeast(n), AtMost(n), Between(lo, hi).

Failure output

A failure prints the near miss the engine identified — which clauses failed, and the request's actual values — not just a count:

rifttest: imposter "users" (port 4545): expected exactly 1 matching request(s), got 0 of 2 recorded

  expected a request matching:
    {"equals":{"method":"GET","path":"/api/users/2"}}

  closest recorded request:
    GET /api/users/1 headers=map[Accept:application/json]

  it failed these clauses:
    wanted {"equals":{"method":"GET","path":"/api/users/2"}}
      actual: map[path:/api/users/1]

  journal:
    GET /api/users/1 headers=map[Accept:application/json]
    GET /health

An empty journal says so explicitly, and points at the usual cause — the system under test not being pointed at Handle.BaseURL().

The handle

h.Port()                       // uint16
h.BaseURL()                    // http://localhost:4545
h.URL("/api/users/1")          // joined
h.Client()                     // the underlying rift.Client
h.Recorded()                   // []rift.RecordedRequest
h.ClearRecorded()              // reset, so a later assertion counts only what follows
h.AddStub(stub)                // append to a live imposter
h.AddStubAt(0, stub)           // insert in front of an existing catch-all
h.SetScenarioState("s", "done")

Running against another transport

The tests do not change; only where the engine comes from:

func TestMain(m *testing.M) {
    eng, _ := rift.Connect("http://localhost:2525", rift.RemoteOptions{})
    rifttest.Engine(nil, rifttest.Options{Client: eng})
    code := m.Run()
    rifttest.Close()
    os.Exit(code)
}

When to call rifttest.Close

The default in-process engine does not need it — the test binary's exit releases everything. Call it from TestMain when the engine owns a child process or a remote connection that should not outlive the suite.

Staying green without a native library

rifttest.Engine(t, rifttest.Options{SkipIfUnavailable: true})

Skips instead of failing when no engine can be started — useful for a suite that should stay green on a contributor's machine while remaining required in CI. See Native library & CI.