Unit Tests are NOT enough!
You have 100% coverage. All the unit tests are passing. But then, in production, a horrible bug happened.
The Unit Test Passed. The Customer Was Overcharged.
Your unit tests can’t catch this category of bug. Here’s proof.
Before, I showed you how Marco’s team shipped a broken tax calculation despite having green tests across the board.
I received this question:
“Can you show me the actual code? I want to prove this to my team.”
So here it is. A concrete example you can drop into any conversation about test strategy.
The Setup
We have a simple e-commerce system. When a customer places an order, the system needs to look up the tax rate from an external Tax API and calculate the total.
public class TaxGateway {
private final HttpClient httpClient;
public double getTaxRate(String countryCode) {
HttpResponse response = httpClient.get(
"https://tax-api.example.com/rates/" + countryCode
);
JsonObject json = JsonParser.parse(response.getBody());
return json.getDouble("rate");
}
}public class OrderService {
private final TaxGateway taxGateway;
public Order placeOrder(String countryCode, double unitPrice, int quantity) {
double basePrice = unitPrice * quantity;
double taxRate = taxGateway.getTaxRate(countryCode);
double taxAmount = basePrice * taxRate;
return new Order(basePrice, taxAmount, basePrice + taxAmount);
}
}The code looks correct. OrderService calls TaxGateway, which makes an HTTP call to the external Tax API. Everything is wired up properly.
But there’s a hidden bug. The Tax API returns rates as whole numbers — { "rate": 8 } for 8%. The TaxGateway reads this value and returns it directly. It should divide by 100 first, but it doesn’t.
The Unit Test: GREEN ✅
@Test
void shouldApplyTaxRateFromExternalService() {
when(taxGatewayMock.getTaxRate("US"))
.thenReturn(0.08);
Order order = orderService.placeOrder("US", 20.00, 5);
assertEquals(100.00, order.getBasePrice());
assertEquals(8.00, order.getTaxAmount());
assertEquals(108.00, order.getTotalPrice());
}This test passes. And it should pass — given the mock’s return value, the math is perfect.
The developer sees green. CI sees green. The PR gets approved.
But the mock is a lie. It returns 0.08 because that’s what the developer assumed TaxGateway would return. The mock replaces the entire TaxGateway — the HTTP call never happens, the JSON parsing never runs, and the missing / 100.0 is never exposed.
In production, the customer orders $100 worth of products and gets charged $900 — because 100 * 8 = 800 in tax.
And here’s the thing: you can’t fix this with more unit tests. You could unit test every single class — OrderService, TaxGateway — and they would all pass. The bug lives at the boundary between your code and the real world, and unit tests must mock that boundary. If the mock is wrong, every test built on top of it is wrong too.
The Acceptance Test: RED ❌
Now here’s the same scenario written as an acceptance test:
@Test
void shouldCalculateCorrectTotalWithCountryTaxRate() {
scenario
.given().product()
.withUnitPrice("20.00")
.and().country()
.withCode("US")
.withTaxRate("8%")
.when().placeOrder()
.withQuantity("5")
.withCountry("US")
.then().shouldSucceed()
.and().order()
.hasBasePrice("100.00")
.hasTaxAmount("8.00")
.hasTotalPrice("108.00");
}This test runs against the real system — real backend, real database, real TaxGateway code — but with a stub for the external Tax API. The given().country().withTaxRate("8%") line configures the Tax API Stub to return { "rate": 8 } — exactly what the real Tax API would return.
The real TaxGateway code executes. The HTTP call fires against the Tax API Stub. The JSON is parsed. And the missing / 100.0 is exposed.
Result: FAILS.
Expected taxAmount: 8.00
Actual taxAmount: 800.00
Expected totalPrice: 108.00
Actual totalPrice: 900.00The Tax API Stub returned { "rate": 8 }, just like the real API would. TaxGateway read the value and returned 8 directly to OrderService, which calculated 100 * 8 = 800 in tax. In the unit test, the mock hid this by returning 0.08 directly. In the acceptance test, the real code path runs and the bug is caught.
Why Unit Tests Can Never Catch This
The bug isn’t in OrderService. The math is correct. The wiring is correct. The bug is inside TaxGateway — in how it parses the external API response.
But the unit test for OrderService mocks out TaxGateway entirely. The HTTP call never fires. The JSON parsing never runs. The bug is invisible.
The unit test:
Tests
OrderServicein isolationReplaces
TaxGatewaywith a mockThe mock returns what the developer assumes the gateway returns
The real HTTP call and JSON parsing never execute
Can’t catch a bug inside the mocked component
The acceptance test:
Tests the full feature against the real system with stubs for external dependencies
The real
TaxGatewaycode runs — HTTP call, JSON parsing, everythingThe Tax API Stub behaves like the real Tax API — returning
{ "rate": 8 }Catches bugs anywhere in the chain, not just in the class under test
Unit tests verify your logic given your assumptions. Acceptance tests verify the feature works regardless of your assumptions. No amount of unit testing can catch a bug that’s hidden behind a mock.
But Wait — How Do We Know the Stub Is Correct?
Fair question. If the acceptance test uses a Tax API Stub instead of the real Tax API, couldn’t the stub itself be wrong?
That’s where contract tests come in. A contract test runs the same scenarios against both the real Tax API and the Tax API Stub, and verifies they return the same results. If the stub drifts from reality, the contract test fails.
So the full safety net looks like this:
Unit tests verify each component’s logic in isolation
Acceptance tests verify the feature works end-to-end, using stubs for external systems
Contract tests verify the stubs behave like the real external systems
Each layer catches a different category of bug. Skip any one of them, and you have a blind spot.
This Isn’t an Edge Case
The bug in our example was a wrong assumption about the API response format. But this is just one of many ways external system integration breaks:
Wrong format assumption — The Tax API returns
{ "rate": 8 }meaning 8%, but your code treats it as0.08. Your mock returns whatever you assumed, so the unit test passes.Hardcoded value — The developer hardcoded
return 0.10insideTaxGatewaywhile building the feature, planning to wire up the HTTP call “later.” Later never came. The unit test mocks outTaxGatewayentirely, so the hardcoded value is never executed.Wrong field — The API returns
{ "taxRate": 8, "importRate": 12 }and your code readsimportRateinstead oftaxRate. Your mock only returns the field you expected, so the unit test passes.Stale mapping — The API used to return
{ "rate": 8 }but v2 changed it to{ "tax": { "rate": 8 } }. Your code still reads the top-level field. Your mock still returns the old format, so the unit test passes.
This pattern shows up constantly across all types of external integrations:
A payment gateway that returns amounts in cents, but your code treats them as dollars.
A shipping API that changed its response format in v2, but your mocks still return v1.
An inventory service that returns
"OUT_OF_STOCK"as a string, but your mock returns a booleanfalse.A currency API that returns rates with 6 decimal places, but your mock rounds to 2.
Every one of these passes unit tests — because the mock is the assumption. If the assumption is wrong, the mock is wrong, and the test is worthless.
Every one of these ships to production. Every one of these gets caught by acceptance tests.
The Takeaway
Unit tests answer: “Does this component do its job correctly, given my assumptions?”
Acceptance tests answer: “Does this feature work the way the customer expects?”
Contract tests answer: “Are my assumptions about external systems correct?”
These are fundamentally different questions. If you’re only asking the first one, you’re leaving the other two for your customers to answer.
Want to learn how to write acceptance tests like this?
I’m running a live workshop: Stop Shipping Bugs: Acceptance Tests Workshop.
4 hours. Two evenings. Live on Zoom, with me.
⚡ I’ll walk you through the full architecture — DSL, Drivers, how it all fits together — and by the end, you’ll have written a real acceptance test from scenario to assertion.
📅 May 25-26, 5:00-7:00 PM CET
Limited spots. Register now with the early bird discount - 100 EUR off with code EARLYBIRD100
— Valentina


