The Enterprise Frontend Blueprint - PART 3: Testing Strategy for Large Frontend Monorepos

Satish Pednekar

· 37 min read
The testing diamond for frontend monorepos: integration tests with MSW are the widest layer at about 60 percent, with contract tests at 10 percent, end-to-end journeys at 5 percent, and unit tests of pure logic at 25 percent.

Most confidence in a frontend monorepo should come from integration tests that render real features against a mocked network, with contract tests guarding each API boundary and only a handful of end-to-end journeys. Flaky tests are treated as defects, each with an owner, an expiry date, and a place in a team budget. Every pattern here comes with configs you can copy into an Nx workspace, shown on a payments app where a wrong amount or a duplicated transfer has real consequences.

The Enterprise Frontend Blueprint: Testing and Release Confidence

Part 3: Testing Strategy for Large Frontend Monorepos (you are here)

  1. The Cost of Low Confidence
  2. The Monorepo Testing Diamond
  3. Contract Testing: API Guarantees Without Staging
  4. Flaky Tests: Detection, Quarantine, and Ownership
  5. Frontend Security Testing: Abuse Cases Before Merge
  6. AI in Frontend Testing: Where It Helps (Preview)
  7. The Pre-Merge Confidence Ladder
  8. Appendix: Reproduce This Setup

Part 4: Release Confidence: Flags, Canaries, and Rollback (next in the series)

  1. Feature Flags: Separating Deploy from Release
  2. Frontend Deploy Mechanics That Make Rollback Instant
  3. Canary Analysis for the Client Side
  4. Circuit Breakers and Automated Rollback
  5. Where AI Fits in Releases (Preview)
  6. The Release Dashboard and the Full Confidence Ladder
  7. Appendix: Reproduce This Setup

The Cost of Low Confidence

A frontend testing strategy for a large monorepo has one job: make a green build something engineers trust. When they don't, the cost is more than CI minutes. It is the time spent re-running jobs, the focus lost while waiting, and the releases that slip because nobody fully believes the result. This part is about earning that trust back before code is merged.

Most of us know the moment. A payment-status test fails in CI, it passed on the previous run, and you press "Re-run failed jobs" and switch to something else. After a few months of this, a red build means "try again" rather than "something is broken". That shift happens on well-run teams too, and its cost is easy to estimate.

# Rerun cost model (illustrative numbers, replace with your own)
weekly hours lost = engineers x reruns per engineer per week x (wait minutes + refocus minutes) / 60
example           = 40 x 5 x (12 + 15) / 60
                  = 90 hours per week

Ninety hours is more than two engineers' working week. The 15-minute refocus figure is my own estimate, so substitute your team's. Even a conservative version of this number usually gets the problem time on the roadmap.

Confidence comes from the whole delivery system, not only the test suite. Tests reduce how often a bad change reaches main; feature flags, canaries, and rollback limit how far it spreads afterwards. Part 2 promised both in one article, but each needed room, so this part stops at the merge button and Part 4 continues from there.

The examples use a banking monorepo: a transfer form, a payment status page, and a payments API owned by another team. In payments, a duplicated transfer or a rounding error can become a regulatory report, so the examples keep money in integer minor units and send an idempotency key with every payment. The patterns carry over to any product where a wrong write is expensive: an e-commerce checkout, a healthcare record, an admin screen that changes customer permissions.

The Monorepo Testing Diamond

In a large frontend monorepo, most confidence should come from integration tests that render a real feature against a mocked network. Keep a thin layer of unit tests for pure logic, contract tests at each API boundary, and a small number of end-to-end tests for journeys where money moves across apps.

Why the pyramid struggles in a monorepo

The classic pyramid (many unit tests, fewer integration tests, very few end-to-end tests) still suits backend business logic. In frontend monorepos I see two things go wrong.

First, component unit tests with every collaborator mocked mostly confirm that a component renders what the test told it to. Customer-facing bugs tend to live in the wiring: the API client, form state, error mapping, and the screen shown while a request is in flight.

Second, the top of the pyramid grows, because each incident adds another end-to-end test. Those tests span several apps and don't map to one project in the Nx graph, so nx affected can't skip them and every pull request pays for all of them. Size also works against them. Google's analysis of its own CI found that the larger a test is, measured by binary size and memory use, the more likely it is to be flaky. End-to-end tests are the largest tests most of us own.

The diamond, and where it comes from

The shape I recommend is a diamond: a small unit base, a wide integration middle, a contract layer at API boundaries, and a thin end-to-end tip. It builds on Kent C. Dodds' Testing Trophy, which centred integration tests for JavaScript apps, and Spotify's Testing Honeycomb, which argued for as few tests as possible that depend on another live system.

A monorepo adds two things. Testing tools become shared libraries with owners and module boundaries, under the rules from Part 1. And each layer becomes its own Nx target with its own cache inputs, so affected and the remote cache from Part 2 decide what runs.

A diamond of four test layers. The widest layer is integration tests with MSW at about 60 percent of tests, above it contract tests at about 10 percent and end-to-end journeys at about 5 percent, below it unit tests of pure logic at about 25 percent.

The testing diamond. Shares are starting targets, not rules.

In the payments example:

  • Unit: pure logic with no rendering. Money formatting, fee calculation, IBAN checksum validation, and settlement cut-off rules.
  • Integration with MSW: a feature rendered with its real components and its real API client, with the network mocked at the request level. The transfer form, the payment status page, the statements list.
  • Contract: what payments-web sends and expects matches what payments-api provides (next section).
  • End-to-end: sign in, send a transfer, see the confirmation, on a production-like environment. A handful, each tied to a journey the business would page someone about.

This decision tree settles most debates about where a new test belongs.

Decision tree. If the code is pure logic with no UI, write a unit test. Otherwise, if the test checks an API request or response shape, write a contract test. Otherwise, if it is a critical journey across apps, write an end-to-end test. Everything else is an integration test with MSW.

Where a new test belongs.

The shared testing library

The integration layer's tooling lives in three small libraries under libs/payments/testing, tagged scope:payments and type:testing so the module-boundary rules from Part 1 apply to them too. Helpers with no payments knowledge can move to a scope:shared testing library once a second domain needs them.

# Workspace layout for the payments example
libs/
  payments/
    data-access/          # API client, Zod schemas (Part 1), Pact contract specs
    feature-transfer/     # transfer form UI and its integration specs
    testing/
      fixtures/           # data builders: payments, payees
      msw/                # handlers, scenarios, node and browser setup
      drivers/            # feature drivers built on Testing Library
contracts/
  openapi/payments.yaml   # provider-owned spec, synced from the API repo
apps/
  banking-web/
  banking-web-e2e/        # Playwright, critical journeys only

It starts with the Zod schemas from Part 1, which the API client, the fixtures, and the mock handlers all reuse.

// libs/payments/data-access/src/payment.schema.ts (Zod 4)
import { z } from 'zod';

export const PaymentStatusSchema = z.enum(['pending', 'authorized', 'settled', 'failed', 'reversed']);

export const PaymentSchema = z.object({
  id: z.string().regex(/^pmt_[A-Za-z0-9]+$/),
  fromAccountId: z.string(),
  payeeId: z.string(),
  // Money is an integer in minor units (pence, cents) plus an ISO 4217 code.
  // Floating-point amounts are how rounding errors reach customer statements.
  amountMinor: z.number().int().positive(),
  currency: z.string().regex(/^[A-Z]{3}$/),
  status: PaymentStatusSchema,
  reference: z.string().max(18),
  createdAt: z.iso.datetime(),
});

export const CreatePaymentRequestSchema = PaymentSchema.pick({
  fromAccountId: true,
  payeeId: true,
  amountMinor: true,
  currency: true,
  reference: true,
});

export const PayeeSchema = z.object({ id: z.string(), name: z.string() });
export const ApiErrorSchema = z.object({ code: z.string(), message: z.string().optional() });

export type Payment = z.infer<typeof PaymentSchema>;
export type Payee = z.infer<typeof PayeeSchema>;
export type CreatePaymentRequest = z.infer<typeof CreatePaymentRequestSchema>;

Test data comes from a Builder. The pattern fits because a test states only the fields it cares about while every other field gets a valid default, and build() runs the production schema, so a drifting fixture fails where it was created. Each builder gets its own seeded generator, because shared random state is a common cause of order-dependent failures.

// libs/payments/testing/fixtures/src/payment.builder.ts
import { Faker, en } from '@faker-js/faker';
import { PaymentSchema, type Payment } from '@acme/payments/data-access';

// Fixtures use obviously fake identifiers. Never copy real account numbers,
// card numbers, or customer names into test data, even "just for a quick test".
export class PaymentBuilder {
  private data: Payment;

  // One seeded generator per builder, so output never depends on test order.
  constructor(seed = 1) {
    const faker = new Faker({ locale: [en] });
    faker.seed(seed);
    this.data = {
      id: `pmt_${faker.string.alphanumeric(10)}`,
      fromAccountId: 'acc_test_001',
      payeeId: 'payee_test_001',
      amountMinor: faker.number.int({ min: 100, max: 500_000 }),
      currency: 'GBP',
      status: 'pending',
      reference: `INV-${faker.number.int({ min: 1000, max: 9999 })}`,
      createdAt: '2026-09-22T10:15:00.000Z',
    };
  }

  withId(id: string): this {
    this.data = { ...this.data, id };
    return this;
  }

  withStatus(status: Payment['status']): this {
    this.data = { ...this.data, status };
    return this;
  }

  withAmount(amountMinor: number, currency = 'GBP'): this {
    this.data = { ...this.data, amountMinor, currency };
    return this;
  }

  withPayee(payeeId: string): this {
    this.data = { ...this.data, payeeId };
    return this;
  }

  // Parsing here turns a stale fixture into a failure at the point of creation.
  build(): Payment {
    return PaymentSchema.parse(this.data);
  }
}

export const aPayment = (seed?: number) => new PaymentBuilder(seed);

The default MSW handlers describe the API on a normal day. They validate request bodies against the real schema and enforce the documented idempotency-key rule, so a wrong shape or a missing key fails the test instead of passing against a mock that accepts anything.

// libs/payments/testing/msw/src/handlers/payments.handlers.ts (MSW 2.x)
import { http, HttpResponse } from 'msw';
import { CreatePaymentRequestSchema } from '@acme/payments/data-access';
import { aPayment } from '@acme/payments/testing/fixtures';

export const paymentsHandlers = [
  http.get('/api/v1/payees', () =>
    HttpResponse.json({
      items: [
        { id: 'payee_test_001', name: 'Northwind Utilities' },
        { id: 'payee_test_002', name: 'Contoso Lettings' },
      ],
    }),
  ),

  http.get('/api/v1/payments', () =>
    HttpResponse.json({
      items: [aPayment(1).withStatus('settled').build(), aPayment(2).build()],
    }),
  ),

  http.get('/api/v1/payments/:id', ({ params }) =>
    HttpResponse.json(aPayment().withId(String(params.id)).build()),
  ),

  http.post('/api/v1/payments', async ({ request }) => {
    // Mirrors the provider's documented rule: no key, no payment.
    if (!request.headers.get('Idempotency-Key')) {
      return HttpResponse.json({ code: 'missing_idempotency_key' }, { status: 400 });
    }
    // parse() throws on a malformed body; MSW turns the throw into a 500,
    // so a wrong request shape fails the test instead of passing silently.
    const body = CreatePaymentRequestSchema.parse(await request.json());
    const payment = aPayment().withAmount(body.amountMinor, body.currency).withPayee(body.payeeId).build();
    return HttpResponse.json(payment, { status: 201 });
  }),
];

Scenarios live in a Registry, a typed map of named handler overrides per domain. The pattern fits because tests, Storybook, and developers reproducing bugs all need the same situations (insufficient funds, a duplicate request, a slow API), and a registry gives each one name and one implementation.

// libs/payments/testing/msw/src/scenarios.ts
import { delay, http, HttpResponse, type RequestHandler } from 'msw';
import { aPayment } from '@acme/payments/testing/fixtures';
import { paymentsSecurity } from './security.scenarios'; // added in the security section

type PaymentScenario =
  | 'happy' | 'empty' | 'insufficient-funds' | 'duplicate-request'
  | 'error-500' | 'slow' | 'unauthorized';

const payments: Record<PaymentScenario, RequestHandler[]> = {
  happy: [],
  empty: [http.get('/api/v1/payments', () => HttpResponse.json({ items: [] }))],
  'insufficient-funds': [
    http.post('/api/v1/payments', () =>
      HttpResponse.json(
        { code: 'insufficient_funds', message: 'Available balance is lower than the payment amount.' },
        { status: 422 },
      ),
    ),
  ],
  'duplicate-request': [
    http.post('/api/v1/payments', () =>
      HttpResponse.json({ code: 'idempotency_key_reused' }, { status: 409 }),
    ),
  ],
  'error-500': [http.all('/api/v1/*', () => HttpResponse.json({ code: 'internal' }, { status: 500 }))],
  slow: [
    http.post('/api/v1/payments', async () => {
      await delay(2500);
      return HttpResponse.json(aPayment().build(), { status: 201 });
    }),
  ],
  unauthorized: [http.all('/api/v1/*', () => HttpResponse.json({ code: 'unauthorized' }, { status: 401 }))],
};

export const scenarios = { payments, paymentsSecurity };
type Scenarios = typeof scenarios;

// Accepts the Node server or the browser worker; both expose use().
export function applyScenario<D extends keyof Scenarios>(
  target: { use: (...handlers: RequestHandler[]) => void },
  domain: D,
  name: keyof Scenarios[D],
): void {
  target.use(...(scenarios[domain][name] as RequestHandler[]));
}

The same handlers serve tests (Node) and local development or Storybook (browser). The library's index exports only the Node side; give the browser entry its own path alias, @acme/payments/testing/msw/browser, so Node never loads msw/browser.

// libs/payments/testing/msw/src/index.ts (Node entry for tests)
export { server } from './node';
export { applyScenario, scenarios } from './scenarios';
export { paymentsHandlers } from './handlers/payments.handlers';
// libs/payments/testing/msw/src/node.ts
import { setupServer } from 'msw/node';
import { paymentsHandlers } from './handlers/payments.handlers';

export const server = setupServer(...paymentsHandlers);
// libs/payments/testing/msw/src/browser.ts
// Start it from your dev bootstrap or Storybook preview, never from production code:
//   await worker.start({ onUnhandledRequest: 'bypass' });
import { setupWorker } from 'msw/browser';
import { paymentsHandlers } from './handlers/payments.handlers';

export const worker = setupWorker(...paymentsHandlers);

Each line of the setup file prevents a specific false result.

// libs/payments/testing/msw/src/vitest.setup.ts
import { afterAll, afterEach, beforeAll } from 'vitest';
import { server } from './node';

// 'error' fails any test that makes a request without a handler, so a new
// endpoint can't quietly hit the network or resolve to undefined.
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));

// Scenario overrides must not leak into the next test. This matters even more
// when test isolation is disabled, which is Angular's Vitest default.
afterEach(() => server.resetHandlers());

afterAll(() => server.close());

Vitest projects split unit and integration tests so they can become separate Nx targets.

// libs/payments/feature-transfer/vitest.config.ts (Vitest 3.2+ "projects")
// Angular workspaces: use the @angular/build:unit-test builder instead (see note below).
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    projects: [
      {
        extends: true,
        test: {
          name: 'unit',
          environment: 'node',
          include: ['src/**/*.spec.ts'],
          exclude: ['src/**/*.integration.spec.ts'],
        },
      },
      {
        extends: true,
        test: {
          name: 'integration',
          environment: 'jsdom',
          include: ['src/**/*.integration.spec.ts'],
          setupFiles: ['../testing/msw/src/vitest.setup.ts'],
        },
      },
    ],
  },
});
A note for Angular and Nx workspaces. Since Angular 21, Vitest is the default unit test runner, through the @angular/build:unit-test builder with include and setupFiles options. That builder disables test isolation by default for speed, so spec files share module state, and resetHandlers() plus per-builder seeds are what keep tests independent.

Specs talk to the feature through a Component Driver, the page-object idea applied to one feature. The pattern fits because specs describe what a customer does while the driver owns the DOM details, so a markup change touches one file. It uses Testing Library role and label queries, available for React, Angular, Vue, and Svelte.

// libs/payments/testing/drivers/src/transfer.driver.ts
import { screen } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';

export class TransferDriver {
  private readonly user = userEvent.setup();

  async choosePayee(name: string): Promise<void> {
    await screen.findByRole('option', { name }); // payees load asynchronously
    await this.user.selectOptions(screen.getByLabelText(/payee/i), name);
  }

  async fillAmount(value: string): Promise<void> {
    const input = screen.getByLabelText(/amount/i);
    await this.user.clear(input);
    await this.user.type(input, value);
  }

  async submit(): Promise<void> {
    await this.user.click(this.submitButton());
  }

  isSubmitDisabled(): boolean {
    return this.submitButton().hasAttribute('disabled');
  }

  amountValue(): string {
    return (screen.getByLabelText(/amount/i) as HTMLInputElement).value;
  }

  async statusText(): Promise<string> {
    return (await screen.findByRole('status')).textContent?.trim() ?? '';
  }

  async alertText(): Promise<string> {
    return (await screen.findByRole('alert')).textContent?.trim() ?? '';
  }

  private submitButton(): HTMLElement {
    return screen.getByRole('button', { name: /send payment/i });
  }
}

Mounting is the only framework-specific piece. It lives next to the feature's specs rather than in the drivers library, because a drivers library that imports the feature would create a circular dependency once the feature's specs import the drivers. Here are the Angular and React versions; Vue and Svelte follow the same shape with their own Testing Library render.

// libs/payments/feature-transfer/src/testing/mount-transfer-form.ts (Angular, sketch)
import { render } from '@testing-library/angular';
import { TransferFormComponent } from '../transfer-form.component';

// The component receives the real payments client through DI.
// Only the network underneath is mocked, by MSW.
export async function mountTransferForm(): Promise<void> {
  await render(TransferFormComponent);
}
// libs/payments/feature-transfer/src/testing/mount-transfer-form.tsx (React, sketch)
import { render } from '@testing-library/react';
import { TransferForm } from '../transfer-form';

// The form creates its payments client as it does in production.
// Only the network underneath is mocked, by MSW.
export async function mountTransferForm(): Promise<void> {
  render(<TransferForm />);
}

The resulting spec reads like a description of the feature. Beyond the happy path, it covers two behaviours that matter in payments: an insufficient-funds error must keep what the customer typed, and a second submission must be impossible while the first is in flight.

// libs/payments/feature-transfer/src/transfer-form.integration.spec.ts
import { describe, expect, it } from 'vitest';
import { applyScenario, server } from '@acme/payments/testing/msw';
import { TransferDriver } from '@acme/payments/testing/drivers';
import { mountTransferForm } from './testing/mount-transfer-form';

describe('Transfer form', () => {
  it('confirms a submitted payment', async () => {
    await mountTransferForm();
    const transfer = new TransferDriver();

    await transfer.choosePayee('Northwind Utilities');
    await transfer.fillAmount('250.00');
    await transfer.submit();

    expect(await transfer.statusText()).toMatch(/payment submitted/i);
  });

  it('explains insufficient funds and keeps the entered amount', async () => {
    applyScenario(server, 'payments', 'insufficient-funds');
    await mountTransferForm();
    const transfer = new TransferDriver();

    await transfer.choosePayee('Northwind Utilities');
    await transfer.fillAmount('250.00');
    await transfer.submit();

    expect(await transfer.alertText()).toMatch(/available balance/i);
    expect(transfer.amountValue()).toBe('250.00');
  });

  it('blocks a second submission while the first is in flight', async () => {
    applyScenario(server, 'payments', 'slow');
    await mountTransferForm();
    const transfer = new TransferDriver();

    await transfer.choosePayee('Northwind Utilities');
    await transfer.fillAmount('250.00');
    await transfer.submit();

    expect(transfer.isSubmitDisabled()).toBe(true);
  });
});

Cache inputs must include everything that can change a test's result. Teams usually forget files outside any project, such as the OpenAPI spec, and then Nx replays a cached pass after the contract has changed: the "fast but dishonest" pipeline Part 2 warned about.

{
  "name": "payments-feature-transfer",
  "projectType": "library",
  "tags": ["scope:payments", "type:feature"],
  "targets": {
    "test-unit": {
      "executor": "nx:run-commands",
      "options": { "command": "vitest run --project unit", "cwd": "{projectRoot}" }
    },
    "test-integration": {
      "executor": "nx:run-commands",
      "options": { "command": "vitest run --project integration", "cwd": "{projectRoot}" }
    }
  }
}

Angular workspaces express the same split through the builder's include option.

{
  "targets": {
    "test-integration": {
      "executor": "@angular/build:unit-test",
      "options": {
        "include": ["src/**/*.integration.spec.ts"],
        "setupFiles": ["libs/payments/testing/msw/src/vitest.setup.ts"]
      }
    }
  }
}
{
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "production": ["default", "!{projectRoot}/**/*.spec.ts", "!{projectRoot}/vitest.config.ts"],
    "sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"],
    "apiContract": ["{workspaceRoot}/contracts/openapi/**/*"]
  },
  "targetDefaults": {
    "test-unit": {
      "cache": true,
      "inputs": ["default", "^production"]
    },
    "test-integration": {
      "cache": true,
      "inputs": ["default", "^production", "apiContract"]
    },
    "test-contract": {
      "cache": true,
      "inputs": ["default", "^production", "apiContract"],
      "outputs": ["{projectRoot}/pacts"]
    },
    "e2e": {
      "cache": false
    }
  }
}

End-to-end runs aren't cached because Nx can't hash the environment. The contract target declares pacts as an output, so a cache hit still restores the file CI publishes. If your cache hit ratio looks healthy but you suspect it is hiding stale results, the cache-observability approach in Mastering Enterprise-Scale Monorepo Builds (Vol. 2) applies directly to test targets.

Not on Nx? The same rules apply in Turborepo: list the OpenAPI spec and fixture folders under inputs for each test task in turbo.json, declare pacts/** as an output of the contract task, and run only what changed with turbo run test-integration --filter=...[origin/main]. Everything else in this part (MSW, Pact, Playwright, the quarantine file) is independent of the monorepo tool.

How the layers compare

LayerSpeed and flake riskCatchesBlind toFit with nx affected
UnitMilliseconds, very lowCalculation and validation bugsWiring, rendering, API driftExcellent, one project each
Integration with MSWSeconds, lowFeature behaviour, error states, double submitsReal API changes, cross-app flowsExcellent, runs per feature
Contract (Pact)Seconds, very lowRequest and response drift between teamsUI behaviourGood, runs per data-access lib
Visual regressionSeconds to minutes, mediumLayout and styling regressionsLogic and data errorsFair, needs stable baselines
End-to-endMinutes, highestEnvironment and multi-app wiringSlow to localize failuresPoor, usually runs every time

Typical failure modes

Failure modeSymptomHow to detect itMitigation
Mock driftTests green, production shows parse errorsFixture fails schema parseBuild fixtures through the production schema
Handler leakageA test passes alone, fails in the full runRun specs in random orderresetHandlers after each test
Over-mockingRefactors break many tests with no bugMocks of your own modules in specsMock only the network, never your own code
Unhandled requestTest passes with undefined dataonUnhandledRequest set to errorAdd a handler for every endpoint used
All green, prod brokenMocks encode the frontend's assumptionsContract tests fail on the same fieldPair MSW with contract tests (next section)

Recommendation

Shift investment from isolated component tests and long end-to-end suites into MSW integration tests built on shared fixtures, scenarios, and drivers. If you can do one thing this sprint, turn on onUnhandledRequest: 'error' and parse fixtures through your Zod schemas. Measure progress by pull-request feedback time and by how many production bugs each layer could have caught.

Contract Testing: API Guarantees Without Staging

Contract tests check that the requests your frontend sends, and the responses it expects, match what the API actually provides, without deploying either side to a shared environment. They run in seconds, fail in the pull request that caused the break, and can replace most end-to-end tests that exist only to catch API drift.

Why end-to-end is an expensive way to check an API

Suppose the payments team renames amountMinor to amount_minor. An end-to-end test catches it only after both sides reach staging, often one that depends on slow, rate-limited card-scheme or core-banking sandboxes, and it fails as a timeout with nothing pointing at the field. A contract test catches it in the pull request that made the change and names the field.

Part 1 opened with a version of this: a backend migration started returning null for avatarUrl, nobody told the frontend team, and new users saw a white screen after a Friday deploy. Runtime validation turned that into a clear, reported error. A contract test would have stopped the backend change in its own pipeline, before it deployed.

MSW tests can't catch this alone, because the mocks encode what the frontend believes, and they keep agreeing with the frontend after the provider changes.

Choosing an approach

ApproachFits whenProvider team effortWatch out for
Consumer-driven (Pact)Provider will run verification in its CIMedium: provider states and a verification jobProvider states that drift from real data
Bi-directional (OpenAPI plus Pact, PactFlow)Provider publishes an accurate OpenAPI spec but won't run consumer testsLow: publish the specA spec that doesn't match the running API
Schema diff only (oasdiff)You mainly need to block breaking spec changesLow: keep the spec in version controlCatches spec changes, not behaviour
gRPC (buf breaking)Frontend talks gRPC-web or through a gRPC BFFLowField numbers and reserved ranges
GraphQL schema checksFrontend uses GraphQLLow: publish the schemaChecks operations against the schema, not resolvers

In my experience the deciding factor is the provider team's capacity. Consumer-driven contracts need them to run verification and maintain provider states; if they can, it's the strongest option. If they can only publish a spec this quarter, a schema-based check still catches most breaking changes, and nothing is wasted when you add Pact later.

Consumer-driven contracts with Pact

The contract test exercises the feature's real API client, which validates every response with the same safeParse-and-report pattern as Part 1's validatedGet, so it also proves the Part 1 schema accepts what the provider returns.

// libs/payments/data-access/src/payments-client.ts
import type { z } from 'zod';
import { ApiContractError, reportContractViolation } from '@acme/shared/api-client'; // from Part 1
import { ApiErrorSchema, PaymentSchema, type CreatePaymentRequest, type Payment } from './payment.schema';

export class PaymentsApiError extends Error {
  constructor(readonly status: number, readonly code: string) {
    super(code);
  }
}

// Same safeParse-report-throw pattern as Part 1's validatedGet.
function validate<T extends z.ZodType>(schema: T, data: unknown, endpoint: string): z.infer<T> {
  const result = schema.safeParse(data);
  if (!result.success) {
    reportContractViolation(endpoint, result.error);
    throw new ApiContractError(`Contract violation: ${endpoint}`, result.error);
  }
  return result.data;
}

// Framework-agnostic fetch client; Angular apps can wrap it in a service.
export function createPaymentsClient(baseUrl = globalThis.location?.origin ?? '') {
  async function send(path: string, init?: RequestInit): Promise<unknown> {
    const res = await fetch(`${baseUrl}${path}`, init);
    const body: unknown = await res.json();
    if (!res.ok) {
      const error = ApiErrorSchema.safeParse(body);
      throw new PaymentsApiError(res.status, error.success ? error.data.code : 'unknown_error');
    }
    return body;
  }

  return {
    async createPayment(input: CreatePaymentRequest, idempotencyKey: string): Promise<Payment> {
      const body = await send('/api/v1/payments', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey },
        body: JSON.stringify(input),
      });
      return validate(PaymentSchema, body, 'POST /api/v1/payments');
    },
    async getPayment(id: string): Promise<Payment> {
      const body = await send(`/api/v1/payments/${encodeURIComponent(id)}`);
      return validate(PaymentSchema, body, 'GET /api/v1/payments/:id');
    },
  };
}

The test uses matchers for shapes and formats instead of exact values, which are the most common cause of brittle contracts. Each given line is a provider state: a named precondition the provider sets up before replaying the request.

// libs/payments/data-access/src/payments.pact.spec.ts (Pact JS, V4 spec)
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { MatchersV3, PactV4 } from '@pact-foundation/pact';
import { createPaymentsClient, PaymentsApiError } from './payments-client';

const { datetime, integer, like, regex } = MatchersV3;
const UUID = '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$';
const KEY = '8f14e45f-ceea-4e6a-9f3a-1d2c3b4a5e6f';
const input = { fromAccountId: 'acc_001', payeeId: 'payee_77', amountMinor: 25000, currency: 'GBP', reference: 'INV-1042' };

const pact = new PactV4({
  consumer: 'payments-web',
  provider: 'payments-api',
  dir: path.resolve(process.cwd(), 'pacts'),
});

describe('payments-api contract', () => {
  it('creates a payment when funds are available', async () => {
    await pact
      .addInteraction()
      .given('account acc_001 has an available balance of at least 25000 minor units')
      .uponReceiving('a request to create a GBP payment')
      .withRequest('POST', '/api/v1/payments', (builder) => {
        builder.headers({ 'Content-Type': 'application/json', 'Idempotency-Key': regex(UUID, KEY) });
        builder.jsonBody({ ...input, amountMinor: integer(25000), reference: like('INV-1042') });
      })
      .willRespondWith(201, (builder) => {
        builder.headers({ 'Content-Type': 'application/json' });
        builder.jsonBody({
          id: regex('^pmt_[A-Za-z0-9]+$', 'pmt_9f2c1a7b3d'), amountMinor: integer(25000),
          fromAccountId: like('acc_001'), payeeId: like('payee_77'),
          currency: regex('^[A-Z]{3}$', 'GBP'), status: regex('^(pending|authorized)$', 'pending'),
          reference: like('INV-1042'), createdAt: datetime("yyyy-MM-dd'T'HH:mm:ss.SSSX", '2026-09-22T10:15:00.000Z'),
        });
      })
      .executeTest(async (mockServer) => {
        const payment = await createPaymentsClient(mockServer.url).createPayment(input, KEY);
        expect(payment.status).toBe('pending');
      });
  });

  it('rejects a payment larger than the available balance', async () => {
    await pact
      .addInteraction()
      .given('account acc_001 has an available balance below 25000 minor units')
      .uponReceiving('a request to create a payment that exceeds the balance')
      .withRequest('POST', '/api/v1/payments', (builder) => {
        builder.headers({ 'Content-Type': 'application/json', 'Idempotency-Key': regex(UUID, KEY) });
        builder.jsonBody(input);
      })
      .willRespondWith(422, (builder) => {
        builder.jsonBody({ code: 'insufficient_funds', message: like('Available balance is lower than the payment amount.') });
      })
      .executeTest(async (mockServer) => {
        const call = createPaymentsClient(mockServer.url).createPayment(input, KEY);
        await expect(call).rejects.toBeInstanceOf(PaymentsApiError);
      });
  });
});

Run contract specs as their own target with no MSW setup file loaded. Otherwise MSW intercepts calls to Pact's mock server and fails them, with an error that points at MSW rather than at the configuration.

{
  "name": "payments-data-access",
  "projectType": "library",
  "tags": ["scope:payments", "type:data-access"],
  "targets": {
    "test-contract": {
      "executor": "nx:run-commands",
      "options": { "command": "vitest run pact.spec --environment node", "cwd": "{projectRoot}" }
    }
  }
}

In CI, the consumer publishes its pact with the commit SHA and branch, then asks the broker whether that version can deploy; --retry-while-unknown waits for provider verification to finish. The commands follow the Pact can-i-deploy documentation, which prefers recorded deployments over the older tags.

# .github/workflows/contracts.yml
name: contracts
on:
  pull_request:
  push:
    branches: [main]

env:
  PACT_BROKER_BASE_URL: ${{ vars.PACT_BROKER_BASE_URL }}
  PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
  VERSION: ${{ github.event.pull_request.head.sha || github.sha }}
  BRANCH: ${{ github.head_ref || github.ref_name }}
  # Pinned through a repository variable; Part 2's rule of no floating tags applies here too.
  PACT_CLI: pactfoundation/pact-cli:${{ vars.PACT_CLI_TAG }}

jobs:
  consumer-contracts:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: pnpm/action-setup@v4
        with:
          version: 10.17.1 # pinned, as in Part 2
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm nx run payments-data-access:test-contract

      - name: Publish pacts
        run: >
          docker run --rm -v "$PWD:$PWD" -w "$PWD"
          -e PACT_BROKER_BASE_URL -e PACT_BROKER_TOKEN "$PACT_CLI"
          publish libs/payments/data-access/pacts
          --consumer-app-version "$VERSION" --branch "$BRANCH"

      - name: Can payments-web deploy to production?
        run: >
          docker run --rm -e PACT_BROKER_BASE_URL -e PACT_BROKER_TOKEN "$PACT_CLI"
          broker can-i-deploy --pacticipant payments-web --version "$VERSION"
          --to-environment production --retry-while-unknown 12 --retry-interval 10

After a production deploy, record which version is live. That record lets the provider's own can-i-deploy check block a backend change that would break the frontend in production.

# .github/workflows/deploy.yml (excerpt): add after the production deploy step.
# Assumes the same PACT_* and VERSION env values as contracts.yml.
- name: Record deployment in the Pact Broker
  run: >
    docker run --rm -e PACT_BROKER_BASE_URL -e PACT_BROKER_TOKEN "$PACT_CLI"
    broker record-deployment --pacticipant payments-web
    --version "$VERSION" --environment production

On the provider side, a broker webhook for contract_requiring_verification_published triggers verification whenever a changed pact arrives.

Sequence diagram. The frontend pull request publishes a pact to the Pact Broker. The broker triggers provider verification in the payments API pipeline, which reports the result back. The frontend asks the broker can-i-deploy and receives yes or no, which decides whether the pull request merges.

Consumer-driven contract flow between payments-web and payments-api.

The schema-first loop

With an OpenAPI spec, two cheap checks cover a lot of ground. The first blocks breaking spec changes in their own pull request. Note that the oasdiff action's include-checks input was deprecated in 2026 in favour of severity levels, so older tutorials no longer match.

# .github/workflows/openapi-breaking.yml
name: openapi-breaking
on:
  pull_request:
    paths: ['contracts/openapi/**']

jobs:
  breaking:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: oasdiff/oasdiff-action/breaking@v0.1.13
        with:
          base: origin/${{ github.base_ref }}:contracts/openapi/payments.yaml
          revision: contracts/openapi/payments.yaml
          fail-on: ERR

The second closes a gap Part 1 admitted to, where hand-written Zod schemas mirrored a spec the backend already had. Orval generates the typed client, Zod schemas, and MSW handlers from the spec, so mocks can't describe endpoints the spec lacks. Orval 8 is ESM-only and needs Node 22.18+. Generated handlers return random data, so keep builders and scenarios for anything a test asserts on.

// orval.config.ts (Orval 8)
import { defineConfig } from 'orval';

const spec = './contracts/openapi/payments.yaml';
const endpoints = './libs/payments/data-access/src/generated/endpoints';

export default defineConfig({
  paymentsClient: {
    input: { target: spec },
    output: {
      mode: 'tags-split',
      client: 'fetch', // 'angular' generates HttpClient services instead
      target: endpoints,
      schemas: './libs/payments/data-access/src/generated/model',
      mock: true, // emits *.msw.ts handlers next to each endpoint file
    },
  },
  paymentsZod: {
    input: { target: spec },
    output: {
      mode: 'tags-split',
      client: 'zod',
      target: endpoints,
      fileExtension: '.zod.ts',
    },
  },
});
Worth experimenting with: Field-access tracing. Wrap each parsed API response in a Proxy during integration tests and record which fields the UI actually reads. After a run, you have a list of fields the frontend depends on, which is a better starting point for a consumer contract than guessing. The risk is a Proxy changing behaviour for code that checks object identity, so apply it only in tests. For a one-sprint trial, compare the traced field list with your current Pact expectations and count fields the UI reads that no contract covers.

Typical failure modes

Failure modeSymptomHow to detect itMitigation
Over-specified contractVerification fails when provider test data changesExact values in response bodiesUse type and regex matchers
Rotting provider statesProvider team skips or stubs failing statesVerification pending for daysReview provider states with both teams quarterly
Unowned verification failuresRed results nobody acts onFailures older than one sprintRoute broker notifications to CODEOWNERS
Version skewcan-i-deploy passes but production breaksDeployments not recordedRun record-deployment in every environment
MSW intercepts PactContract test fails with an unhandled requestError mentions MSWSeparate Vitest project without MSW setup

Recommendation

Start with the schema-diff check this week, because it needs nothing from the provider team except a spec in version control. Add consumer-driven contracts for the two or three endpoints where a break costs the most (in payments, creating a payment and reading its status), and agree on who owns verification failures before the first one appears. If one team owns both the frontend and the API, or the API is a BFF that ships in the same deploy, contract tests add little over good integration tests, and your time is better spent elsewhere. Track two numbers: how many API-drift incidents reach staging, and how many end-to-end tests you can delete because a contract now covers them.

Flaky Tests: Detection, Quarantine, and Ownership

A flaky test passes and fails on the same code. Treat each one as a defect with an owner: detect it automatically, move it out of the blocking path with an expiry date, and fix or delete it before that date. Retries alone hide whether the problem is in the test, the product, or the environment.

What the published data shows

Retries feel kind because they unblock the person waiting, but they hide the trend. Google's testing team defined a flaky result as one that both passes and fails with the same code, reported that about 1.5% of all their test runs were flaky, and found some flakiness in almost 16% of their tests. Slack's engineering team described a main branch that passed only about 20% of the time before they automated detection and suppression, after which test-job failures fell from 57% to under 5%.

Nx Cloud applies the same definition: a task that fails and then succeeds with identical input hashes is marked flaky, retried on another agent, and unflagged after two weeks without incidents. Detection is the easier half; deciding who fixes a test, and by when, needs a policy.

Root causes

CauseSymptomHow to detect itFix
Async raceFails when CI is slower than a laptopFails more on smaller runnersWait for state (findBy, expect.poll), never for time
Shared state and test orderPasses alone, fails in the full runRun with shuffled orderReset handlers, stores, and seeds per test
Mock leakageA scenario from one test appears in anotherFailures follow a specific earlier testresetHandlers after each test
Time, timezone, cut-offsFails near midnight, month end, or DSTFailures cluster by time of dayFix timezoneId and control the clock
Asynchronous statusPayment still pending when assertedFails on shared staging onlyStub the status sequence, advance the clock
AnimationsScreenshot or click lands mid-transitionVisual diffs on moving elementsreducedMotion and disabled animations
Real third-party callsFraud-scoring or analytics script times outNetwork errors to external hostsBlock or stub third-party hosts in tests
CPU contentionTimeouts on large parallel runsCorrelates with worker countFewer workers per runner, or bigger runners
Shared test dataBalances or payees change mid-runFails only when shards run togetherUnique accounts per worker
Cached flaky pass (Nx)Flake seen once, then never againCache hits on a known-flaky targetKeep flaky tests out of cached targets until fixed

Time is the cause I see most often in payments code: settlement cut-offs, bank holidays, and payment statuses that change asynchronously. The spec below freezes the clock one minute after a 17:30 London cut-off, then replays a status that moves from pending to settled without any real waiting.

// apps/banking-web-e2e/src/payments/cut-off-and-status.spec.ts
import { expect, test } from '@playwright/test';

// playwright.config.ts sets timezoneId: 'Europe/London', so these times mean
// London time on every CI runner. September dates are BST (UTC+1).
test.describe('Payment timing', () => {
  test('a payment after the cut-off shows the next business day', async ({ page }) => {
    await page.clock.setFixedTime(new Date('2026-09-25T16:31:00Z')); // Friday 17:31 London
    await page.goto('/payments/new');
    await expect(page.getByText(/arrives monday 28 september/i)).toBeVisible();
  });

  test('status polling reaches settled without real waiting', async ({ page }) => {
    await page.clock.install({ time: new Date('2026-09-22T09:00:00Z') });
    const statuses = ['pending', 'pending', 'settled'];

    await page.route('**/api/v1/payments/pmt_test0001', (route) =>
      route.fulfill({
        json: {
          id: 'pmt_test0001',
          fromAccountId: 'acc_test_001',
          payeeId: 'payee_test_001',
          amountMinor: 25000,
          currency: 'GBP',
          status: statuses.shift() ?? 'settled',
          reference: 'INV-1042',
          createdAt: '2026-09-22T09:00:00.000Z',
        },
      }),
    );

    await page.goto('/payments/pmt_test0001');
    await expect(page.getByRole('status')).toHaveText(/pending/i);

    // The page polls every 5 seconds. Advance the clock instead of sleeping.
    await page.clock.runFor(10_000);
    await expect(page.getByRole('status')).toHaveText(/settled/i);
  });
});

Quarantine as data

Quarantine works best as a data file the Playwright config reads, rather than test.skip edits scattered through specs. Entries arrive through normal pull requests, CODEOWNERS routes review, and a schema requires an owner, a ticket, and an expiry date. An expired entry becomes blocking again, so the list can't become a place where tests are forgotten.

failOnFlakyTests, added in Playwright 1.52, is a run-level setting, so the blocking and quarantine suites run as separate CI jobs. Introduce it gently: leave PW_BLOCKING unset for two weeks so flaky results are reported without failing builds, share the list with each team, then turn it on.

// apps/banking-web-e2e/playwright.config.ts (Playwright 1.61+)
// Run from the project root (Nx sets cwd to {projectRoot}).
import { readFileSync } from 'node:fs';
import { defineConfig, devices } from '@playwright/test';

type QuarantineEntry = { title: string; owner: string; ticket: string; expiresAt: string };
const { entries } = JSON.parse(readFileSync('tests/quarantine.json', 'utf8')) as { entries: QuarantineEntry[] };

const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Expired entries drop out, so the test blocks again until someone acts on it.
const active = entries.filter((entry) => new Date(entry.expiresAt) > new Date());
const quarantined = active.length > 0 ? new RegExp(active.map((e) => escape(e.title)).join('|')) : /$^/;

export default defineConfig({
  testDir: './src',
  retries: process.env.CI ? 1 : 0, // one retry lets Playwright label a result as flaky
  failOnFlakyTests: process.env.PW_BLOCKING === '1',
  reporter: [['list'], ['json', { outputFile: 'results/report.json' }]],
  use: {
    baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:4200',
    trace: 'on-first-retry',
    timezoneId: 'Europe/London',
    locale: 'en-GB',
    reducedMotion: 'reduce',
  },
  expect: { toHaveScreenshot: { animations: 'disabled' } },
  projects: [
    { name: 'blocking', use: { ...devices['Desktop Chrome'] }, grepInvert: quarantined },
    { name: 'quarantine', use: { ...devices['Desktop Chrome'] }, grep: quarantined },
  ],
});

Both projects run in CI, in separate jobs. The quarantine job never blocks, and both upload their JSON reports so the detection script below has history to work with. PW_BLOCKING is a repository variable, so switching from report-only to enforcing needs no code change.

# .github/workflows/e2e.yml
name: e2e
on:
  pull_request:
  push:
    branches: [main]

env:
  E2E_BASE_URL: ${{ vars.E2E_PREVIEW_URL }}

jobs:
  e2e:
    strategy:
      fail-fast: false
      matrix:
        project: [blocking, quarantine]
    runs-on: ubuntu-latest
    # The quarantine suite reports but never fails the pull request.
    continue-on-error: ${{ matrix.project == 'quarantine' }}
    env:
      PW_BLOCKING: ${{ matrix.project == 'blocking' && vars.PW_BLOCKING || '0' }}
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 10.17.1
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm exec playwright install --with-deps chromium
      - name: Run ${{ matrix.project }} suite
        working-directory: apps/banking-web-e2e
        run: pnpm exec playwright test --project=${{ matrix.project }} --pass-with-no-tests
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: e2e-${{ matrix.project }}-${{ github.sha }}-${{ github.run_attempt }}
          path: apps/banking-web-e2e/results/report.json
          retention-days: 14

Playwright matches grep against the project name, file name, describe titles, and test title joined by spaces, so each title below is the describe title followed by the test title.

{
  "$schema": "./quarantine.schema.json",
  "entries": [
    {
      "title": "Payment status shows settled after polling",
      "file": "src/payments/status.spec.ts",
      "owner": "@acme/payments-web",
      "ticket": "PAY-2141",
      "reason": "Polling races with status updates on shared staging",
      "addedAt": "2026-09-15",
      "expiresAt": "2026-10-15"
    },
    {
      "title": "Statements downloads a PDF for last month",
      "file": "src/accounts/statements.spec.ts",
      "owner": "@acme/accounts-web",
      "ticket": "ACC-988",
      "reason": "Download event missed under CPU contention on 2-core runners",
      "addedAt": "2026-09-18",
      "expiresAt": "2026-10-18"
    }
  ]
}

The schema's maxItems is the organisation-wide flakiness budget. When the list is full, adding an entry means fixing or deleting another one first.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "E2E quarantine list",
  "type": "object",
  "required": ["entries"],
  "properties": {
    "$schema": { "type": "string" },
    "entries": {
      "type": "array",
      "maxItems": 25,
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["title", "file", "owner", "ticket", "reason", "addedAt", "expiresAt"],
        "properties": {
          "title": { "type": "string", "minLength": 5 },
          "file": { "type": "string", "pattern": "\\.spec\\.ts$" },
          "owner": { "type": "string", "pattern": "^@[a-z0-9-]+/[a-z0-9-]+$" },
          "ticket": { "type": "string", "pattern": "^[A-Z]+-[0-9]+$" },
          "reason": { "type": "string", "minLength": 15 },
          "addedAt": { "type": "string", "format": "date" },
          "expiresAt": { "type": "string", "format": "date" }
        }
      }
    }
  }
}
# .github/CODEOWNERS (excerpt)
/libs/payments/                               @acme/payments-web
/apps/banking-web-e2e/src/payments/           @acme/payments-web
/apps/banking-web-e2e/src/accounts/           @acme/accounts-web
# Quarantine changes need the platform team. The bot also requests review from each entry's owner.
/apps/banking-web-e2e/tests/quarantine*.json  @acme/frontend-platform
State diagram of a flaky test. It is detected, then quarantined through a reviewed pull request. From quarantine it is either fixed and restored to the blocking suite after repeated green runs, or it expires, which makes it blocking again and escalates to the owning team, who fix or delete it.

Every quarantined test ends in one of two places: fixed or deleted.

Detection, burn-in, and budgets

Detection reads the Playwright JSON reports you already produce, counting per test how often it passed only on retry or failed in some runs but not others, with a normalised error signature so one root cause appears once. Node 22.18+ runs it directly.

// scripts/detect-flaky.ts
// Usage: node scripts/detect-flaky.ts results/*.json > flaky-candidates.json
import { readFileSync } from 'node:fs';

type Result = { error?: { message?: string } };
type PwTest = { status: 'expected' | 'unexpected' | 'flaky' | 'skipped'; results: Result[] };
type Spec = { title: string; tests: PwTest[] };
type Suite = { title: string; specs?: Spec[]; suites?: Suite[] };
type Stat = { runs: number; flaky: number; failed: number; signatures: Set<string> };

const stats = new Map<string, Stat>();

// Strip volatile details so one root cause produces one signature.
const fingerprint = (message = '') =>
  (message.split('\n')[0] ?? '')
    .replace(/\u001b\[[0-9;]*m/g, '')
    .replace(/\b[0-9a-f]{8,}\b/gi, '<id>')
    .replace(/\d+(\.\d+)?m?s\b/g, '<time>')
    .replace(/\d+/g, '<n>')
    .slice(0, 160);

function walk(suite: Suite, path: string[], depth: number): void {
  // depth 0 is the file; quarantine titles use describe + test titles only.
  const here = depth > 0 && suite.title ? [...path, suite.title] : path;
  for (const spec of suite.specs ?? []) {
    for (const test of spec.tests) {
      const key = [...here, spec.title].join(' ');
      const stat = stats.get(key) ?? { runs: 0, flaky: 0, failed: 0, signatures: new Set<string>() };
      stat.runs += 1;
      if (test.status === 'flaky') stat.flaky += 1;
      if (test.status === 'unexpected') stat.failed += 1;
      for (const result of test.results) if (result.error?.message) stat.signatures.add(fingerprint(result.error.message));
      stats.set(key, stat);
    }
  }
  for (const child of suite.suites ?? []) walk(child, here, depth + 1);
}

for (const file of process.argv.slice(2)) {
  const report = JSON.parse(readFileSync(file, 'utf8')) as { suites: Suite[] };
  for (const suite of report.suites) walk(suite, [], 0);
}

// Passed on retry, or failed in some runs and passed in others: flaky by Google's definition.
const candidates = [...stats]
  .filter(([, s]) => s.flaky > 0 || (s.failed > 0 && s.failed < s.runs))
  .map(([title, s]) => ({ title, runs: s.runs, flakyRate: Number(((s.flaky + s.failed) / s.runs).toFixed(3)), signatures: [...s.signatures] }))
  .sort((a, b) => b.flakyRate - a.flakyRate);

console.log(JSON.stringify({ generatedAt: new Date().toISOString(), candidates }, null, 2));

Prevention is cheaper: a burn-in job runs every changed spec ten times before merge and validates the quarantine file.

# .github/workflows/burn-in.yml
name: burn-in
on:
  pull_request:
    paths: ['apps/banking-web-e2e/**']

jobs:
  burn-in:
    runs-on: ubuntu-latest
    env:
      E2E_BASE_URL: ${{ vars.E2E_PREVIEW_URL }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: pnpm/action-setup@v4
        with:
          version: 10.17.1 # pinned, as in Part 2
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm exec playwright install --with-deps chromium

      - name: Validate quarantine list
        run: >
          pnpm exec ajv validate --spec=draft2020 -c ajv-formats
          -s apps/banking-web-e2e/tests/quarantine.schema.json
          -d apps/banking-web-e2e/tests/quarantine.json

      - name: Run changed specs ten times
        working-directory: apps/banking-web-e2e
        run: >
          pnpm exec playwright test --project=blocking
          --only-changed=origin/${{ github.base_ref }}
          --repeat-each=10 --retries=0 --workers=2 --pass-with-no-tests

An Auto-Quarantine Bot ties these together: a nightly job downloads seven days of report artifacts from the e2e workflow, runs detect-flaky.ts over them, and opens one pull request per owning team, with tickets and 30-day expiries. Cap it at five new entries per day. Needing more usually means something systemic broke, such as a runner image or shared environment, and a person should look first.

Budgets make the policy fair. When a team has more than five active entries, or its Flaky Test Ratio stays above 2% for two weeks, its feature merges pause while fixes still merge. That can feel harsh at first. Reporting the numbers weekly for a month before enforcing helps, as does presenting the budget as protection for teams with healthy tests who currently pay for everyone else's retries.

Worth experimenting with: Flake fingerprinting. The fingerprint function above collapses error messages into signatures by removing IDs, timings, and numbers. Grouping candidates by signature instead of by test often shows that ten "different" flaky tests share one cause, such as a slow payees endpoint on staging. The risk is over-grouping two real causes into one signature, so keep the raw messages alongside it. For a one-sprint trial, compare how many distinct signatures you have with how many flaky tests, and fix the most frequent signature first.
MetricFormulaStarting targetAlert when
Flaky Test RatioTests with both outcomes on the same SHA in 7 days / tests runUnder 2%Above 2% for 2 weeks
Retry-rescued rateRuns green only after a retry / all runsUnder 5%Rising 3 weeks in a row
Quarantine sizeActive entries in quarantine.jsonUnder 15At the schema cap
Median quarantine ageMedian days since addedAt for active entriesUnder 14 daysOver 21 days
Mean time to deflakeAverage days from addedAt to fix mergedUnder 10 daysOver 20 days
First-attempt green rate on mainMain-branch runs green without retry / all main runsAbove 95%Below 90%

These targets are starting points to calibrate against your own baseline for a month, not industry benchmarks.

Recommendation

Measure flakiness with Google's definition before changing anything, then move flaky tests into a data-driven quarantine with owners and expiry dates. Start with the quarantine file, its schema, and burn-in for changed specs; add failOnFlakyTests and team budgets after a few weeks of shared reporting. The first-attempt green rate on main tells you whether engineers can trust a red build again.

Frontend Security Testing: Abuse Cases Before Merge

Frontend security tests check what the UI does when someone or something behaves badly: injected content, tampered or unexpected responses, expired sessions, replayed requests, and scripts that shouldn't be on a payment page. Most fit into the integration and end-to-end layers you already have, and they complement backend controls and penetration testing rather than replacing them.

Where the frontend can undermine the backend

In payments the server owns balances, limits, fraud checks, and authentication. The frontend can still undermine those controls in ways a backend test never sees: showing success before the server confirms, displaying the amount the customer typed instead of the amount the server accepted, rendering an attacker-controlled payee name as HTML, generating a new idempotency key on retry and causing a double payment, retrying a rate-limited request in a tight loop, or following a returnUrl to another domain after login. Each is a business failure as much as a security one, which makes them easy to prioritise with product owners.

CaseWhat goes wrongLayerThe test asserts
Stored XSS in payee name or referenceAttacker text runs as HTML in statementsIntegrationText renders literally, no element created
Confirmed amount differs from enteredUI shows the typed amount as paidIntegrationServer amount shown, mismatch warning, no success
Retry after a lost responseNew idempotency key, double paymentIntegrationSame key sent on every retry of one payment
Session expires mid-transferForm data leaks or success is shownIntegrationRedirect to sign-in, nothing sensitive stored
Step-up authentication requiredPayment shown as sent before the challengeIntegrationChallenge shown, status stays unconfirmed
Rate limited (429)Tight retry loop worsens an attack or outageIntegrationRetry-After respected, no automatic loop
Open redirect after sign-inreturnUrl sends user to a phishing siteUnitOnly same-origin paths are accepted
Verbose API errorsStack traces or IDs shown to usersContractError body is code and message only
Unexpected script on payment pageCard skimming (Magecart)E2EOnly inventoried scripts load, CSP present

The integration cases reuse the scenario registry. The security scenarios sit in their own map, which scenarios.ts already registers next to payments.

// libs/payments/testing/msw/src/security.scenarios.ts (registered in scenarios.ts)
import { http, HttpResponse, type RequestHandler } from 'msw';
import { aPayment } from '@acme/payments/testing/fixtures';

export type SecurityScenario =
  | 'xss-payee' | 'amount-mismatch' | 'lost-response-once'
  | 'session-expired' | 'step-up-required' | 'rate-limited';

export const paymentsSecurity: Record<SecurityScenario, RequestHandler[]> = {
  'xss-payee': [
    http.get('/api/v1/payees', () =>
      HttpResponse.json({ items: [{ id: 'payee_test_001', name: '<img src=x onerror=alert(1)>Northwind' }] }),
    ),
  ],
  // The server accepted 2.50 while the customer typed 250.00.
  'amount-mismatch': [
    http.post('/api/v1/payments', () => HttpResponse.json(aPayment().withAmount(250).build(), { status: 201 })),
  ],
  // First attempt fails at the network level, later attempts reach the default handler.
  'lost-response-once': [http.post('/api/v1/payments', () => HttpResponse.error(), { once: true })],
  'session-expired': [
    http.all('/api/v1/*', () => HttpResponse.json({ code: 'session_expired' }, { status: 401 })),
  ],
  'step-up-required': [
    http.post('/api/v1/payments', () =>
      HttpResponse.json({ code: 'step_up_required', message: 'Additional verification needed.' }, { status: 403 }),
    ),
  ],
  'rate-limited': [
    http.post('/api/v1/payments', () =>
      HttpResponse.json({ code: 'rate_limited' }, { status: 429, headers: { 'Retry-After': '30' } }),
    ),
  ],
};

Three of these cases as integration tests. The idempotency test listens to MSW's request events to capture every key the client sends.

// libs/payments/feature-transfer/src/transfer-form.security.integration.spec.ts
import { afterEach, describe, expect, it } from 'vitest';
import { applyScenario, server } from '@acme/payments/testing/msw';
import { TransferDriver } from '@acme/payments/testing/drivers';
import { mountTransferForm } from './testing/mount-transfer-form';

describe('Transfer form security behaviour', () => {
  afterEach(() => server.events.removeAllListeners());

  it('renders a hostile payee name as text', async () => {
    applyScenario(server, 'paymentsSecurity', 'xss-payee');
    await mountTransferForm();
    const transfer = new TransferDriver();

    await transfer.choosePayee('<img src=x onerror=alert(1)>Northwind');
    await transfer.fillAmount('10.00');
    await transfer.submit();

    expect(await transfer.statusText()).toContain('<img src=x onerror=alert(1)>Northwind');
    expect(document.querySelector('img[src="x"]')).toBeNull();
  });

  it('never reports success when the confirmed amount differs', async () => {
    applyScenario(server, 'paymentsSecurity', 'amount-mismatch');
    await mountTransferForm();
    const transfer = new TransferDriver();

    await transfer.choosePayee('Northwind Utilities');
    await transfer.fillAmount('250.00');
    await transfer.submit();

    expect(await transfer.alertText()).toMatch(/£2\.50/);
    expect(document.querySelector('[role="status"]')?.textContent ?? '').not.toMatch(/submitted/i);
  });

  it('reuses the idempotency key when the customer retries', async () => {
    const keys: string[] = [];
    server.events.on('request:start', ({ request }) => {
      if (request.method === 'POST') keys.push(request.headers.get('Idempotency-Key') ?? 'missing');
    });
    applyScenario(server, 'paymentsSecurity', 'lost-response-once');
    await mountTransferForm();
    const transfer = new TransferDriver();

    await transfer.choosePayee('Northwind Utilities');
    await transfer.fillAmount('250.00');
    await transfer.submit();
    await transfer.alertText(); // network error shown, form still filled
    await transfer.submit();
    await transfer.statusText();

    expect(keys).toHaveLength(2);
    expect(keys[0]).toBe(keys[1]);
  });
});

The payment page and script inventory

Payment pages attract a specific attack. In 2018, attackers modified JavaScript on British Airways' payment pages so card details were copied to their own server while bookings completed normally. BA did not detect the attack for more than two months, and the UK regulator fined it £20m in October 2020.

PCI DSS v4.0.1 responded to this class of attack with requirements 6.4.3 and 11.6.1, mandatory since 31 March 2025. In summary, 6.4.3 asks that every script on a payment page is authorised, has its integrity assured, and appears in an inventory with a written justification. 11.6.1 asks for detection of unauthorised changes to the page's contents and security headers as the browser receives them. A pre-merge test can't satisfy 11.6.1, which is production monitoring and belongs in Part 4. It can stop an unreviewed script from shipping at all, and it produces evidence your assessor may find useful. Confirm scope and evidence with your QSA; this isn't compliance advice.

{
  "page": "/payments/card",
  "reviewedAt": "2026-09-20",
  "allowed": [
    {
      "prefix": "https://bank.example.test/assets/",
      "owner": "@acme/frontend-platform",
      "justification": "First-party application bundles, content-hashed at build time"
    },
    {
      "prefix": "https://js.card-vault.example/v3/",
      "owner": "@acme/payments-web",
      "justification": "Card tokenisation iframe loader; card data never touches our DOM"
    }
  ]
}
// apps/banking-web-e2e/src/security/payment-page-scripts.spec.ts
import { readFileSync } from 'node:fs';
import { expect, test } from '@playwright/test';

type Inventory = { page: string; allowed: { prefix: string; owner: string; justification: string }[] };
const inventory = JSON.parse(readFileSync('src/security/payment-page-scripts.json', 'utf8')) as Inventory;

test('payment page loads only inventoried scripts and sends a strict CSP', async ({ page }) => {
  const scripts = new Set<string>();
  page.on('request', (request) => {
    if (request.resourceType() === 'script') {
      const url = new URL(request.url());
      scripts.add(`${url.origin}${url.pathname}`);
    }
  });

  const response = await page.goto(inventory.page);
  // Wait for late-loading tags too; this is one of the few places networkidle is worth it.
  await page.waitForLoadState('networkidle');

  const csp = response?.headers()['content-security-policy'] ?? '';
  const scriptSrc = csp.split(';').map((d) => d.trim()).find((d) => d.startsWith('script-src')) ?? '';
  expect(scriptSrc, 'payment page must send a script-src policy').not.toBe('');
  // A strict CSP uses nonces or hashes. 'unsafe-inline' may still appear as a fallback
  // for old browsers, which modern browsers ignore when a nonce or hash is present.
  expect(scriptSrc).toMatch(/'nonce-[^']+'|'sha(256|384|512)-/);

  const unexpected = [...scripts].filter((src) => !inventory.allowed.some((a) => src.startsWith(a.prefix)));
  expect(unexpected, 'scripts missing from the payment page inventory').toEqual([]);

  // Inline scripts must carry the per-response nonce issued with the CSP.
  await expect(page.locator('script:not([src]):not([nonce])')).toHaveCount(0);
});

Recommendation

Pick the three cases from the table that match your product's riskiest flows and add them to the scenario registry this sprint; the idempotency-key retry test is the one I'd write first in any payments codebase. Add the script inventory test to every page that collects card data, and make changes to the inventory file require review from whoever owns PCI scope. Products without card data benefit too: run the same test on sign-in and account-settings pages, where injected scripts go after credentials and session tokens. These tests make a class of mistakes visible before merge; keep the backend controls, penetration tests, and production monitoring that catch everything else.

AI in Frontend Testing: Where It Helps (Preview)

AI and machine learning are most useful in testing as advisers: summarising failures, grouping them by cause, predicting which tests matter for a change, and drafting tests or fixes for review. Decisions to merge, quarantine, or ship should stay with deterministic checks or with a person reviewing a pull request.

The techniques go well beyond chat models; classifiers, clustering, and predictive selection have run in production at large companies for years. This section is a map, and each row gets a full article in a later series on AI-native frontend engineering.

JobTechniqueWhat it does hereMain riskMaturity
ReportLLM summary with schema-constrained outputTurns logs and traces into a short triage noteConfident, wrong summariesEmerging
ClusterError signatures, text embeddingsGroups failures that share a root causeMerging distinct causesProven (signatures)
ClassifySupervised classifiers on run historyLabels a failure as likely flaky or likely realNeeds months of labelled dataEmerging
PredictPredictive test selectionRuns the tests most likely to fail for a changeThin tooling for JavaScript monoreposProven at Meta scale
GeneratePlaywright planner and generator agentsDrafts E2E specs by exploring the running appTests that encode today's bugsEmerging
RepairPlaywright healer, Nx Cloud Self-Healing CIProposes fixes for broken locators or tasksHealing into different behaviourEmerging
AssessMutation testing with feedback to the generatorFinds tests that run code without checking itSlow on large codebasesProven (mutation)
LearnQuarantine outcomes as training labelsImproves detection over timeNoisy labels from "fixes" that add waitsExperimental

Predictive test selection is the most established row. Meta's system, published at ICSE 2019, halved the infrastructure cost of testing changes while still reporting over 95% of individual test failures and over 99.9% of faulty changes. Commercial versions exist, such as Develocity's, but they target JVM builds with Gradle and Maven; JavaScript monorepos still rely mostly on graph-based nx affected. That later series will cover predictive test selection for JavaScript monorepos in depth.

Playwright ships planner, generator, and healer agents. When the healer believes the functionality itself is broken, it skips the test instead of forcing it green, which is more careful than many third-party self-healing tools. That later series will look at running Playwright agents in a regulated codebase.

Constraining triage output to a schema keeps it auditable: every field can be checked instead of free text.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "Test failure triage (advisory)",
  "type": "object",
  "additionalProperties": false,
  "required": ["testTitle", "category", "confidence", "evidence", "suggestedAction"],
  "properties": {
    "testTitle": { "type": "string" },
    "category": {
      "enum": ["async-race", "shared-state", "mock-leakage", "time", "third-party", "environment", "product-bug", "unknown"]
    },
    "confidence": { "type": "number", "minimum": 0, "maximum": 1 },
    "evidence": {
      "type": "array",
      "minItems": 1,
      "maxItems": 5,
      "items": { "type": "string", "maxLength": 300 }
    },
    "suggestedAction": { "enum": ["investigate", "quarantine-candidate", "likely-product-bug"] }
  }
}

In a payments codebase, two security points apply to any AI step in the pipeline. Test logs contain attacker-controllable strings, such as the hostile payee name from the previous section, which makes prompt injection a real risk for any model that reads them; give triage bots no permissions beyond opening a pull request. And logs can contain customer data, so redact them before they leave your network, whatever the vendor's retention terms say.

Community experience is mixed. The most common complaint about self-healing tools is that they heal into different functionality and keep a broken flow green. The second is that AI-generated unit tests often derive expected values from the implementation, so they confirm the code as written, bugs included; practitioners counter this with mutation testing and by generating tests in a separate session that never sees the implementation. That later series will cover mutation-guided test generation.

Worth experimenting with: Diff-scoped mutation testing. Run a mutation tool such as Stryker only on the files a pull request changed, by passing that file list to its mutate option, and report the mutation score for the diff rather than the whole repository. This gives reviewers a direct answer to whether the new tests would fail if the new code were wrong, which matters most when the tests were AI-generated. The risk is run time on large diffs, so cap it per pull request. For a one-sprint trial, compare line coverage with the diff mutation score on ten AI-assisted pull requests.

The Pre-Merge Confidence Ladder

Every check belongs at the cheapest stage that can catch the problem. The ladder below extends the layered-checks table from Part 2 with the testing layers from this part, and shows the two release stages that Part 4 adds after merge.

StageWhat runsCatchesTime budgetOwner
EditorFormat, lint autofix, type errorsSyntax and obvious mistakesInstantEach engineer
pre-commitlint-staged on staged filesStyle and lint issuesUnder 40 sEach engineer
pre-pushTypecheck, affected unit testsLogic errors before sharingUnder 90 sEach engineer
CI: affected testsUnit and integration with MSW, security scenariosFeature behaviour and error states2 to 5 minOwning team
CI: contract gatePact publish, can-i-deploy, oasdiffAPI drift between teams1 to 3 minConsumer and provider teams
CI: burn-inChanged E2E specs repeated 10 timesNew flaky tests3 to 8 minSpec owner
CI: E2E journeysCritical journeys, payment page script inventoryMulti-app wiring, unexpected scripts5 to 10 minJourney owner
Part 4: canaryStaged rollout with RUM gatesReal-traffic regressions30 to 120 minRelease owner
Part 4: productionMonitoring, kill switches, rollbackEverything elseContinuousOn-call
Left-to-right pipeline. A commit passes local hooks, then affected unit and integration tests, then the contract gate, then burn-in and end-to-end journeys, and then merges. After merge, the canary and production stages are covered in Part 4.

The pre-merge pipeline, with Part 4 stages after merge.

Tests reduce how often a bad change reaches main, and the ladder above is how this part does that. No suite catches everything, and a payments team still needs to limit how many customers a bad change reaches and to undo it in seconds. Part 4 covers that: feature flags, client-side canaries, and rollback.

Appendix: Reproduce This Setup

These steps assume an Nx workspace with pnpm, as set up in Parts 1 and 2. Each step ends with a check that tells you it worked.

1. Install the testing dependencies.

# step 1: install (add @testing-library/angular for Angular workspaces)
# -w installs at the workspace root, which pnpm requires to be explicit
pnpm add -w -D msw @faker-js/faker vitest jsdom @testing-library/dom @testing-library/user-event
pnpm add -w -D @pact-foundation/pact @playwright/test orval ajv-cli ajv-formats
pnpm add -w zod

Check: pnpm exec msw --version prints a 2.x version.

2. Generate the browser worker for local development and Storybook.

# step 2: copy mockServiceWorker.js into the app's public folder
pnpm exec msw init apps/banking-web/public --save

Check: apps/banking-web/public/mockServiceWorker.js exists and is committed.

3. Create the testing libraries.

# step 3: one library per concern, tagged so module boundaries apply
for lib in fixtures msw drivers; do
  pnpm nx g @nx/js:lib "libs/payments/testing/$lib" --name="payments-testing-$lib" \
    --importPath="@acme/payments/testing/$lib" --tags=scope:payments,type:testing \
    --bundler=none --unitTestRunner=none
done

Check: pnpm nx show projects lists the three libraries. Then extend the module-boundary rules from Part 1 so testing libraries are importable only from spec files. ESLint overrides replace a rule's options rather than merging them, so merge these constraints into your full list from Part 1.

{
  "overrides": [
    {
      "files": ["*.ts"],
      "excludedFiles": ["*.spec.ts"],
      "rules": {
        "@nx/enforce-module-boundaries": ["error", {
          "depConstraints": [
            { "sourceTag": "type:testing", "onlyDependOnLibsWithTags": ["type:testing", "type:data-access", "type:util"] },
            { "sourceTag": "type:feature", "onlyDependOnLibsWithTags": ["type:ui", "type:util", "type:data-access"] }
          ]
        }]
      }
    },
    {
      "files": ["*.spec.ts"],
      "rules": {
        "@nx/enforce-module-boundaries": ["error", {
          "depConstraints": [
            { "sourceTag": "type:feature", "onlyDependOnLibsWithTags": ["type:ui", "type:util", "type:data-access", "type:testing"] },
            { "sourceTag": "type:data-access", "onlyDependOnLibsWithTags": ["type:data-access", "type:util", "type:testing"] }
          ]
        }]
      }
    }
  ]
}

4. Wire Vitest and run the integration target.

# step 4: after adding vitest.config.ts, vitest.setup.ts, and project.json targets
pnpm nx run payments-feature-transfer:test-integration

Check: temporarily remove the payees handler. The run should fail with an unhandled request error, which proves onUnhandledRequest: 'error' is active.

5. Confirm cache honesty.

# step 5: run twice, then change the OpenAPI spec's content and run again
pnpm nx affected -t test-unit test-integration --base=origin/main
pnpm nx affected -t test-unit test-integration --base=origin/main
echo "# cache check" >> contracts/openapi/payments.yaml
pnpm nx run payments-feature-transfer:test-integration
git checkout -- contracts/openapi/payments.yaml

Check: the second run is a cache hit; the third run executes again because the spec is an input. Nx hashes file contents, not timestamps, so touch alone would not invalidate it.

6. Add the Playwright config and quarantine file.

# step 6: browsers, then list what each project would run
pnpm exec playwright install --with-deps chromium
cd apps/banking-web-e2e
pnpm exec playwright test --list --project=quarantine
pnpm exec ajv validate --spec=draft2020 -c ajv-formats -s tests/quarantine.schema.json -d tests/quarantine.json

Check: the quarantine project lists exactly the tests in quarantine.json, and the schema validation passes.

7. Add the CI workflows.

# step 7: after copying contracts.yml, openapi-breaking.yml, burn-in.yml, and e2e.yml
git add .github/workflows && git commit -m "ci: contract, schema, and burn-in checks"
gh workflow list

Check: a test pull request that touches the payments library shows the contracts check, and one that touches the E2E app shows burn-in.

8. Publish the first contract.

# step 8: requires PACT_BROKER_BASE_URL, PACT_BROKER_TOKEN, and PACT_CLI_TAG in your shell
pnpm nx run payments-data-access:test-contract
PACT_CLI="pactfoundation/pact-cli:${PACT_CLI_TAG:?set PACT_CLI_TAG to a pinned release tag}"
docker run --rm -v "$PWD:$PWD" -w "$PWD" -e PACT_BROKER_BASE_URL -e PACT_BROKER_TOKEN "$PACT_CLI" \
  publish libs/payments/data-access/pacts \
  --consumer-app-version "$(git rev-parse HEAD)" --branch "$(git branch --show-current)"

Check: the broker shows a payments-web version for your commit and branch, with a pact for payments-api.

If you have one afternoon, do these three things and stop:

  1. Turn on onUnhandledRequest: 'error' and resetHandlers() in your existing test setup.
  2. Create quarantine.json with its schema and move every currently skipped test into it with an owner and an expiry date.
  3. Add the burn-in job for changed E2E specs.

Frequently Asked Questions

Is contract testing better than end-to-end testing for APIs?

For checking that a frontend and an API agree on request and response shapes, yes. Contract tests run in seconds, fail in the pull request that caused the break, and name the field involved. End-to-end tests are still needed for a few critical journeys, but using them to detect API drift is slow and expensive.

How do I stop flaky Playwright tests from blocking my team?

Measure flakiness first, using "passed and failed on the same code" as the definition. Move flaky tests into a quarantine file with an owner and expiry date, run them in a separate non-blocking job, and repeat changed specs before merge with --repeat-each so new flaky tests rarely reach main.

Can AI fix flaky tests?

AI can help triage flaky tests by summarising logs, grouping failures by cause, and proposing fixes for review. It should not merge fixes or quarantine tests on its own. Self-healing tools can hide real regressions by adapting a test to broken behaviour, so treat every AI-proposed change as a pull request.

How can frontend tests support PCI DSS 6.4.3 on a payment page?

An end-to-end test can record every script the payment page loads, compare it against a reviewed inventory with owners and justifications, and check that a strict Content Security Policy is sent. That stops unreviewed scripts shipping. It doesn't replace production change detection for 11.6.1, and your assessor decides what evidence counts.

Sources

Frontendpedia Platform

Turn this article into a learning plan

Our companion platform helps you follow structured learning paths and track your frontend skills over time. Free to start, with paid plans for professionals who want to go deeper.

Explore the platform
Satish Pednekar

About Satish Pednekar

Technical consultant | System Design | AI Strategy | Experimentalist

Admin @ www.frontendpedia.com

Thumbnail

The Enterprise Frontend Blueprint - PART 2: The Build Pipeline & Developer Experience

Part 2 of the Enterprise Frontend Blueprint: a no-hype guide to the 2026 build pipeline - choosing a bundler after Webpack, making CI fast, and git hooks that catch bugs on your machine, not in CI.

Satish Pednekar
•
modern enterprise frontend architecture

The Enterprise Frontend Blueprint - PART 1: Foundations & Repository Strategy

A battle-tested frontend architecture blueprint for enterprise teams. Learn how to scale using monorepos, pnpm, Feature-Sliced Design, and TypeScript with Zod

Satish Pednekar
•
enterprise-nx-monorepo-architecture

The Architect’s Ledger: High-Scale Patterns for Nx, Angular, and Tactical Interop

Master Nx monorepo Angular architecture. Solve "affected:build" slowness, React-to-Webcomponent ReactDOM errors, and nx caching. Proven Fortune 500 strategies for truly scalable frontends

Satish Pednekar
•