When a browser test passes on a developer machine but fails in CI, the instinct is often to blame timing. Sometimes that is true. But if the test is using API mocks or network stubs, the more interesting failure mode is local versus CI drift: the test is not exercising the same data, the same timing, or even the same request paths in both environments.

This is where logging matters more than screenshots. If your browser tests fail because of API mocks, the right logs can tell you whether the problem is a stale stub, a mismatched payload shape, a cache issue, a race between route registration and navigation, or a test environment that is quietly bypassing the mock entirely.

The goal is not to log everything. The goal is to log the smallest set of signals that explains why the browser saw different API behavior in CI than it saw locally.

Why mocked browser tests drift between local and CI

Local runs and CI runs differ in ways that are easy to overlook:

  • Different hostnames, origins, and base URLs
  • Different browser engines or versions
  • Different network conditions and startup timing
  • Different environment variables and feature flags
  • Different file system state, including cached fixtures or persisted auth
  • Different test isolation, especially when workers run in parallel

With network stubs, those differences can change what the browser actually requests and what your mock returns. A test can appear deterministic locally because the developer machine has leftover state that makes the stub “work.” In CI, the same test can take a different branch, hit a real backend, or receive a response that no longer matches the UI’s assumptions.

For background on the broader testing and CI concepts involved, the basic definitions of software testing, test automation, and continuous integration are worth keeping in mind, but the practical problem here is narrower: you need evidence about request behavior, stub registration, and response shape.

The first thing to log: request identity

If there is one class of log that pays for itself immediately, it is request identity. You want to know exactly which request the browser made, and whether your mock intercepted it.

Log these fields for every mocked request:

  • Request URL, including query string
  • HTTP method
  • Resource type, if available
  • Request headers that affect routing or auth
  • The test name or scenario label
  • Whether the route handler matched
  • Which stub file, fixture, or handler produced the response
  • Timestamp in both local time and UTC, if you need ordering across systems

A simple Playwright route logger can surface enough detail to identify mismatches quickly:

import { test } from '@playwright/test';

test.beforeEach(async ({ page }, testInfo) => { page.route(‘/api/’, async route => { const request = route.request(); console.log(JSON.stringify({ test: testInfo.title, method: request.method(), url: request.url(), headers: request.headers(), matched: true })); await route.fulfill({ status: 200, contentType: ‘application/json’, body: JSON.stringify({ ok: true }) }); }); });

If the same test passes locally but fails in CI, compare the actual request URL and headers first. Very often the stub is written for /api/users, but CI navigates to /app/api/users, or the request includes a missing Authorization header that causes a different code path.

Log mock registration order, not just mock responses

Many flaky tests register mocks too late. The page can start fetching as soon as it loads, before the route handler is active. Locally, that race may be hidden because the machine is fast or the network is slow enough to let the handler register in time. In CI, the opposite can happen.

Log the lifecycle of mock registration:

  • When the mock layer is initialized
  • Which handlers are registered, in order
  • Which glob or regex each handler uses
  • Whether the handler is page-scoped, context-scoped, or test-scoped
  • Whether the mock is active before navigation starts

A useful pattern is to log a single “mock ready” line before the first page visit:

import { test, expect } from '@playwright/test';
test('dashboard loads', async ({ page }) => {
  await page.route('**/api/session', route => route.fulfill({
    status: 200,
    body: JSON.stringify({ user: { id: 'u1' } })
  }));

console.log(‘mock-ready: session route registered’); await page.goto(‘http://localhost:3000/dashboard’); await expect(page.getByText(‘Dashboard’)).toBeVisible(); });

If CI fails before this line ever appears in the logs, you know the problem is not the response payload. It is handler setup timing or test orchestration.

Record the full response shape, not only status code

A mock can return HTTP 200 and still break the UI if the payload shape differs from what the frontend expects. That is especially common when local fixtures lag behind schema changes.

Log at least the following response details:

  • Status code
  • Content type
  • Response body keys, or a summarized shape
  • Response length
  • Headers that matter, such as cache control or auth hints
  • Any transformation your test layer applies before fulfillment

If full bodies are too noisy, hash them and log the hash alongside a small structural summary. Example:

function summarizeJson(body: unknown) {
  if (body && typeof body === 'object' && !Array.isArray(body)) {
    return Object.keys(body as Record<string, unknown>).sort();
  }
  return typeof body;
}

const payload = { data: [{ id: 1, name: ‘Ada’ }] }; console.log(JSON.stringify({ status: 200, keys: summarizeJson(payload), length: JSON.stringify(payload).length }));

This helps detect a subtle but common failure: the mock returns data locally, but CI gets items, or the nested field names changed from displayName to name. The UI might render an empty state instead of throwing, which makes the failure look like a selector issue rather than a contract issue.

Capture cache state and persistence signals

A test that uses mocked API responses may still fail because the browser is reading a cached response, a persisted auth token, or a stored feature flag from a previous run.

Log these browser state signals:

  • Local storage keys related to auth, feature flags, or API versioning
  • Session storage keys that influence API calls
  • Cookies for the test domain
  • Service worker registration status
  • IndexedDB usage if your app caches API data there
  • Whether cache is disabled or cleared before the test

In Playwright, you can inspect browser storage through the context:

typescript

const storage = await page.context().storageState();
console.log(JSON.stringify({
  cookies: storage.cookies.map(c => c.name),
  origins: storage.origins.map(o => o.origin)
}));

If local passes only because a persisted token or cached feature flag masks the real request path, the mock is not actually proving the UI path you think it is.

For CI flakiness, this is often the missing clue. The test passes locally because the browser already has a token that causes /api/session to be skipped, but CI starts with a clean profile and hits a missing stub.

Log environment variables that affect request routing

Mock behavior can differ because the test runtime changes environment values between local and CI. Log the values that alter request routing or stub selection, such as:

  • BASE_URL
  • API base path variables
  • Feature flag variables
  • Auth mode flags
  • Mock enablement flags
  • Worker count or shard metadata

You do not need to print every environment variable. Focus on the ones that affect network behavior.

A useful minimal pattern in CI is to emit a startup block:

bash printf ‘%s\n’ “BASE_URL=$BASE_URL” “API_MOCKS=$API_MOCKS” “CI=$CI” “PLAYWRIGHT_WORKERS=$PLAYWRIGHT_WORKERS”

If your test suite supports a mock mode and a live mode, log which one is active for each run. A lot of “browser tests fail because of API mocks” incidents are really “the mock flag was set in one environment and not the other.”

Compare the route match decision, not just the response

The most valuable signal for diagnosing network stubs is whether a handler matched the request. When a route does not match, the browser may silently hit the real backend, a fallback handler, or a default 404 page that looks like a front-end bug.

Log the route matching decision with enough context to explain why the handler did or did not apply:

  • Matcher pattern
  • Request URL
  • Whether the route matched exactly or by wildcard
  • Handler priority, if your framework supports it
  • Fallback handler name, if one exists

This is especially important when using overlapping patterns like **/api/** and **/api/session. If the generic handler is registered first, it may swallow the specific one. Locally, the specific case might still work because another test file registers in a different order. In CI, parallelization changes the order and the behavior diverges.

Log browser console errors alongside network activity

Sometimes the browser receives the mocked response correctly, but the frontend cannot parse it or cannot reconcile it with application state. Browser console logs can expose issues that request logs do not.

Capture:

  • Uncaught exceptions
  • Promise rejections
  • JSON parse errors
  • React hydration warnings, if relevant
  • Warnings about duplicate keys, missing props, or undefined values
  • Errors from client-side API wrappers or interceptors

In Playwright:

page.on('console', msg => {
  if (msg.type() === 'error') console.log('console-error:', msg.text());
});
page.on('pageerror', error => {
  console.log('page-error:', error.message);
});

A mock that omits one required field can trigger a rendering failure that looks unrelated to network behavior. The console error is what tells you the UI contract changed.

Log the data contract version or schema fingerprint

If your frontend and mock fixtures evolve independently, you need a way to detect contract drift. A lightweight schema fingerprint can be enough.

You do not need a full schema registry to benefit from this. Log:

  • API version header, if present
  • Response field names
  • Presence or absence of required fields
  • Nullable fields that frequently cause rendering differences
  • Enum values, if the UI branches on them

For example, if a mocked response includes status: "active" locally but CI uses a fixture with status: "enabled", the UI may render different components or skip a button entirely.

A practical approach is to validate fixture shape during test startup, not after the UI fails:

function assertUserPayload(payload: any) {
  if (!payload?.data?.[0]?.id) throw new Error('missing data[0].id');
  if (!payload?.data?.[0]?.name) throw new Error('missing data[0].name');
}

If this throws in CI, you have a clean failure tied to the mock contract, rather than a vague UI timeout.

Log timing around navigation, not just wait conditions

When tests use mocks, request timing often changes because stub responses are faster than real services. That can hide race conditions locally and expose them in CI, or vice versa.

Log these milestones:

  • Time when the mock was registered
  • Time when navigation started
  • Time when the first request was sent
  • Time when the first response arrived
  • Time when the page became interactive, if measured
  • Time when the assertion ran

This helps distinguish three common problems:

  1. The mock was not ready before navigation.
  2. The mock response arrived, but the app had not finished state transitions.
  3. The assertion ran before rendering settled.

If you use waitForLoadState or waitForResponse, log the exact condition you waited on. Many flaky tests are caused by a wait that is technically correct but semantically too weak.

Include CI metadata that explains environment drift

When a failure only happens in CI, logs should identify the runtime context without requiring you to open the job page.

Useful CI metadata includes:

  • Build ID or run ID
  • Job name
  • Browser name and version
  • Shard number
  • Worker count
  • Commit SHA
  • Branch name
  • Container image tag, if relevant
  • Timezone and locale

A small metadata header at the start of each test run is often enough:

node -e "console.log(JSON.stringify({ ci: process.env.CI, sha: process.env.GITHUB_SHA, shard: process.env.PLAYWRIGHT_SHARD, locale: process.env.LANG }))"

This becomes valuable when a failing CI shard shows a different environment than the shard that passed. Sometimes the mock is fine, but one shard starts with a different seed or a different feature flag set.

What to log when a mock falls through to the real backend

The most dangerous failure mode is not an obvious mock error, it is a silent fallback to a real service. That can make local tests appear stable while CI becomes flaky or slow.

Log the following signs of fallback:

  • Any network request that was not intercepted by a route handler
  • Requests to domains outside the expected mock host
  • Non-deterministic response latency
  • Unexpected response headers, especially server identifiers
  • Differences between mock origin and actual origin

If your framework allows it, add a “deny by default” policy in test mode and log every unhandled request. That way, if a test tries to call a live endpoint, the failure is explicit.

A practical logging checklist for mocked browser tests

If you are deciding what to instrument first, start with this order:

  1. Request URL, method, and headers
  2. Mock registration order and handler match result
  3. Response status, content type, and body shape
  4. Browser console errors and page errors
  5. Storage state, cookies, and feature flags
  6. CI metadata, especially browser version and shard info
  7. Timing around navigation and first response

This order is deliberate. It moves from the most likely root causes to the more environment-specific ones.

A sample failure triage flow

When a browser test passes locally but fails in CI, use the logs to answer these questions in sequence:

1. Did the request hit the mock?

If not, inspect route registration timing, matcher specificity, and base URL drift.

2. Did the mock return the expected shape?

If not, compare fixture versions and look for missing or renamed fields.

3. Did the browser preserve unexpected state?

If yes, clear storage, disable stale caches, and re-run in a clean profile.

4. Did the UI error after receiving the mock?

If yes, inspect console errors and schema assumptions in the component.

5. Is the failure isolated to CI timing or parallelism?

If yes, look for worker interactions, shared fixtures, and race conditions in test setup.

This triage flow keeps you from overfitting to the wrong layer. A flaky assertion is not always a bad locator. In mock-heavy suites, it is often a contract or setup issue.

When to keep mocks, and when to reduce them

Mocking is still useful, especially for edge cases and error states that are hard to produce reliably with live dependencies. But if a critical browser test can only pass under a carefully staged mock setup, the suite may be too coupled to implementation details.

Consider reducing mocks when:

  • The mock payload duplicates production contracts too closely and needs frequent maintenance
  • The app behavior depends on many chained requests, which increases route complexity
  • CI failures are caused by differences in fixture order or handler precedence
  • The team cannot easily tell whether a failure comes from the mock layer or the UI layer

In those cases, preserve a smaller set of mock-based tests for edge conditions, and add a separate smoke path that exercises real backend contracts in a controlled environment.

A good log line is a debugging contract

The best logging strategy for browser tests is not verbose output, it is intentional evidence. Each log line should answer one of these questions:

  • What did the browser request?
  • Which mock handled it?
  • What did the mock return?
  • What state influenced the request?
  • What changed between local and CI?

If your suite already emits screenshots, traces, or videos, that is useful. But when API mocks are involved, traces alone can still leave you guessing about route matching and payload drift. The logs you add should make the invisible parts visible.

Closing thought

When browser tests fail because of API mocks, the root cause is often not the mock itself. It is the mismatch between what the test author assumed the browser would do and what the CI environment actually did. The fastest way to close that gap is to log the request path, the mock decision, the response shape, and the state around them.

Once you can see those signals clearly, local versus CI drift stops being a mystery and becomes a traceable system problem.