Skip to main content
How to Test Code That Talks to a Bank
AI & Technology

How to Test Code That Talks to a Bank

11 min read
839 views
Share:

The first time one of my payment services took real money out of a real account during a test run, I was standing at a whiteboard explaining to a new hire why our test suite was "totally safe." It was not totally safe. A misconfigured environment variable pointed our integration tests at the bank's production endpoint instead of the sandbox, and about ninety seconds later we had initiated four small transfers to a test IBAN that turned out to belong to an actual dormant account. We got the money back. My pride took longer to recover.

Testing code that talks to a bank is its own discipline, and most of what you learned testing a CRUD app actively works against you here. The failure modes are different, the cost of a bad test is different, and the thing you are actually trying to prove is different. This is how I think about it after a decade of shipping software that moves other people's money.

What you are actually testing

When people say "test the bank integration," they usually mean "check that we call the API correctly." That is maybe twenty percent of the job. The bank's API is the easy part; it has documentation and a schema. The hard part is everything around the call: what happens when the response never comes, what happens when it comes twice, what happens when the bank says "accepted" and then reverses it three hours later in a batch file, and whether your ledger still balances after all of that.

So I split banking tests into three buckets, and I keep them physically separate in the codebase. The first bucket proves our own logic without touching anything external. The second bucket proves we speak the bank's protocol correctly against a controlled fake. The third bucket, run rarely and deliberately, proves the whole thing works end to end against the bank's sandbox. Most teams collapse these into one soupy suite and then wonder why it is both slow and untrustworthy.

Your unit tests must never reach the network

This sounds obvious. It is routinely violated. I have inherited more than one test suite that quietly made live HTTP calls in what were labeled "unit tests," which meant the build was green only when the bank's sandbox was up, which was roughly Tuesday to Thursday. The fix is boring and non-negotiable: the code that builds a payment instruction and the code that sends it over the wire are different objects, and the first one has no idea the second one exists.

In .NET this means the HTTP client sits behind an interface, and your domain logic depends on the interface. The vast majority of your bugs live in the domain logic, not the transport, so that is where the vast majority of your fast tests belong. Here is the shape I reach for every time:

public interface IBankGateway
{
    Task<TransferResult> SubmitTransferAsync(
        TransferInstruction instruction,
        CancellationToken ct);
}

// Domain service under test. No HttpClient in sight.
public sealed class PayoutService
{
    private readonly IBankGateway _gateway;
    private readonly ILedger _ledger;

    public async Task<PayoutOutcome> PayoutAsync(Payout p, CancellationToken ct)
    {
        if (p.Amount <= 0m)
            return PayoutOutcome.Rejected("non-positive amount");

        var idempotencyKey = $"payout:{p.Id}";
        var instruction = TransferInstruction.From(p, idempotencyKey);

        var result = await _gateway.SubmitTransferAsync(instruction, ct);
        await _ledger.RecordAsync(p.Id, result.Status, result.BankReference, ct);
        return PayoutOutcome.From(result);
    }
}

Now I can write forty tests against PayoutService with a stub gateway that returns whatever I want, and none of them touch a socket. Negative amounts, duplicate payout IDs, a gateway that throws a timeout, a gateway that returns "accepted" with a null reference number because yes, one real bank did that to us for a fortnight. All of that is testable in milliseconds.

Idempotency is the whole game

If I could tattoo one lesson onto every engineer who joins a payments team, it would be this: the network will deliver your request twice, and it will hide the first response from you. A timeout does not mean the bank did nothing. It means you do not know what the bank did. Those are wildly different states, and code that treats them as the same is code that pays vendors twice.

Every mutating call to a bank needs an idempotency key that you generate and control, derived from the business event, not from a random GUID minted at send time. And then you need tests that prove the key actually protects you. The scenario I always write first: submit, simulate a timeout, retry with the same key, and assert that the ledger shows exactly one payout. If your fake gateway cannot model "I received your first request even though you never saw my answer," your fake is lying to you and your tests are theater.

A timeout is not a failure. It is a question. The only safe answer is to ask the bank what happened, using a key that lets it recognize the request you already sent.

The corollary is that "retry the failed call" is almost always wrong for writes. I have seen a well-meaning Polly retry policy wrapped around a transfer endpoint turn one intended payment into three because each attempt used a fresh key. Retries belong on reads and on idempotent writes, and the test suite is where you enforce that distinction before it enforces itself on your finance team.

Build a fake that lies the way the bank lies

A stub that always returns "success" is worse than no stub, because it gives you confidence you have not earned. The fakes worth building are the ones that reproduce the bank's specific pathologies. I keep a small in-process fake bank as a first-class part of the codebase, and I add to it every time production teaches me something new. It is one of the highest-return pieces of test infrastructure I own.

What goes into a good fake, in rough order of value:

Enjoying this article?

Get more like it in your inbox — practical engineering leadership, fintech, and AI. No spam, unsubscribe anytime.

  • Duplicate detection keyed on the idempotency header, returning the original result on replay.
  • Configurable latency and outright timeouts, so retry and cancellation paths get exercised.
  • The asynchronous reality: an endpoint that returns "pending" and a separate mechanism that later flips the state, because most bank transfers are not synchronous.
  • Realistic rejection codes with the bank's actual, unhelpful wording, including the ones that mean "try again later" versus "never try this again."
  • Malformed and partial responses, because a truncated JSON body at 2am is a production event, not a hypothetical.

The point is not fidelity for its own sake. The point is that when a new engineer writes handling code, the fake pushes back the way the real thing will. If your fake never returns a "pending" state, nobody on the team will write pending-handling code, and you will find that out during your first real settlement window.

Time is an input, so fake the clock too

Banking runs on cut-off times, value dates, and batch windows. A payment submitted at 16:59 behaves differently from one submitted at 17:01, and any test that depends on the machine's wall clock is a test that fails at midnight and passes when you rerun it in the morning, teaching everyone to rerun rather than investigate. I inject an IClock everywhere and I set it explicitly in tests. Non-negotiable.

This matters most for reconciliation, which is where banking software actually earns its keep. The bank sends you a statement or a settlement file, and your job is to match every line against what you think happened. Test this with real, ugly sample files, not with rows you hand-crafted to match. Ask the bank for a week of anonymised production files. The mismatches, rounding quirks, and mystery fee lines in those files are the exact cases your reconciliation code needs to survive, and they are impossible to imagine from scratch.

Sandbox tests: valuable, slow, and kept on a short leash

You do need tests against the bank's real sandbox, because a fake can only encode what you already understand. The sandbox catches the things you were wrong about: the header they silently require, the field length they actually enforce, the certificate rotation you missed. But these tests are slow, flaky through no fault of your own, and dependent on someone else's uptime, so I never put them in the pull-request pipeline.

They run on a schedule, nightly and on release candidates, and they are allowed to fail loudly without blocking a merge. I tag them explicitly so nobody runs them by accident. In xUnit that is a trait; the important part is that a normal build cannot touch them.

[Trait("Category", "SandboxIntegration")]
public sealed class TransferSandboxTests
{
    [SkippableFact]
    public async Task Submitting_a_valid_transfer_reaches_accepted_state()
    {
        Skip.IfNot(Config.SandboxEnabled, "Sandbox disabled for this run");

        var gateway = RealGatewayFactory.ForSandbox();
        var result = await gateway.SubmitTransferAsync(
            TestData.SmallTransfer(), CancellationToken.None);

        Assert.Contains(result.Status,
            new[] { TransferStatus.Pending, TransferStatus.Accepted });
    }
}

Notice the assertion accepts both pending and accepted. Pinning a sandbox test to one exact outcome is how you get a suite that cries wolf, and a suite that cries wolf gets ignored, and a suite that gets ignored is worse than no suite because it costs money to maintain and buys you nothing.

Money is not a double, and test data is not decoration

Two smaller hills I will die on. First: never represent money as a floating-point number, and write a test that fails loudly if anyone tries. The classic 0.1 + 0.2 example is funny until it is a rounding drift in a settlement of forty thousand line items. Use decimal in .NET, store minor units as integers where you can, and carry the currency alongside the amount as a single value type so that adding euros to pounds is a compile error rather than a silent catastrophe.

Second: your test data must include the numbers that break banks. Amounts with three decimal places for currencies that only allow two. Names with apostrophes and non-Latin characters that blow up fixed-width file formats. The transfer of exactly zero. The maximum value the field allows plus one. I keep a shared fixture of these known landmines and every new integration gets run through all of them before it ships. It takes an afternoon and it has saved me from at least three genuinely embarrassing outages.

If you cannot observe it, you cannot test it in production

The tests you write before shipping are only half the safety net. The other half is whether you can tell, at 3am, what a given payment did. I treat log lines and metrics as testable artifacts: I assert that a rejected payout emits the specific structured event my alerting depends on, because a monitoring rule that references a field the code stopped emitting is a smoke detector with the battery removed. Once, an innocuous refactor renamed a status field, and we lost payment-failure alerting for eleven days before anyone noticed. Nothing broke. That was the problem.

So write a test that says: given a failed transfer, the service records an event containing the payout ID, the bank reference, and the failure code, in the exact shape the dashboard queries. It feels pedantic. It is the difference between a five-minute incident and a five-hour one, and in this line of work five hours of not knowing where the money is will end up in a regulator's inbox.

Anselm Fowel, CTO and fintech architect
Anselm Fowel — CTO & fintech architect

The part nobody tells the new hire

Here is the thing I did not understand early on: testing banking code is not really about the bank. It is about being honest with yourself concerning what you do and do not know at every moment a request is in flight. The bank is just the harshest possible teacher of that lesson, because the gaps in your knowledge come back as real money in the wrong account with a real person's name on it. The engineers I trust with payment code are not the ones who write the most tests. They are the ones who can tell me, without hedging, exactly what their system believes after a timeout and why that belief is safe to act on. Get someone to that point and the worst they will suffer is a quiet nightly build failure instead of a phone call from the CFO. I have had both. I strongly recommend the build failure.

Enjoyed this article? Share it with others!

Share:

Get new posts in your inbox

Occasional, practical notes on engineering leadership, fintech, and building with AI. No spam, unsubscribe anytime.

Comments (0)

Leave a Comment

Comments are moderated and will appear after review.

No comments yet. Be the first to comment!

About the author

Anselm Fowel

Anselm Fowel

Chief Technology Officer & fintech architect. 16+ years leading engineering across AlliancePay, Mondu, Transalliance, Global Accelerex, and Fidelity Bank — writing here about engineering leadership, fintech architecture, and AI in production.

Read next

Subscribe to the newsletter

Practical notes on engineering leadership, fintech, and building with AI — delivered to your inbox. No spam, unsubscribe anytime.

Anselm Fowel

Chief Technology Officer | Fintech Architect | Engineering Leader

Building the future of financial technology through innovative engineering and strategic leadership.

Expertise

  • CTO Advisory
  • Fintech Architecture
  • Team Leadership
  • Technical Strategy
  • System Design

Get In Touch

[email protected]
Lagos, Nigeria

© 2026 Anselm Fowel. Crafted with passion.