July 11, 2026
Why E2E Tests Start Failing After a Frontend Build Tool Migration
A practical debugging guide for e2e tests that fail after a frontend build tool migration, covering selectors, asset loading, timing drift, and CI troubleshooting.
Frontend build tool migrations are supposed to be boring infrastructure work. Swap Webpack for Vite, modernize Babel or PostCSS, trim the bundle, maybe speed up dev server startup, and move on. Then your end-to-end suite starts failing in places that look unrelated to the build itself. Clicks miss targets, selectors stop matching, images never load, a modal appears too late, and CI-only failures show up in bursts.
When teams say their e2e tests fail after frontend build tool migration, the real problem is usually not the test framework. It is the contract between the app, the browser, and the test environment. A new bundler changes more than syntax support and build speed. It changes file naming, asset paths, module timing, CSS injection order, sourcemaps, hot reload behavior, and sometimes even the DOM structure the browser sees at runtime.
This guide breaks down the most common failure modes, how to isolate them, and what to fix first. It is written for frontend engineers, SDETs, and QA leads who need to turn vague “tests are flaky now” reports into a concrete debugging plan.
If your tests only fail after a build pipeline change, treat that as a signal. The app may still be functionally correct, but the browser test environment has drifted enough that your assumptions are no longer true.
What changes during a build tool migration
A build tool migration is not just a compiler swap. It often changes several layers at once:
- how modules are resolved and bundled
- how CSS is loaded and ordered
- how static assets are fingerprinted and served
- how environment variables are injected
- how the dev server handles routing, proxies, and history fallback
- how code splitting changes initial render timing
- how minification and tree shaking affect runtime behavior
That means failures can appear in places that seem “outside” the migration. A test may fail because a selector no longer maps to visible content, but the root cause could be a CSS module class rename or a chunk loading delay.
For context, this is normal in test automation and browser-based software testing. End-to-end tests validate behavior across the whole stack, so they are sensitive to timing and rendering details that unit tests never see. The more your suite asserts on implementation details, the more a build migration can expose hidden coupling.
First question, is the app broken or the test environment drifted?
Before changing locators or sprinkling waits, decide whether the application itself regressed.
A practical triage sequence looks like this:
- Reproduce the failure in a real browser, not just in CI logs.
- Open the same page manually in the migrated build.
- Check whether the broken behavior is visible without automation.
- Compare DOM, network, and timing behavior with the old build.
If the UI is broken manually, focus on the app. If manual usage works but tests fail, the likely cause is browser test environment drift, meaning the app and the test runner are no longer observing the same timing or DOM state.
Useful questions:
- Did route transitions slow down because of chunk splitting?
- Did CSS load later, causing elements to be present but not clickable?
- Did selectors change because class names are now hashed differently?
- Did API mocking or dev server proxy rules change?
- Did the migration alter the base path or asset prefix?
Common failure mode 1, selectors changed without anyone noticing
The easiest failure to diagnose is also the most common: the test is tied to a selector that was always too brittle.
Build migrations often expose this because the app structure becomes slightly different. Examples include:
- CSS modules generating different class names
- styled-components or similar libraries changing class injection order
- JSX refactors moving text into nested elements
- icon components replacing inline SVG markup
- conditional wrappers appearing only in production builds
A test that used to find button.btn-primary may no longer work if the button class is now generated. A test that clicked based on text in a specific node may fail if the text is split across nested spans.
What to do
Prefer stable, user-facing hooks over styling hooks. In most suites, that means data-testid, accessible names, labels, and roles. For example, in Playwright:
typescript
await page.getByRole('button', { name: 'Save changes' }).click();
If the app has no accessibility metadata and the migration is already done, add it now. That is often cheaper than keeping brittle locators alive.
For temporary debugging, inspect the rendered DOM in the migrated build and compare it with the pre-migration version. You are looking for changes in:
- element nesting
- text placement
- role exposure
- ARIA attributes
data-*attributes
If a selector only fails in production build mode, not in dev mode, the issue may be a build optimization changing the markup shape or DOM order.
Common failure mode 2, assets load differently and tests race the page
A frontend build tool migration often changes static asset handling. That can break tests in subtle ways.
Examples:
- image paths are rewritten or fingerprinted differently
- fonts load later, changing layout and click targets
- CSS is extracted into separate chunks
- SVGs move from inline markup to external assets
- lazy-loaded chunks create a brief loading state where the old UI used to appear immediately
These changes do not always show up as obvious errors. Instead, the test clicks too early, asserts before styles apply, or screenshots the page before the real layout settles.
Symptoms to look for
- element exists, but is not visible yet
- button is present, but overlapped by a loading skeleton
- click is intercepted by a spinner, overlay, or transition layer
- visual diffs show shifted spacing or missing fonts
- screenshots differ between local and CI because assets are not fully ready
What to check
Open the network tab and watch asset requests. Look for:
- 404s on
/assets/... - chunk loading delays
- fonts timing out or falling back differently
- CSS requests that arrive after the test starts interacting
If you use route-based code splitting, the first render may now be intentionally lighter and more asynchronous. In that case, tests need to wait on a UI state, not just on navigation completion.
For Playwright, prefer waiting on a known UI signal:
typescript
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByTestId('dashboard-loading')).toBeHidden();
Avoid cargo-cult waits like waitForTimeout(2000). They hide the issue, they do not solve it.
Common failure mode 3, timing changed because the bundle is smaller, faster, or differently split
It sounds strange, but a migration can make tests fail because the app is faster in one path and slower in another. Faster initial JS execution can expose race conditions that were masked before. Slower lazy chunk resolution can expose tests that assumed a page was “done” after navigation resolved.
This matters especially for suites built around:
- network stubbing
- DOM polling
- optimistic UI updates
- client-side hydration
- component frameworks with deferred rendering
A test that relied on a specific order of microtasks, animation frames, or data fetches can become unstable after migration.
Examples of timing drift
- a modal opens before its focus trap is installed
- the page renders skeleton content before the main section mounts
- a toast appears and disappears before the test queries it
- a debounced search updates after the test already asserted no results
- hydration changes the accessible tree after initial DOM inspection
Debugging strategy
Instrument the page with timestamps or console markers around the critical path. Then compare old and new builds.
A small example in Playwright:
typescript page.on(‘console’, msg => console.log(msg.text()));
await page.goto('/checkout');
await page.waitForLoadState('networkidle');
await expect(page.getByRole('button', { name: 'Place order' })).toBeEnabled();
Be careful with networkidle. It can be misleading in apps that keep long-lived connections open or fire background requests. If your app uses websockets, analytics, or polling, wait on a UI condition instead of assuming the network goes idle.
Common failure mode 4, dev server and CI do not behave the same way anymore
Many teams discover that tests pass locally against the dev server but fail in CI against a production build, or the reverse. Build tool migrations widen this gap.
Possible causes:
- different base paths in local versus CI
- environment variables injected at build time instead of runtime
- proxy configuration changed during the migration
- production minification changing code paths
- SSR versus CSR differences becoming more visible
- source maps hiding the real stack traces in one environment
A frontend build migration can also change how test runners launch the app. For example, the old setup may have served files from memory, while the new one writes to disk and serves a built artifact. That can expose path assumptions in tests, especially for assets and downloads.
A simple CI check matrix
Use a small matrix to compare where the failure appears:
name: e2e
on: [push, pull_request]
jobs: run: runs-on: ubuntu-latest strategy: matrix: mode: [dev-server, production-build] steps: - uses: actions/checkout@v4 - run: npm ci - run: npm run build - run: npm run test:e2e – –mode=$
The point is not to add complexity forever. It is to identify whether the failure belongs to the build output, the dev server, or the browser test logic.
Common failure mode 5, route handling and base URLs changed
This one shows up often in Vite migration test failures and similar build swaps. The app works locally, but navigation tests fail on refresh, deep links, or asset requests.
Reasons include:
- the app now expects a different
basepath - the SPA router fallback is not configured the same way
- asset URLs are now relative instead of absolute
- the test server is serving the app from a different origin or subpath
Symptoms include:
- 404 after direct navigation to a nested route
- lazy chunks requested from the wrong prefix
- screenshots of blank pages because the router never boots
- test runner sees the login page instead of the intended route
What to verify
baseorpublicPathsettings in the build config- reverse proxy and rewrites
- router history mode, especially for deployed previews
- asset URLs generated in HTML output
If a test goes directly to /settings/billing, make sure the server behind the test run can serve that route. A dev server that supports SPA fallback is not the same as a production host that does not.
Common failure mode 6, CSS and layout changed enough to break clickability
Not every click failure is a selector issue. Sometimes the element is found, but not interactable.
Build migrations can alter CSS in ways that affect the click target:
- extracted CSS arrives after the element is queried
- z-index stacking changes after refactoring styles
- a sticky header now covers the button
- responsive breakpoints shift because of different font loading or CSS ordering
- overflow or transform changes create a new stacking context
These are classic frontend build regressions because the bug sits between styling and interaction, not in the test itself.
How to inspect it
Use the browser devtools or your test runner’s debugging mode and ask:
- Is the intended element on top?
- Is there an overlay intercepting pointer events?
- Has the element moved because the layout changed after hydration?
- Is the test viewport different in CI?
A locator can be correct and still fail if the element is off-screen or hidden behind another layer. In Playwright, assertions on visibility and stability are often more informative than raw clicks.
typescript
const save = page.getByRole('button', { name: 'Save' });
await expect(save).toBeVisible();
await expect(save).toBeEnabled();
await save.click();
Common failure mode 7, test data or mocks no longer line up with the app
Build migrations often bundle unrelated refactors. Teams upgrade the build tool, then quietly update API clients, environment variables, or mock servers. Tests fail because the app now asks for data differently.
Watch for:
- renamed environment variables
- API base URLs changing from runtime to build-time config
- mocked endpoints no longer matching request paths
- stale fixtures that still reflect old response shapes
- query parameters added by the new router or data layer
If a test asserts on an empty state or error message, verify the API request itself. The UI may be waiting on a response the mock does not recognize.
Useful debugging check
Capture network requests during the failing test and compare them against the mock setup.
page.on('request', request => {
if (request.url().includes('/api/')) {
console.log(request.method(), request.url());
}
});
If the request path changed, fix the mock. If the response shape changed, update the fixture. If the app now needs a runtime env var that was previously compiled in, fix the test environment so it mirrors production more closely.
A step-by-step debugging workflow that actually helps
When e2e failures appear after a build migration, work through the problem in layers.
1. Reproduce with the same build artifact the test uses
Do not debug against an unrelated local dev server if CI runs a production build. If possible, serve the exact built output and reproduce the test there.
2. Classify the failure
Group it into one of these buckets:
- selector cannot find element
- element exists but is invisible or disabled
- element is clickable but action has no effect
- assertion waits forever
- assertion sees the wrong text or layout
- network request never returns
3. Compare old and new DOM output
Inspect the rendered HTML after the page stabilizes. Look for changes in roles, wrappers, class names, and hydration markers.
4. Check timing, not just state
Add logs around navigation, data fetch completion, and user interactions. A failure that looks random is often a race condition that the old build accidentally masked.
5. Verify config parity
Compare:
- base URL
- env vars
- asset prefix
- proxy settings
- viewport
- browser version
- CI container fonts and locale
6. Fix the app or the test, whichever is actually brittle
If the test is too implementation-specific, rewrite it. If the app changed semantics, fix the app. If the migration surfaced a real user-facing regression, keep the failing test as protection.
What to harden in your test suite after the migration
A build tool migration is a good time to reduce future noise. Focus on the parts of the suite most likely to break again.
Prefer user-visible selectors
Use roles, labels, and stable test IDs. Avoid CSS selectors based on styling classes generated by the build system.
Wait on signals, not guesses
Wait for meaningful UI states, API completion, or specific DOM markers. Do not use arbitrary sleeps unless you are temporarily isolating a race during debugging.
Separate app bugs from environment bugs
Keep one set of tests that runs against a local build artifact and another that runs against the real deployment path, if feasible. This helps catch differences in routing, asset serving, and cache behavior.
Keep build config under test too
A surprising amount of flakiness comes from config drift. A simple smoke test that loads the homepage and a deep link can catch routing or asset path mistakes immediately after a migration.
A practical checklist for migrated frontends
If you need a quick triage list, use this:
- confirm the failing test uses the same build mode as CI
- check whether selectors rely on classes that may have changed
- inspect asset requests for 404s or delayed loads
- verify base path, proxy, and router fallback settings
- compare DOM after hydration or route transition
- replace timing assumptions with visible state checks
- review mocks and fixtures for request path or schema drift
- validate viewport, browser version, and locale parity in CI
When to treat the failure as a real regression
Not every post-migration failure is test debt. Some are real user issues that the old build never exposed.
Be especially suspicious if the failing case involves:
- navigation to a deep link
- initial page load performance
- accessibility tree differences
- form submission or validation timing
- responsive layout changes
- asset loading on slower connections
If the test fails because the app is not ready when a real user would expect it to be, that is a product bug, not a flaky test.
Closing thought
When e2e tests fail after frontend build tool migration, the temptation is to blame the test framework or start adding sleeps until the suite goes green. That usually makes the suite worse. The better approach is to trace the failure back to the contract between the browser, the rendered DOM, and the build pipeline.
Most migration-related flakiness comes from a small set of causes, selectors that were never stable, assets that now arrive differently, route handling that changed, or timing assumptions that the new build no longer preserves. Once you know which layer moved, the fix usually becomes straightforward.
For a broader definition of software testing, see software testing, and for the discipline that underpins browser suites like these, see test automation. If you are integrating these checks into release pipelines, continuous integration is the operational layer that makes the differences show up consistently instead of by accident.
A migration is a good moment to harden your suite, not just restore it. The result should be fewer brittle assumptions, clearer failure signals, and tests that keep working after the next tooling change.