Mark Ku's Blog
Podcast ConversationAI dialogue version of this article · Mandarin audio

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 neededWhat 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 databaseTest scripts are just Node.js, so require a DB driver and run SQL
Fits into CIAn official CLI 2 with HTML / JSON / JUnit reporters, plus an official Docker image 3 that drops straight into GitLab CI
Version-control friendlyThe files are the tests, git diff is readable, no cloud account or paid seats

The whole methodology is three steps, and the order matters:

Loading diagram…

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:

  1. Count your wallet before leaving and write the number down (call it B0, the starting value).
  2. Go shop: place an order, pay, return one item, cancel another.
  3. Count the wallet again and check that the amount missing is exactly what you should have spent.

As a test, that looks like:

Loading diagram…

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

  1. Every scenario reads its own starting value. No shared globals: each test folder calls the API itself at the start, so scenarios never interfere.
  2. Round decimals before comparing. E-commerce is full of discounts and tax rates. Run both sides through Math.round(x * 100) / 100 so floating-point noise never turns a test red.
  3. 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.

Bruno CLI output showing the collection-level security gate blocking a production baseUrl
Bruno CLI output showing the collection-level security gate blocking a production baseUrl

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:

CategoryWhat it testsThe real incident it prevents
Normal flowOrder → Pay → ShipBroken basics, or wrong stock and amount maths
Duplicate submissionThe same payment (same receipt number) sent twice 4Double charging from double clicks or webhook retries
Out-of-order arrivalA shipment request arrives before the payment confirmationNetwork lag scrambles the state machine and unpaid orders ship
Amount varianceCancellations, partial refunds, shipping fees and couponsWrong refund maths, disagreement over refundable fees
Rejected (stock)Ordering 7 units when only 2 remainOverselling, and rejected requests quietly mutating data
Rejected (balance)A wallet holding 120 buying a four-figure itemNegative 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:

Bruno CLI run with 55 requests and 85 assertions all passing
Bruno CLI run with 55 requests and 85 assertions all passing

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":

Test generator turning two profiles into 12 scenario folders and 55 .bru requests
Test generator turning two profiles into 12 scenario folders and 55 .bru requests

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:

CodeIn plain wordsWhat it catches
C1 A charge needs a recordEvery successful charge appears in the ledgerMoney moved with nothing to reconcile against
C2 Nothing appears from nowhereEvery ledger row traces back to an order the test createdThe system quietly writing unknown transactions
C3 One event, one rowAt most one row per order per actionIdempotency broken at the database layer
C4 The totals must agreeLedger total equals the money the wallet actually lostThe 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.

Bruno request asserting the API response and the C1 to C4 database ledger invariants
Bruno request asserting the API response and the C1 to C4 database ledger invariants

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:

Red run after disabling idempotency: API returns 200 while the ledger net disagrees with the balance delta
Red run after disabling idempotency: API returns 200 while the ledger net disagrees with the balance delta

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

  1. System environment variables (bru.getProcessEnv('SHOP_API_KEY')): what CI uses, injected from GitLab CI/CD variables (masked and protected).
  2. A project-root .env: local development.
  3. 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 .gitignore or 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):

Bruno HTML report dashboard showing 55 requests and 140 checkpoints passing
Bruno HTML report dashboard showing 55 requests and 140 checkpoints passing

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 usThe fix
1Guessing the rules. The doc said "cancel refunds everything," reality kept the shipping feeConfirm every rule with one real call; never trust the doc
2Reading state from an error response, like the stock number in a 422 bodyError paths check status and error code only; re-GET the state
3Looks successful but did nothing. The duplicate payment returns 200 and charges nothingCatch it with the final balance and ledger totals, not the intermediate 200
417-digit order ids losing precision through JSON.parseAlways String() the id; check its length, not its numeric value
5Running a subfolder alone and failing everything because 00-launch never created the sessionInclude 00-launch in the run, or cache the token at collection level
6Floating point: 567 * 1.05 disagreeing by a hairRound both sides with Math.round(x * 100) / 100 first
7Mojibake when PowerShell 5.1 runs the helper scriptsKeep .ps1 English-only, move prose into Markdown
8Bruno's sandbox cannot load node:sqliteUse 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.

MetricMeasured in this demo
Platform rules tables2
Scenario folders12 (6 scenarios × 2 platforms)
Generated .bru requests55
Checkpoints (tests + assertions)140
Full suite runtime0.4 seconds
Cost of adding a platformOne 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.

Loading diagram…

Author

Mark Ku

擁有 10+ 年經驗的資深軟體工程師,現為 AI 應用 Builder,專注於大型平台架構與簡化複雜系統設計,從電商系統到訂閱與收費平台,結合 AI Agent、AI 整合與自動化開發,打造高效率且可持續演進的產品技術基礎。Read More

Found this useful?

The author's free tools, daily podcasts and newsletter are all here.

Mark Ku · This article is licensed under CC BY 4.0. Credit the author and link back to the original when reusing it.

Comments

Subscribe to Newsletter

Subscribe to get new posts delivered instantly — never miss a tech share.

By submitting, you agree to receive emails. You can anytime.

Popular Posts

View all
Mark Ku
··599

Oracle Cloud Always Free Tier: Linux Host and Static IP for a $0 Cloud Solution

Oracle Cloud Always Free Tier: Linux Host and Static IP for a $0 Cloud Solution
Mark Ku
··493

Say Goodbye to Postman's Fee Trap! A Hands-on Guide to Bruno, the Open-Source Git-Native API Testing Powerhouse.

Say Goodbye to Postman's Fee Trap! A Hands-on Guide to Bruno, the Open-Source Git-Native API Testing Powerhouse.
Mark Ku
··333

A Free, Open-Source, Notion-like Knowledge Base — A Complete Guide to Deploying and Backing Up Outline Wiki

A Free, Open-Source, Notion-like Knowledge Base — A Complete Guide to Deploying and Backing Up Outline Wiki
Mark Ku
··271

Training Your Own AI Voice: Hardware Requirements, Open-Source Model Comparison, and LoRA Fine-Tuning

Training Your Own AI Voice: Hardware Requirements, Open-Source Model Comparison, and LoRA Fine-Tuning
Mark Ku
··222

Setting Up Samba on Ubuntu to Share Folders with Windows 11

Setting Up Samba on Ubuntu to Share Folders with Windows 11
Mark Ku
··217

Building an Efficient API Management Platform: Deploying Kong Gateway from Scratch - Part 1

Building an Efficient API Management Platform: Deploying Kong Gateway from Scratch - Part 1