Platform Testing
Testing a Basis Theory integration well is not about finding one test that proves everything works. It is about building confidence in layers. Each layer of testing answers a different question, runs at a different speed, and catches a different class of problem. You combine them, and the combination is what gives you confidence to go live.
This guide is organized around the testing pyramid. Unit tests sit at the base: many of them, fast, fully isolated. Integration tests sit in the middle: fewer, slower, exercising real systems one boundary at a time. End-to-end tests sit at the top: fewest, slowest, and in a payments integration the hardest to reproduce faithfully, because a single payment passes through several independent providers that each run their own sandbox.
Once your environment is ready, the sections below walk up the pyramid, name the Basis Theory functionality relevant to each layer, and explain what a passing test at that layer actually proves.
Environment readiness
Most of the time lost early is not in the integration itself. It is in the environment not being ready, so a request fails for a reason that has nothing to do with your code. Confirm the following before you start.
We recommend a dedicated TEST tenant for each of your non-production environments. A typical setup is three TEST tenants (Dev, QA, Staging) alongside a single PRODUCTION tenant. This isolates test data across environments, reduces the risk of misconfigured API keys, and mirrors your internal environment structure for safer deployments. See Test Tenants for how test tenants behave.
Then check the request itself is set up correctly:
- The right application. Make sure the application whose key you are using is the one with the feature enabled. It is easy to enable a feature on one application and send requests with another.
- The right authorization model for the feature. Network Tokens require the classic
permissionsmodel. They cannot be added to an application using Access Rules, so use apermissions-based application (or create one) for Network Token operations. - The right key type. Use a private application key for server-side operations. A management key will be rejected by data-plane endpoints.
- The right tenant. Test data is tenant-scoped. A token created in one tenant cannot be used in a request against another, and the request will fail.
- Feature access enabled. The feature you are testing has to be turned on for the tenant.
For Account Updater and Network Tokens specifically, Onboard a Merchant walks through the prerequisites, how test and production tenants differ, and the information to gather before you start.
Unit tests
Unit tests prove your own code in isolation. You are testing your logic, not Basis Theory's behavior, so you mock the Basis Theory API and any external dependency and assert that your code does the right thing with the responses it receives.
There are two complementary places to mock. Mock at the unit level by loosely coupling your application code from external dependencies, including Basis Theory, using inversion of control and dependency injection wherever possible. Our official SDKs provide standard interfaces in many languages, and those interfaces are straightforward to mock so your tests exercise your logic rather than the network. Mock at the network level when you want to test a component against its external dependencies without calling them for real, a category of testing sometimes called acceptance testing: black box testing of a component to confirm it meets its requirements, with external calls stubbed out. Tools like Wiremock (our preferred tool) or Mock Service Worker make this straightforward. Our series of blog posts goes deeper into our testing philosophy.
Reactors
Reactor code is an ES module that exports a default function, so you can test it like any other JavaScript module. Mock the network calls your reactor makes and assert on the request it sends and the response it returns.
const fetch = require('node-fetch');
// this is a fake piece of data we expect the destination service to return
const fakeTransactionId = chance.string();
// here we mock the json response returned by node-fetch
jest.mock('node-fetch', () =>
jest.fn().mockResolvedValue({
status: 200,
json: jest
.fn()
// this mock should return a "real" response based on what your mocked service normally returns
.mockImplementation(() => Promise.resolve({ transaction_id: fakeTransactionId }))
})
);
describe('Example reactor unit test', () => {
const reactorFunction = require('./reactor');
it('should call third party service', async () => {
// this is the request we will pass into the reactor function
const reactorRequest = {
configuration: {
THIRD_PARTY_API_KEY: chance.string()
},
args: {
card_number: chance.string()
}
};
// we expect the third party service to be called by our reactor with this payload
const expectedRequestToThirdParty = {
payment_method: {
credit_card: {
number: reactorRequest.args.card_number
}
}
};
const reactorResponse = await reactorFunction(reactorRequest);
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(
'https://my.downstream.service.com/',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
// our third party service accepts the API key in an X-API-KEY header
'X-API-KEY': reactorRequest.configuration.THIRD_PARTY_API_KEY
},
body: JSON.stringify(expectedRequestToThirdParty)
}
);
// we expect our reactor to return the transactionId returned by the third party service
expect(reactorResponse).toEqual({
raw: {
transactionId: fakeTransactionId
}
});
});
});
Integration tests
Integration tests prove that your code works against a real system across a single boundary, in isolation. This is where the Basis Theory sandbox comes in. You point your code at a Basis Theory test tenant and exercise one real Basis Theory leg on its own (tokenize a card, provision a network token, run an Account Updater batch, authenticate a 3DS session, decrypt a wallet payload) using that feature's own test data. You test the same way against each external provider separately, using that provider's sandbox and its published test data.
The key word is isolation. An integration test exercises one hop and asserts it works, but it never chains hops together. A passing integration test against the Basis Theory sandbox proves the Basis Theory leg; it says nothing about your PSP's leg or the card networks' leg, because those systems never saw the request. Wiring the legs together so the output of one feeds the next is what end-to-end testing covers, in the next section.
Reactors
For a reactor, an integration test runs the reactor code against the real destination service it integrates with.
const reactorFunction = require('./reactor'); // this file exports our reactor function as an ES module
const { AuthenticationError } = require('@basis-theory/basis-theory-reactor-formulas-sdk-js');
describe('Example reactor integration test', () => {
it('should return expected response', async () => {
const actualResponse = await reactorFunction({
configuration: {
// this is a real api key for an external service called by our reactor
THIRD_PARTY_API_KEY: process.env.THIRD_PARTY_API_KEY
},
args: {
// requests to the reactor will contain a token, which will be detokenized
// into plaintext before it reaches our reactor code
// here we test with a fake plaintext card number
card_number: '4242424242424242'
}
});
// assert on whatever you expect the reactor to return, in our case we expect a transactionId to be returned
expect(actualResponse.raw.transactionId).not.toBeNull();
});
it('should throw an AuthenticationError when using an invalid API key', async () => {
await expect(
reactorFunction({
configuration: {
// pass in a fake api key that will be rejected by the third party service
THIRD_PARTY_API_KEY: chance.string()
},
args: {
card_number: '4242424242424242'
}
})
).rejects.toThrow(new AuthenticationError().message);
});
});
Proxy
To test a Proxy, point it at https://echo.basistheory.com/anything. The echo endpoint returns the exact request it received, so you can confirm what your proxy actually forwarded: the detokenized values, headers, and body. This lets you verify your detokenization expressions produce the payload you expect before you point the proxy at your real PSP.
curl 'https://api.test.basistheory.com/proxy' \
--header 'BT-PROXY-URL: https://echo.basistheory.com/anything' \
--header 'BT-API-KEY: <PRIVATE_API_KEY>' \
--header 'Content-Type: application/json' \
--data '{
"customer_reference": "test123",
"card": {
"brand": "{{ token: <TOKEN_ID> | json: \"$.card.brand\" }}",
"number": "{{ token: <TOKEN_ID> | json: \"$.data.number\" }}",
"expiration": "{{ token: <TOKEN_ID> | json: \"$.data\" | card_exp: \"MM/YYYY\" }}",
"cvv": "{{ token: <TOKEN_ID> | json: \"$.data.cvc\" }}"
}
}'
The echo response shows the request the destination received. The json object confirms the proxy detokenized the card and forwarded the real values:
{
"method": "POST",
"url": "http://echo.basistheory.com/anything",
"headers": {
"Content-Type": "application/json",
"Bt-Proxy-Hops": "1"
},
"json": {
"customer_reference": "test123",
"card": {
"brand": "visa",
"number": "4242424242424242",
"expiration": "12/2030",
"cvv": "123"
}
}
}
Tokenization
The card and card_number token types accept any card number in a test tenant, so you do not need special test cards to prove tokenization. See Card Numbers for the details.
Network Tokens
The Network Tokens sandbox returns stubbed responses without forwarding requests to the card networks. Use the documented test cards to simulate success and error scenarios.
Persist the network token id. Operations like retrieving a cryptogram, suspending, resuming, deleting, or proxying with a network token key off the id, not the card number, so store it and relate it to the right customer.
In the sandbox, card art is also simulated. The Get a Network Token Account endpoint returns placeholder images so you can build and verify your rendering before going live, and the production response is populated with the network's own assets.
Account Updater
The Account Updater sandbox returns stubbed result codes without forwarding requests to the card networks. Use the documented test cards to simulate each result code.
Verify deliberately that an update never mutates the original token. When a card is updated, Account Updater mints a new token and the result maps the original token to a new_token. Store that mapping in your test, because the same thing happens in production.
3DS
Authenticate in a test tenant using the 3DS test cards. The issuer's authentication decision is simulated in the sandbox, so a frictionless or challenge outcome in testing reflects the test card you chose, not a real issuer decision.
When you forward Basis Theory's 3DS results to your PSP, keep in mind that PSP sandboxes typically accept any syntactically valid authentication data (correct lengths and format) without checking it against a real authentication. So the forward generally succeeds on format alone, which validates your wiring but not a real liability shift. A real authentication outcome can only be confirmed in production.
Apple Pay™
There are two ways to test the Apple Pay™ leg, depending on how much of the wallet flow you want to exercise.
To prove the Basis Theory leg in isolation without an Apple device, send a pre-decrypted payload using the Testing without an Apple Pay token mechanism. No Sandbox Apple ID or device is required.
To exercise the real wallet flow, use Apple's sandbox: sign in with a Sandbox Apple ID, add an Apple sandbox card to Wallet, and trigger Apple Pay™. In a test tenant, Basis Theory decrypts the resulting token with your stored Payment Processing Certificate and returns the real sandbox card data (DPAN, expiration, cryptogram, and ECI), which you can forward to your processor's sandbox. Only use Apple's sandbox cards in test tenants; Basis Theory does not check whether a token came from a sandbox device. See Apple Pay™ Setup for certificate and merchant configuration.
End-to-end tests
End-to-end tests sit at the top of the pyramid. Where an integration test proves one hop in isolation, an end-to-end test chains the hops together and proves the handoffs between them: the output of one leg becomes the input to the next, all the way through. In a single-system application this just means driving the whole stack. A payments integration is harder, because a single payment passes through several independent providers (Basis Theory, your PSP, the card networks, and a wallet like Apple Pay) and each one runs its own sandbox that knows nothing about the others.
Why one test card will not go end to end in sandbox
Each sandbox is a simulator. It accepts a fixed set of test inputs and returns canned outputs. It does not talk to the other providers' sandboxes, and it has no knowledge of their test data. Two consequences follow:
- Test cards do not carry across providers. A card on Basis Theory's test data pages is recognized by the Basis Theory sandbox. Your PSP publishes its own test cards, and the card networks have theirs. A Basis Theory test card might happen to be accepted by another provider's sandbox, either by coincidence (well-known PANs like
4242 4242 4242 4242overlap) or because that sandbox is lenient about what it accepts, but that is not something to rely on. Use each provider's own published test data. - Some values returned in the sandbox are simulated. When you provision a network token in a test tenant, the value you get back is a mock, so your PSP's sandbox has no reason to recognize a network token that Basis Theory minted, because it never issued it. (Apple Pay is the exception: a test tenant returns the real decrypted sandbox payload, not a mock.)
None of this is unique to Basis Theory. It is how multi-party payment sandboxes work everywhere. The way to build confidence is not to hunt for a magic card that works everywhere. It is to test each leg where its data actually lives.
What you can validate in sandbox
Plenty of the end-to-end picture is provable before you go live. The trick is to chain together the hops you can, and to verify each handoff you cannot fully chain.
These legs are not isolated steps. In a real integration, they connect into a workflow, where each leg's output is the next leg's input, right up to the boundary with your PSP. You can chain the Basis Theory legs together inside a test tenant and assert the data flows correctly from one to the next; what you cannot do is feed a Basis Theory sandbox value into a PSP sandbox and expect recognition. So you chain what you own, and at the PSP boundary you split the test in two: verify your proxy produces the payload the PSP expects, and separately confirm the PSP's sandbox behaves as expected using the PSP's own test card.
The two scenarios below show this in practice.
Scenario 1: BIN-based routing
A freshly collected card is routed to a processor based on its BIN, then forwarded for authorization.
- Collect and tokenize. Any Luhn-valid card works in a test tenant, so use
4242424242424242. You get back acardtoken. - Route by BIN. Your application reads the token's BIN details to decide where to send the payment. For
4242 4242 4242 4242thecard_segment_typeisCommercial, and your routing logic sends commercial cards to Adyen. The routing decision is your application's; Basis Theory supplies the BIN enrichment it runs on. - Forward to the PSP. This is the boundary with Adyen, and you test it in two halves:
- Verify the proxy leg. Send the Proxy request to the echo endpoint and confirm the forwarded payload matches the shape Adyen expects and the detokenized card data matches your input.
- Authorize against Adyen's sandbox. A Basis Theory test card is not an Adyen test card, so use a token carrying one of Adyen's test cards (for example
4111111145551142), created during test setup or just in time, and confirm Adyen returnsAuthorised.
Scenario 2: 3DS challenge with a declined payment
A card is authenticated with a 3DS challenge, then forwarded to a processor that declines it.
- Collect and tokenize. Use
4000020000000000, the Basis Theory 3DS test card for a successful challenge. You get back acardtoken. - Run the 3DS challenge. Authenticate the token with 3DS. The challenge completes successfully and liability shifts. The issuer decision is simulated, so the outcome reflects the test card you chose, not a real issuer.
- Forward to the PSP. The boundary with Stripe, again in two halves:
- Verify the proxy leg. Send the Proxy request to the echo endpoint and confirm the forwarded payload, now carrying your 3DS authentication values, matches the shape Stripe expects.
- Authorize against Stripe's sandbox. Use a token carrying Stripe's
insufficient_fundstest card (4000000000009995) and confirm Stripe returns aninsufficient_fundsdecline.
Reactors
End-to-end tests against reactors deployed in Basis Theory can add further confidence that a reactor works in a deployed environment. These sit at the very top of the pyramid, so focus on unit and integration tests first. If you do write them, keep in mind:
- The reactor under test must be provisioned either just-in-time during the test using a management application's API key, or provisioned ahead of time and referenced by id.
- Invoking the reactor requires a private application's API key with
token:usepermission. - If your reactor expects detokenization expressions in the request, the tokens referenced in your test requests must be pre-created by your test code, which requires a public or private application's API key with
token:createpermission.
These scenarios prove every leg you own and every handoff you control. The one thing left unproven is whether all the providers agree on a single real credential, which can only happen in production.
Before you deploy to Production, work through the Production Checklist as the operational gate for going live. The stages that follow all run in production.
Smoke tests
There is one thing no sandbox can prove: that every provider in the chain agrees on a single real credential. Each sandbox only knows its own mock data, so the moment where Basis Theory, your PSP, and the networks all act on the same live card cannot be reproduced in any of them. The way to close that seam is a smoke test in production: a small, controlled set of real transactions that confirms the wiring works end to end before real customers depend on it.
Keep a production smoke test deliberately narrow:
- Use a small number of real, known cards (for example, cards belonging to your own team), not generated card ranges.
- Keep the volume low and the window time-boxed.
- Follow your PSP's guidance for validating in their live environment.
A pattern of many low-value or zero-value authorizations across many cards looks like card testing to acquirers and the networks, and that pattern can trip fraud monitoring and get an account throttled or offboarded. A production smoke test is a handful of attributed transactions you can account for, not a sweep.
Limited rollout
A smoke test confirms the path works. It does not yet prove it works for everyone. The safe bridge between the two is a limited rollout: gate live traffic behind a feature flag and enable it for a small, known cohort first (sometimes called a canary release, closed beta, or friends-and-family launch). Watch real authorization, decline, and error rates against your expectations, then widen the flag toward general availability as your confidence grows.