Testing pipelines that take 30 minutes to run aren't suffering
retry to a flaky test to stop the noise. By Friday, we have three tests failing for reasons nobody can explain, but the team just merges anyway because "that's just how the browser tests are." We call it "flakiness" because that makes it feel unavoidable, but it's actually reliability debt.The truth is that browser tests don't just fail; they report the exact state of the architecture you've built. Most of what we label as "flaky" is actually the result of specific technical oversights: animations behaving differently in CI, feature flags shifting the DOM mid-test, or running more parallel workers than the runner's CPU can actually handle.
The Environment is Part of the Product
We tend to treat a test that passes locally but fails in CI as a tooling glitch. In reality, the two environments are rarely equivalent.
One specific example that bit us was CSS motion preferences. A developer's machine might have normal motion enabled, while the CI browser reports prefers-reduced-motion. This changes transition durations and element visibility. The test isn't "randomly" failing; it's responding consistently to different inputs.
The same logic applies to timezones, locales, GPU acceleration, and viewport sizes. If an environment setting can change the user experience, it must be explicit in the config. Stop relying on whatever default the container or hosted runner provides.
For anyone struggling with this, I recommend forcing your CI environment to match your local specs exactly. For example, if you're using Playwright, don't leave the browser context to chance. Use a specific config:
// playwright.config.js
export default defineConfig({
use: {
viewport: { width: 1280, height: 720 },
locale: 'en-US',
timezoneId: 'America/New_York',
// Force consistent motion preferences to avoid "flaky" transitions
browserContextOptions: {
reducedMotion: false,
},
},
});Feature Flags Create Parallel Realities
A feature flag isn't just a boolean; it's a fork in your product's reality. If you have five independent flags, you theoretically have 32 different versions of your app. Most teams don't test all of them, but they also fail to define which combinations are actually critical.
This is where browser tests become a nightmare. The same test name might execute against different UI structures depending on the rollout state or cached configuration. The problem is that most test reports save screenshots and logs, but they don't save the active flag set. When a test fails during a gradual rollout, you aren't debugging a test—you're trying to reconstruct a missing environment.
To stop the guessing game, you need to capture the state of the application at the moment of failure. I've pushed my team to ensure every failure log includes:
- The evaluated flag values
- The user/account segment
- The specific application version
- The API response that triggered the UI branch
Asserting the Wrong Things
Modern UI patterns like React Server Actions and optimistic updates make apps feel faster by showing the expected result before the server confirms it. This is great for the user, but lethal for traditional assertions.
If your test checks for a "Success" message the millisecond a button is clicked, you're testing the optimistic UI, not the actual server outcome. If the server then returns an error, the UI flips back, but your test has already passed.
You have to move away from implementation-detail assertions and focus on user-visible outcomes. Instead of checking if a loading spinner appeared and disappeared, check if the data actually persisted in the database or the final UI state is correct after the network request settles.
Stop treating your test suite like a chore that just "happens" to be slow. It's a mirror. If the tests are noisy and slow, your environment and deployment logic are likely the same.