In this article, Mikhail Golikov, the sole QA on a seven-team backend e-commerce platform, explains where each regression check belongs, in the browser or at the API. He shares the rule he actually uses, two bugs that only one layer could have caught, and the realization that finally made his browser suite small and stable.
Author: Mikhail Golikov
You cannot test an e-commerce backend entirely through the browser. It is too slow, and at any real scale it is too flaky to trust. You also cannot test it entirely at the API, because then you never exercise the thing the customer actually does, which is assemble an order in a real page and pay for it. So every regression check lands on the same question: does this one belong in the browser or at the API?
I ask that question a lot. I am the only tester on a seven-team backend e-commerce platform, with a Selenium suite on one side and an API suite on the other. Getting the answer wrong is expensive in both directions. Put a business rule in the browser and you get a slow, flaky test that fails for reasons that have nothing to do with the rule. Put a user-journey check at the API and you can ship a bug straight to a customer while every test stays green. After a few years of being the person who owns that line, I stopped guessing and settled on a rule.
The rule I actually use
Test at the lowest layer where the risk actually shows up, and where the signal is cleanest. Anything tied to user behaviour, browser integration, or rendering goes in the UI. Everything else I push down to the API.
That is the whole heuristic. It is not a pyramid I drew once and pinned to a wall. It is a question I ask per check: where does this risk first become real, and from which layer will a failure point straight at the cause instead of somewhere three steps away. Most of the time the answer is the API, because most of what can go wrong is data and rules, and the API is where data and rules live. The browser earns a check when the risk only exists in the browser. Two real bugs taught me where that line sits better than any list could.
A bug only the browser could catch
We had a checkout page, an Angular front end over the order API. The API was fine. Send it a valid order and it came back correct: price right, discount right, delivery right, a clean payload every time. Every API test was green.
In the browser, the Place order button would not enable.
The form validation depended on an async field, a postcode lookup that ran while the user typed. The data was correct and the API was happy, but the front end never marked the form as valid, because of a race between the lookup and the form’s validity check. In one path the lookup resolved after the form had already recomputed its state, so the control kept its earlier invalid status even though the value was now good, and the form sat dirty-but-invalid with the button dead. A real customer with a real postcode could not place a real order.
The API could not catch this, and the reason matters. From the backend’s point of view nothing was wrong. The contract held. There was no bad data, no error, nothing to assert against. The bug lived entirely in the browser: in the form state, the Angular lifecycle, the timing between two things that were each correct on their own. Only something driving a real browser could see it. This is the kind of bug that reaches production, because the suite that would notice it is the one teams are tempted to cut for being slow, and the suite that stays green is the one that cannot see the problem at all.
A bug only the API could catch
The other direction looks the opposite and ends somewhere worse.
We ran several discounts: a loyalty reduction, a promo code, a seasonal offer. The rule was that some of them could not stack. The UI enforced that rule, but only partway. It stopped you from assembling a forbidden combination on the screen, so through the browser the rule looked solid.
The API did not stop you. You could send it a payload with a combination the UI would never let you build, and the backend would take it and apply the discounts in the wrong order. With the rounding on top, one combination produced a negative price. The system would have paid the customer to take the goods.
No browser test was ever going to find that. The UI will not let you assemble the state, and even an end-to-end test will not reach it without hacks that defeat the point of the test. The API found it the only way it can be found: by sending the payload directly and walking through the combinations the UI pretends are impossible. When the failure mode is money, that is not a case you leave to chance.
UI tests are about behaviour, not data
Those two bugs are the same lesson from both ends, and it is the thing I was slow to understand myself. Point your UI tests at the right target and most of your trouble with them goes away.
The wrong target is data. A lot of QA work, mine included for too long, treats a UI test as a check that the screen matches the API: the price on the page equals the price in the response, the name in the field equals the name in the record. That is real work, but it is the API’s truth re-checked in the slowest and most fragile place you could pick. If the data is the risk, assert on the data where the data lives.
The browser’s job is to catch what only exists in the browser: race conditions, state that has drifted into a shape nobody designed, focus and accessibility, an event that does not fire when a click should submit, a journey that breaks between two screens that each work on their own. None of that is visible from the API, and all of it reaches customers. Once I aimed the browser suite at behaviour instead of data, the number of UI tests dropped sharply, the flakiness dropped with it, and what was left meant something. A red browser build started pointing at a real user-facing problem instead of a timing wobble in a test that was checking the wrong thing.
The API owns the mirror image: the rules and the data. The pricing maths with its rounding corners, the way three discounts stack or refuse to, the status codes, the payloads a real client never sends but a broken or hostile one will. That space is enumerable. You can walk every combination in fast, stable tests and run thousands of them without ever starting a browser. That is why the rules go to the API and the behaviour stays in the UI: each lands where it can be checked cheapest and read clearest.
The contract that keeps both honest
The split only holds while both suites stay honest, and they rot in opposite directions. A browser suite rots by flaking. The moment a red build reads as probably the test, the team stops looking, and a suite nobody trusts is worse than none, because it costs time and buys nothing back. So I keep it small on purpose and quarantine a flake the day it shows up. What earns a place in it is short: the journeys a customer actually walks, sign in, add to cart, check out, and a handful of edge cases that only exist once a real page is running. The API suite rots by drifting, until its checks pass against a contract the service no longer honours. I keep those checks generated from the collections the teams maintain, run on every push, scoped so each team owns its slice, rather than hand-typed and left to age.
What stops the two from rotting into each other is a contract at the boundary. Without one, an API change the UI has not caught up with surfaces as a failing browser test, and you lose the afternoon debugging the wrong layer. A schema check, OpenAPI or JSON Schema validated in CI against every response, keeps that argument out of the browser suite: when the shape changes, the contract test fails first and names the layer that broke. Each suite is then tested for its own job, not the other’s.
What I would tell the next sole QA
If you are the only tester holding the line between a browser suite and an API suite, do not start from a ratio. Start from each check and ask which layer owns that risk, and which one will fail loudly enough to point straight at the cause.
Push business rules and the nasty can-this-even-happen states down to the API, where you can enumerate them and a failure is one clean signal. Keep the browser suite small and aimed at behaviour, the things that only exist once a real page is running. Put a contract between the two so each layer is tested for its own job and not the other’s. Do that, and the API will catch the negative price, the browser will catch the dead button, and neither suite spends its day re-checking what the other already knew.
References
- Selenium, browser automation: https://www.selenium.dev/
- postman2pytest on PyPI (the API side, in pytest): https://pypi.org/project/postman2pytest/
- OpenAPI Specification, schema and contract at the boundary: https://www.openapis.org/
- JSON Schema: https://json-schema.org/
- Martin Fowler, “The Test Pyramid”: https://martinfowler.com/bliki/TestPyramid.html
- Pact, consumer-driven contract testing: https://pact.io/
About the Author
Mikhail Golikov is a QA automation engineer based in Hove, UK. He has more than five years in QA, most recently as the sole tester on a multi-team backend e-commerce platform, where he owns regression across a browser suite and an API suite and builds open-source testing tools for Python. He maintains postman2pytest and several other pytest plugins on GitHub under the handle golikovichev.



Leave a Reply