1. Introduction: Every Test Is Green, So Why Was the Customer Charged Twice?
Here is a scenario everyone has lived through.
Every API test returns 200 OK, every field matches the schema, and the CI report is a wall of green. Then support starts getting complaints: a user's connection hiccupped, they clicked twice, and their store credit was deducted twice. Or an item that was already out of stock somehow got sold.
Where does it break? A single API test only proves that one call answered correctly. Real business logic is a chain of actions.
So the scary bug is never the API returning 500, because you spot that instantly. The scary one is every API dutifully returning 200 while the customer's money and your inventory refuse to add up.
Our earlier post, Goodbye Postman's Pricing Traps! A Practical Guide to Bruno, the Open-Source, Git-Native API Testing Tool, covered tool choice and basic setup. This one goes a layer deeper: how to move from "testing one endpoint" to "testing a whole flow," and how to make that scale.
2. What Actually Blocks Us in the AI Era: Not Writing, but Verifying
The biggest change this past year is that writing code is no longer the bottleneck. A requirement arrives and AI gets an endpoint or a page 80% right within the hour.
What has not sped up at all is verifying any of it. So the bottleneck simply moved downstream.
The faster you ship, the more combinations there are to check. Clicking through a UI and eyeballing database rows is a linear cost, and it cannot keep up. What deserves the investment is a verification loop that is repeatable, generated by code, and runnable in CI.
I evaluated the options and settled on Bruno, for four plain reasons:
| What I needed | What Bruno gives |
|---|---|
| Easy for AI to edit | .bru is plain text, so AI can read it, change it, and generate a hundred of them. Postman's giant JSON blob cannot do that |
| Can query the database | Test scripts are just Node.js, so require a DB driver and run SQL |
| Fits into CI | An official CLI 2 with HTML / JSON / JUnit reporters, plus an official Docker image 3 that drops straight into GitLab CI |
| Version-control friendly | The files are the tests, git diff is readable, no cloud account or paid seats |
The whole methodology is three steps, and the order matters:
Step one catches broken APIs. Step two catches APIs returning wrong data. Step three catches the sneakiest kind: the API returns 200 with correct fields, but the books in the database are wrong. Later in this article a real run proves that only step three stops it.
None of these screenshots are mockups. I wrote a Node.js + SQLite fake e-commerce API as a target, and every image below is this suite actually running against it.
3. The Core Idea: Don't Compare Fields, Reconcile the Books
If an integration test just walks down the response fields one by one, you miss the forest for the trees. What you should really be validating is the system's invariant, or in plain words, the equation that must hold no matter what 6.
Test Like You Balance a Wallet
Picture going shopping:
- Count your wallet before leaving and write the number down (call it B0, the starting value).
- Go shop: place an order, pay, return one item, cancel another.
- Count the wallet again and check that the amount missing is exactly what you should have spent.
As a test, that looks like:
Record the Difference, Not the Remainder
It feels natural to assert balance == 1000. But the moment the environment is shared, someone else touches a row and your test goes red.
Record the delta instead, for example balance == B0 - 600. Now it passes whether the starting balance is 1000 or 5000. The test stops caring about polluted data and about execution order.
Three Practical Rules
- Every scenario reads its own starting value. No shared globals: each test folder calls the API itself at the start, so scenarios never interfere.
- Round decimals before comparing. E-commerce is full of discounts and tax rates. Run both sides through
Math.round(x * 100) / 100so floating-point noise never turns a test red. - Put the expected number in the assertion name, for example
Verify balance (expected 850). When it fails, the report already tells you the gap and you never open a log.
4. Bruno's Three Key Features
To run the loop above, the tool has to pass state around and organize structure. Open-source Bruno 1 does both well.
Pass the Previous Step's Result to the Next
Integration testing is a relay, known as request chaining 5. Bruno passes values between requests with bru.setVar and bru.getVar.
Read the balance first and store it:
// Read the starting balance and save it as a variable
bru.setVar('s01_bal0', Number(res.getBody().member.balance));
In the final step, compute what it should be, then let the declarative assert compare:
// Compute the expected value in pre-request
const b0 = Number(bru.getVar('s01_bal0'));
bru.setVar('s01_expBal', Math.round((b0 - 600) * 100) / 100);
// Compare in the assert block
assert {
res.status: eq 200
res.body.member.balance: eq {{s01_expBal}}
}
Complex maths in code, final comparison declarative. Clean reports, full flexibility.
One Shared Script for the Whole Collection
Some work repeats in every request, like logging in for a token. Instead of duplicating it, lift it to the top of the collection:
- Compute the token once: every later request reuses it, no repeated login flow.
- A safety gate: integration tests really do mutate data, so they must never hit production. Add this at the collection level:
// Collection-level script:pre-request
const url = (bru.getEnvVar('baseUrl') || '').toLowerCase();
if (!url) {
throw new Error('No baseUrl - select the UAT environment first.');
}
if (/prod|production|live/.test(url)) {
throw new Error('BLOCKED: baseUrl looks like production. UAT only!');
}
This gate is not decoration, it really stops the run. Below is what happened when I forced the URL to something that looks like production: the very first request is aborted and not one row is written.

Pair it with the rule "never create a production environment file" and mistakes become very hard to make.
Folders Are the Test Structure
Bruno's .bru files carry a seq that sets execution order, and folders naturally group scenarios. One business scenario per folder:
requests/
├─ 00-launch/ # Create the session everything else needs
├─ 02-shop-a/ # Platform A
│ ├─ 01-regular-checkout/ # Buy something normally
│ │ ├─ 1-read-stock.bru # Record the stock starting value
│ │ ├─ 2-read-balance.bru# Record the balance starting value
│ │ ├─ 3-create-order.bru
│ │ ├─ 4-pay.bru
│ │ ├─ 5-ship.bru
│ │ ├─ 6-verify-stock.bru
│ │ └─ 7-verify-ledger.bru # Check balance + database
│ ├─ 02-duplicate-payment/
│ └─ 03-out-of-order/
└─ 03-shop-b/ # Platform B, same six scenarios, different rules
Bruno walks the folders in order, so the report structure looks exactly like the business scenarios. Very easy to read.
5. Six Scenarios Where Things Actually Break
Don't write integration tests by walking down the API spec. Write them by walking down the ways the system breaks. In e-commerce, the damage comes from double clicks, network lag, and rejected requests.
Borrowing from industry practice such as Stripe's idempotency design 4, here are six scenarios:
| Category | What it tests | The real incident it prevents |
|---|---|---|
| Normal flow | Order → Pay → Ship | Broken basics, or wrong stock and amount maths |
| Duplicate submission | The same payment (same receipt number) sent twice 4 | Double charging from double clicks or webhook retries |
| Out-of-order arrival | A shipment request arrives before the payment confirmation | Network lag scrambles the state machine and unpaid orders ship |
| Amount variance | Cancellations, partial refunds, shipping fees and coupons | Wrong refund maths, disagreement over refundable fees |
| Rejected (stock) | Ordering 7 units when only 2 remain | Overselling, and rejected requests quietly mutating data |
| Rejected (balance) | A wallet holding 120 buying a four-figure item | Negative balances, or a ledger row written for a rejected order |
A quick plain-language note on idempotency: it means doing the same action several times should produce the same result, like pressing an elevator button ten times and still getting one elevator. Payment APIs implement it by having the client send a receipt number (an Idempotency Key). When the server sees a repeat, it replays the first result instead of charging again.
Six scenarios times two platform profiles is the whole demo. Here it is running:

55 requests, 55 tests, 85 assertions, under 0.4 seconds. Speed is not the point. The point is that all 140 checkpoints can be re-run on every commit for free. That is the difference between systematic verification and clicking around.
6. Stop Hard-Coding Numbers into Tests
The fastest way to rot an integration suite is magic numbers:
// Three months later nobody knows where 600 came from
expect(res.getBody().member.balance).to.equal(b0 - 600);
Switch platform or tweak a shipping rule and every one of those numbers is wrong, silently.
The fix is to lift "how this platform calculates" into a rules table and derive the expectations from it:
{
"shop-a": {
"cancelSemantics": "refund-all",
"insufficientStockBehavior": "block-order",
"shippingFee": 60,
"taxRate": 0
},
"shop-b": {
"cancelSemantics": "keep-shipping-fee",
"insufficientStockBehavior": "block-order",
"shippingFee": 80,
"taxRate": 0.05
}
}
In plain words: Shop A refunds everything on cancellation, charges 60 for shipping, no tax. Shop B keeps the shipping fee, charges 80, and adds 5% tax.
Then write the maths as plain functions shared by the whole collection:
// lib/expectations.js
const round2 = (n) => Math.round(n * 100) / 100;
// amount = goods x (1 + tax) + shipping - coupon
function orderAmount(profile, unitPrice, qty, coupon) {
const goods = round2(unitPrice * qty);
return round2(goods * (1 + profile.taxRate) + profile.shippingFee - (coupon || 0));
}
// How much comes back on cancel depends on this platform's rule
function cancelRefund(profile, amount) {
return profile.cancelSemantics === 'keep-shipping-fee'
? round2(amount - profile.shippingFee)
: round2(amount);
}
module.exports = { round2, orderAmount, cancelRefund };
Now the tests contain no hard-coded numbers, only "given this platform's rules, the answer should be":
// script:pre-request
const P = { cancelSemantics: 'refund-all', shippingFee: 60, taxRate: 0 };
const exp = require(bru.cwd() + '/lib/expectations.js');
bru.setVar('s01_expAmount', exp.orderAmount(P, Number(bru.getVar('s01_price')), 2, 100));
Two benefits. First, onboarding a platform means swapping the rules table, with zero test edits. Second, the derived number lands in the assertion name, for example Refund follows profile.cancelSemantics=keep-shipping-fee (expected 189). When it goes red, you immediately know whether I misread the rule or the system miscalculated.
7. Don't Hand-Write Tests One by One
By now the suite is highly regular: six scenarios, a fixed three-step check, expectations derived from a rules table. Regular enough that hand-writing it is a waste.
A Postman collection is one huge JSON document. Code can emit it, but nobody can review the diff. Bruno's .bru is plain text, so a string template is enough:
// generate.mjs (excerpt)
function req({ name, seq, method, url, body, assert = [], tests }) {
const parts = []
parts.push(`meta {\n name: ${name}\n type: http\n seq: ${seq}\n}`)
parts.push(`${method.toLowerCase()} {\n url: ${url}\n body: ${body ? 'json' : 'none'}\n auth: inherit\n}`)
if (body) parts.push(`body:json {\n${indent(body)}\n}`)
if (assert.length) parts.push(`assert {\n${assert.map((a) => ` ${a}`).join('\n')}\n}`)
if (tests) parts.push(`tests {\n${indent(tests)}\n}`)
return parts.join('\n\n') + '\n'
}
Onboarding another partner drops from "rewrite the suite" to "fill in a rules table":

That is the generator actually running: two rules tables in, 12 scenario folders, 55 .bru files and 75 files total out, in under a second.
The benefits compound:
- A new platform costs almost nothing: coverage lands immediately, nobody rewrites anything.
- Everything looks the same: identical folder structure, variable names and assertion wording, so reports compare side by side.
- The methodology upgrades in bulk: want every scenario to also check the order ends up SHIPPED? Change one line in the generator, regenerate, 55 tests updated.
This is what I really mean by Bruno being "AI-friendly." Plain text lets AI and scripts rewrite tests at scale, and because the tests execute, a bad rewrite turns red immediately.
8. The API Says It Worked, but Did the Database Record It?
So far we have only been trusting what the API says.
A correct balance response does not prove the transaction record was written correctly. A missing reconciliation row or a duplicated entry is invisible at the API layer.
This is where Bruno's Node.js scripting earns its keep: the test can talk to the database directly.
// lib/ledger.js
const { createRequire } = require('module');
function openDb(collectionPath, dbRelPath) {
const nodeRequire = createRequire(collectionPath + '/bruno.json');
const { DatabaseSync } = nodeRequire('node:sqlite');
return new DatabaseSync(collectionPath + '/../' + dbRelPath, { readOnly: true });
}
This demo uses SQLite; a real project swaps in mssql, mysql2 or pg with the same shape, only the connection string changes.
Then define four database-level checks, numbered C1 to C4:
| Code | In plain words | What it catches |
|---|---|---|
| C1 A charge needs a record | Every successful charge appears in the ledger | Money moved with nothing to reconcile against |
| C2 Nothing appears from nowhere | Every ledger row traces back to an order the test created | The system quietly writing unknown transactions |
| C3 One event, one row | At most one row per order per action | Idempotency broken at the database layer |
| C4 The totals must agree | Ledger total equals the money the wallet actually lost | The API and the ledger telling different stories |
Hang them on the last request of each scenario and one request does all three jobs: call the API, check the response, confirm the database.

C4 Is the Real Gatekeeper
The first three still only ask whether the system contradicts itself. C4 is the lethal one, because it compares two independent facts: the ledger total must equal the amount the balance actually dropped by.
I deliberately removed idempotency from the fake server, simulating a real regression where someone reworks the payment flow and forgets to check the receipt number, then re-ran the duplicate-payment scenario:

Look at the details, because the support incident from the introduction is fully reproduced here:
- Both payment calls returned 200, with the correct amount.
- The database's unique index blocked the second ledger row, so C3 "one event, one row" is still green.
- But the member was charged twice:
AssertionError: expected -380 to equal -760. The ledger says this order moved 380, while the wallet lost 760.
A suite that stops at "check the API response" ships this bug. Only pulling the database into the reconciliation catches it in CI.
9. Where Secrets Live and How to Wire Up CI
Integration tests touch real credentials, so settle the rules before wiring CI.
Where Secrets Are Read From, in Order
- System environment variables (
bru.getProcessEnv('SHOP_API_KEY')): what CI uses, injected from GitLab CI/CD variables (masked and protected). - A project-root
.env: local development. - The Bruno environment file: non-sensitive defaults only, such as
baseUrl.
// Collection level: CI reads the process env, local falls back
bru.setVar('apiKey', bru.getProcessEnv('SHOP_API_KEY') || bru.getEnvVar('apiKey'));
Three Rules You Don't Break
- Secrets never enter Git; the repo keeps only
.env.example. - Secrets never go on the command line, or they show up in process lists and CI logs.
- Reports (HTML / JSON) contain request and response bodies, so they go in
.gitignoreor get filtered with flags such as--reporter-skip-headers.
Wiring Up GitLab CI
Add SHOP_API_KEY as a Masked variable under Settings → CI/CD → Variables. The runner injects it as an environment variable, so .gitlab-ci.yml never repeats it:
stages:
- test
api-integration:
stage: test
image: node:22
script:
- cd collection
- >
npx @usebruno/cli run requests -r
--env uat --sandbox developer
--reporter-html ../report.html
--reporter-junit ../junit.xml
artifacts:
when: always
expire_in: 1 week
paths:
- report.html
- junit.xml
reports:
junit: junit.xml
artifacts:reports:junit lets GitLab surface failures straight in the merge request's test summary, and --reporter-html produces the report you can hand to non-engineers (kept under paths, downloadable from the job page):

Two things are easy to forget. First, when: always is mandatory: without it a red suite fails the job before the artifacts are collected, so the report is missing exactly when you need it. Second, Bruno CLI exits non-zero on failure and that turns the job red, which is what you want; if you only want to observe for a while without blocking merges, add allow_failure: true instead of swallowing the command with || true.
There is also an official Docker image 3 if you would rather not manage Node versions in CI: swap image: for it and you are done.
10. Traps We Hit
| # | What bit us | The fix |
|---|---|---|
| 1 | Guessing the rules. The doc said "cancel refunds everything," reality kept the shipping fee | Confirm every rule with one real call; never trust the doc |
| 2 | Reading state from an error response, like the stock number in a 422 body | Error paths check status and error code only; re-GET the state |
| 3 | Looks successful but did nothing. The duplicate payment returns 200 and charges nothing | Catch it with the final balance and ledger totals, not the intermediate 200 |
| 4 | 17-digit order ids losing precision through JSON.parse | Always String() the id; check its length, not its numeric value |
| 5 | Running a subfolder alone and failing everything because 00-launch never created the session | Include 00-launch in the run, or cache the token at collection level |
| 6 | Floating point: 567 * 1.05 disagreeing by a hair | Round both sides with Math.round(x * 100) / 100 first |
| 7 | Mojibake when PowerShell 5.1 runs the helper scripts | Keep .ps1 English-only, move prose into Markdown |
| 8 | Bruno's sandbox cannot load node:sqlite | Use require('module').createRequire(...), or a regular npm driver |
Number 8 bit us during this write-up. When Bruno decides whether something is a Node builtin, it strips the node: prefix before checking its list, but node:sqlite happens to be listed with the prefix. So it gets misclassified as an npm package and the file lookup fails. One line of createRequire fixes it.
Which is a reminder: what a sandbox can and cannot do must be confirmed by running it, not by guessing.
11. Conclusion: Automate the Defense, but the Boundaries Stay Human
Back to the opening question. What changes here is not that tests get written faster, but that verification stops being a one-off action and becomes a line of defense you can re-run.
| Metric | Measured in this demo |
|---|---|
| Platform rules tables | 2 |
| Scenario folders | 12 (6 scenarios × 2 platforms) |
Generated .bru requests | 55 |
| Checkpoints (tests + assertions) | 140 |
| Full suite runtime | 0.4 seconds |
| Cost of adding a platform | One rules table |
One thing needs saying plainly: automation accelerates the defense, but not the boundaries.
A generator can turn 55 tests into 550. AI can write assertions faster and cleaner than you. CI can run them on every push.
But whether this platform should refund the shipping fee, whether a duplicate payment counts as an error, whether a ledger mismatch blocks a release: those boundaries are yours to decide. Define one wrong and the machine will very efficiently verify the wrong rule a thousand times.
Tools will change, and Bruno will not be the last one. What survives is the methodology: reconcile instead of comparing fields, design scenarios around failure modes, derive expectations from rules, and always ask the database once more after the API answers.
That ordering is what this article is really about.


























Comments