Chapter 13 · Running it
Testing your service
Scenario: the airtime service from chapter 7 needs tests that run on every go test: on a laptop, in CI, with no Blnk, Docker or network.
humanblnktest starts an in-memory Blnk for each test and gives you a humanblnk.Client on it. Your code runs exactly as in production: the same humanblnk, the same Blnk client, the same rules, against a Blnk that lives in the test's memory and disappears when it ends.
hb := humanblnktest.New(t) // a fresh, empty Blnk, closed when t ends
ada := hb.Customer(t, "NGN") // an owner and their wallet
hb.Fund(t, ada, "5000") // a deposit from @World
air := airtime.New(hb.Holds, ...) // hb embeds a *humanblnk.Client (hb.Client)
hb.Available(t, ada) // "4000.00"Step 1: fake what isn't yours
The airtime package depends on two things: humanblnk's holds, and a provider. The holds are real, from the in-memory Blnk. Only the provider is faked: it answers however the test says and counts its calls.
type provider struct {
err error
calls int
}
func (p *provider) Buy(context.Context, string, string, money.Money) error {
p.calls++
return p.err
}Step 2: test each outcome
A table of what the provider answers, and what Ada can spend afterwards. Each case gets its own Blnk, so cases never see each other's money:
func TestBuy(t *testing.T) {
for _, c := range []struct {
name string
answer error
wantErr error
available string // what Ada can spend afterwards
}{
{"delivered", nil, nil, "4000.00"},
{"declined", airtime.ErrDeclined, airtime.ErrDeclined, "5000.00"},
{"no answer", errors.New("timeout"), airtime.ErrPending, "4000.00"}, // still held
} {
t.Run(c.name, func(t *testing.T) {
hb := humanblnktest.New(t) // a fresh, empty Blnk for each case
ada := hb.Customer(t, "NGN")
hb.Fund(t, ada, "5000")
air := airtime.New(hb.Holds, &provider{err: c.answer}, "vtpass")
_, err := air.Buy(ctx, airtime.Purchase{
ID: "air_1", Wallet: ada, Phone: "08031234567", Amount: humanblnktest.Money(t, "1000", "NGN"),
})
if !errors.Is(err, c.wantErr) {
t.Fatalf("Buy: %v, want %v", err, c.wantErr)
}
if got := hb.Available(t, ada); got != c.available {
t.Fatalf("Ada can spend %s, want %s", got, c.available)
}
})
}
}Step 3: test the retry
The bug that costs money is a retry that charges twice. The in-memory Blnk refuses a reused reference the way Blnk does, so this tests the real thing:
func TestBuyIsSafeToRetry(t *testing.T) {
hb := humanblnktest.New(t)
ada := hb.Customer(t, "NGN")
hb.Fund(t, ada, "5000")
air := airtime.New(hb.Holds, &provider{}, "vtpass")
p := airtime.Purchase{ID: "air_1", Wallet: ada, Phone: "08031234567", Amount: humanblnktest.Money(t, "1000", "NGN")}
first, err := air.Buy(ctx, p)
if err != nil {
t.Fatal(err)
}
again, err := air.Buy(ctx, p) // the same purchase, as a retry sends it
if err != nil || again != first {
t.Fatalf("retry: %q %v, want the same hold %q", again, err, first)
}
if got := hb.Available(t, ada); got != "4000.00" {
t.Fatalf("Ada can spend %s after a retried Buy, want 4000.00", got)
}
}Step 4: test the refusal
func TestBuyWithoutFunds(t *testing.T) {
hb := humanblnktest.New(t)
ada := hb.Customer(t, "NGN") // never funded
p := &provider{}
air := airtime.New(hb.Holds, p, "vtpass")
_, err := air.Buy(ctx, airtime.Purchase{ID: "air_1", Wallet: ada, Phone: "08031234567", Amount: humanblnktest.Money(t, "1000", "NGN")})
if !errors.Is(err, humanblnk.ErrInsufficientFunds) {
t.Fatalf("Buy: %v, want ErrInsufficientFunds", err)
}
if p.calls != 0 {
t.Fatalf("the provider was called %d times for an unpaid purchase", p.calls)
}
}The whole test
// Chapter 13: the airtime service from chapter 7, tested against an
// in-memory Blnk. Run it with go test ./docs/examples/13-testing: no Blnk,
// Docker or network needed.
package airtime_test
import (
"context"
"errors"
"testing"
"github.com/otyang/humanblnk"
"github.com/otyang/humanblnk/docs/examples/07-airtime/airtime"
"github.com/otyang/humanblnk/humanblnktest"
"github.com/otyang/humanblnk/money"
)
var ctx = context.Background()
// provider is a fake airtime provider: it answers with err, and counts the
// calls so a test can check nothing was sent twice.
type provider struct {
err error
calls int
}
func (p *provider) Buy(context.Context, string, string, money.Money) error {
p.calls++
return p.err
}
func TestBuy(t *testing.T) {
for _, c := range []struct {
name string
answer error
wantErr error
available string // what Ada can spend afterwards
}{
{"delivered", nil, nil, "4000.00"},
{"declined", airtime.ErrDeclined, airtime.ErrDeclined, "5000.00"},
{"no answer", errors.New("timeout"), airtime.ErrPending, "4000.00"}, // still held
} {
t.Run(c.name, func(t *testing.T) {
hb := humanblnktest.New(t) // a fresh, empty Blnk for each case
ada := hb.Customer(t, "NGN")
hb.Fund(t, ada, "5000")
air := airtime.New(hb.Holds, &provider{err: c.answer}, "vtpass")
_, err := air.Buy(ctx, airtime.Purchase{
ID: "air_1", Wallet: ada, Phone: "08031234567", Amount: humanblnktest.Money(t, "1000", "NGN"),
})
if !errors.Is(err, c.wantErr) {
t.Fatalf("Buy: %v, want %v", err, c.wantErr)
}
if got := hb.Available(t, ada); got != c.available {
t.Fatalf("Ada can spend %s, want %s", got, c.available)
}
})
}
}
// A retried Buy charges once: Place returns the same hold, and a full
// capture can't move the money twice.
func TestBuyIsSafeToRetry(t *testing.T) {
hb := humanblnktest.New(t)
ada := hb.Customer(t, "NGN")
hb.Fund(t, ada, "5000")
air := airtime.New(hb.Holds, &provider{}, "vtpass")
p := airtime.Purchase{ID: "air_1", Wallet: ada, Phone: "08031234567", Amount: humanblnktest.Money(t, "1000", "NGN")}
first, err := air.Buy(ctx, p)
if err != nil {
t.Fatal(err)
}
again, err := air.Buy(ctx, p) // the same purchase, as a retry sends it
if err != nil || again != first {
t.Fatalf("retry: %q %v, want the same hold %q", again, err, first)
}
if got := hb.Available(t, ada); got != "4000.00" {
t.Fatalf("Ada can spend %s after a retried Buy, want 4000.00", got)
}
}
// A customer who can't pay gets humanblnk's error, and nothing is sent.
func TestBuyWithoutFunds(t *testing.T) {
hb := humanblnktest.New(t)
ada := hb.Customer(t, "NGN") // never funded
p := &provider{}
air := airtime.New(hb.Holds, p, "vtpass")
_, err := air.Buy(ctx, airtime.Purchase{ID: "air_1", Wallet: ada, Phone: "08031234567", Amount: humanblnktest.Money(t, "1000", "NGN")})
if !errors.Is(err, humanblnk.ErrInsufficientFunds) {
t.Fatalf("Buy: %v, want ErrInsufficientFunds", err)
}
if p.calls != 0 {
t.Fatalf("the provider was called %d times for an unpaid purchase", p.calls)
}
}Run it:
go test ./docs/examples/13-testingHow close is it to Blnk?
The in-memory Blnk copies the Blnk Core 0.15.6 behaviour humanblnk relies on:
- System accounts (
@World,@FeeIncome, clearing accounts) are made the first time a transaction names them. - A reused reference is refused, so
ErrDuplicateandErrReferenceUsedcome back as they would. - A split is held a posting at a time. If a later posting fails, the earlier holds are released at once, as Blnk does.
- Holds stay
INFLIGHT; captures and releases are transactions of their own. - Watches fire
balance.monitor:humanblnktest.WithWebhook(url, secret)sends signed webhooks to your handler, soVerifyWebhookcan be tested end to end. Delivery is best effort and gives up after 5 seconds, so a handler must answer rather than block (say, on a full channel).
humanblnk's contract tests run against both the in-memory Blnk and a real one, so a difference in what they cover fails CI. Where they don't look, it isn't Blnk:
- Nothing is ever queued: every write applies at once, and queued balances are always 0.
- Hold expiry happens on the first request after it, to the second.
- Splits take exact amounts (
precise_distribution) only, and a batch can't hold a split. Batches, commits and voids can't be previewed. effective_dateand backdating are ignored.- Reconciliation matches
one_to_oneonly.
humanblnk itself uses none of these, but a raw blnk.Client on hb.URL could.
Options
| Option | For |
|---|---|
WithCustomerLedger("Savings") | another customer ledger; its id is hb.CustomerLedgers["Savings"] |
WithRollbackDelay(d) | how long a split or batch applied directly (not through humanblnk) stays before Blnk's refund |
WithWebhook(url, secret) | send Blnk's webhooks to a test server |
WithClientOptions(...) | pass options to humanblnk.New, such as WithAccountCacheSize |
Keep a test against a real Blnk too, for the behaviour your service relies on most: a few in CI with docker compose -f deploy/docker-compose.yml up -d cost little and catch what no fake can.