Match a test environment where the bugs live

Test environment best practices say mirror production. The useful version: list the escaped bugs, name the axis each one depended on, and share only that.

Three nested glass frames surround a cube, crossed by one orange environment marker

The usual test environment best practice is to mirror production as closely as possible. That advice fails on the day a bug reproduces in production and not in test, because nobody can say which difference hid it. The useful rule is narrower: list the bugs that escaped, name the axis each one depended on, and share only that.

Mirror production is advice nobody can follow

Every list of test environment best practices opens with the same item: make test look like production. The advice is right in the sense that nobody argues with it and useless in the sense that no team has ever done it. Production has real traffic, real data, third parties billing per call, and a cache that has been warm for a month. A staging environment has a fixture file and whoever deployed last.

So every team is already choosing which differences to tolerate. The choice is usually made by cost and habit: the database schema is copied because migrations make that easy, the server build is identical because the pipeline produces it, and everything in front of the server is left to differ because nobody owns it. That is a policy, written down or not, and it decides which bugs escape.

The better starting point is the bugs themselves. A team that has had bugs reproduce in production and not in test, or in test and not in production, already holds a list of exactly the differences that matter to it. Each escaped defect depended on some axis where the two environments diverged. Name the axis and the environment decision writes itself for that class of bug.

Sort the escaped bugs by the axis they depended on

Put the last dozen environment-specific bugs in a table with one column for the axis, and the axes repeat.

AxisHow test usually differsBug class it hidesShare it, or record it
Data volume and shapeFixture rows, all well formed, all recentSlow queries, pagination at the boundary, a null in a column that fixtures never leave nullShare the shape, never the rows; see the last section
Caching and the CDNNo CDN, or a CDN with caching switched offStale assets after a deploy, a personalised page cached for everyoneShare the cache configuration; it is code
Third-party scriptsTag manager, chat and analytics loaded in production onlyA script that patches a global, blocks the main thread or fails a consent checkShare the tags, on a test container
Feature flagsEvery flag on, or every flag at its defaultA flag combination nobody tested, a flag one cohort gets and the tester does notRecord the flag state on every report
Browser and deviceThe developer’s browser, current version, wide screenEverything the users’ browsers do differentlyRecord it on every report; the matrix decides what to test by hand
Auth and roleOne admin accountA permission check that fires for a real customer’s roleShare the roles, with an account per role
Network and geographySame building as the serverTimeouts, a region behind a different edge, a slow first paintRecord timings on the report rather than trying to simulate them

The last column has two answers, and the distinction is the whole method. Some axes are cheap to share because they are configuration: a cache policy, a set of tags, a list of roles. Others cannot be shared at any price, because they are the users’ machines and connections, and for those the right move is to stop trying to replicate them in test and instead make sure every report from production records where it came from.

The divergence is usually in front of the server

Most of what teams spend on environment parity goes behind the server: same database engine, same container, same queue. Most of the rows in the table are in front of it, and that is the region test environments leave undefined.

Caching is the clearest case. A test environment without a CDN never sees the bug where a deploy ships a new bundle and the HTML still references the old one, or where a hashed filename was meant to force a fresh fetch and did not, because the HTML that references it was itself cached. That is a cache-busting failure, and it is invisible anywhere the cache is off. Sharing the cache policy between test and production is a header configuration, and it moves that whole bug class into test.

Third-party scripts are the second case. A tag manager loaded only in production brings with it every script marketing has added since the last audit, and any one of those can wrap a global, stall the main thread or rewrite the page after the app has rendered. Third-party scripts and web storage follows one of those interactions through. A test container in the tag manager, with the same tags pointed at test properties, closes most of the gap.

Feature flags are the case where sharing is wrong and recording is right. A flag state is per user and per moment, so there is no single production flag state to copy. What test needs is the flag state written onto each report, so that “reproduces in production and not in test” becomes a question with a checkable answer: which flags did the reporter have on.

Make the report carry the conditions it failed under

The axes marked “record” in the table share one requirement: the report that arrives from the failure has to carry the conditions it failed under, without the reporter being asked. A report that says “it broke on the checkout page” has to be triaged as “was that staging or production, which browser, and did anything fail in the network tab”.

That is a capture problem. With evidence capture running on both environments from the same script tag, the two reports carry the same fields, taken from the page rather than from the reporter: which path, which browser and operating system, what the viewport was, what the console logged and which requests returned which status. “Reproduces in production and not in test” is then a diff between two tickets instead of a conversation, and the first field to compare is the network log, because the request that returned a different status names the axis in one line.

The page cannot report its own environment name or the flag state. A visible banner on every non-production page puts the name into whatever the reporter marks up or records. The flag state has to be exposed by the application, which flags this user had on, or the feature-flag row of the table stays unrecorded.

Masked data loses the rows that break exports

The first row of the table carries the limit, and it belongs to the reader rather than to any tool. Production data is the axis that hides the most bugs, and it is the one axis that cannot be copied into test as it stands. A copy of the customer table is a second place that customer data lives, with weaker access controls, no retention rule of its own and no purpose the customer consented to. PII masking is the standard answer, and it has a cost: a masked dataset preserves the schema and roughly the volume, and changes the distribution. Names come from a list, email addresses become uniform, and the one row with the accented surname that broke the export becomes an ordinary row.

So a class of bugs is seen first in production, and no environment decision changes that. What the decision changes is what happens next: whether the first production report of a data-shape bug arrives with the row’s shape, the request that failed and the browser it failed in, or arrives as “the export is broken for one customer” and starts a hunt.

A test environment is a set of decisions about which differences to tolerate, and the honest version of the best-practice list says which ones were chosen. The class you will keep meeting in production first goes in the plan by name.

Common questions

What are the most important test environment best practices?

Decide which differences from production you are tolerating, on purpose, per axis: data, caching, third-party scripts, feature flags, browsers, roles and network. Share the axes that are configuration, such as cache headers, tag containers and roles. Record the axes that are people, such as browsers and flag states, on every report. Then name the bug class you will keep meeting in production first.

Why does a bug reproduce in production but not in the test environment?

The bug depends on an axis where the two environments diverge, and the divergence is usually in front of the server rather than behind it: a warm CDN serving a stale bundle, a third-party script loaded only in production, a feature flag the reporter has and the tester does not, or a data row shaped in a way no fixture is.

Should a test environment use a copy of production data?

A raw copy is a second place customer data lives, with weaker controls and no consent for the purpose, so the copy has to be masked. Masking keeps the schema and roughly the volume and changes the distribution, which means the awkward real rows that break exports are exactly the rows masking normalises away. Plan for that class of bug to appear in production first.

Does a test environment need a CDN and caching?

A test environment should run the same cache policy as production, because cache-busting failures, where a deploy ships a new bundle and cached HTML still references the old one, are invisible anywhere caching is off. Cache policy is a header configuration, so sharing it is cheap and moves a whole class of post-release bugs into test.

How do you compare a bug report from production with one from test?

Both reports have to carry their conditions without the reporter being asked: the page path, the browser and operating system, the viewport, the console errors and the network requests with their statuses. When both environments capture that from the same script tag, a bug that reproduces in one and not the other becomes a diff between two tickets, and the request that returned a different status names the axis.

The Notarus team

The people who build Notarus. We write up the decisions behind the product when the reasoning is worth more than the changelog line.

Keep reading

Reports with the evidence attached.

Start free, one script tag.

<script src="…/widget.js" data-key="pb_pub_…">