React applications often depend on scripts provided by external services. These scripts may power payment gateways, analytics, customer support chat, maps, advertising, CAPTCHA verification, and other features.

When everything works, these integrations can seem straightforward. However, third-party scripts run outside your application's direct control. A network interruption, content security policy (CSP), browser extension, ad blocker, provider outage, or unexpected SDK change can prevent a script from loading or initializing correctly.

The challenge is not simply detecting that a script has failed. Your application must also respond appropriately. A payment form should not allow an unverified payment to proceed, a support widget should not block the rest of the page, and an analytics failure should not prevent a user from completing an important task.

Testing these situations helps developers build React applications that remain usable when external services become unavailable.

In this article, we will explore how to isolate third-party integrations, simulate script failures, test error handling with React Testing Library and Vitest, and decide which scenarios require browser-level testing.

Why Third-Party Script Failures Need Dedicated Tests

Third-party scripts introduce dependencies that your team cannot completely control.

Consider a React application that loads a customer support widget. The widget might load successfully during development but fail when a user has an ad blocker enabled. If your application assumes that the widget is always available, the support button may remain stuck in a loading state.

Similar problems can occur with other integrations.

Integration

Possible failure

Expected application behavior

Analytics

Script cannot load

Core application features continue working

Customer support chat

Initialization fails

Display a useful fallback or contact option

Payment SDK

Provider script is unavailable

Prevent payment submission and show an appropriate message

Maps

API key is invalid or the service is unavailable

Display an address or another fallback

CAPTCHA

Verification script fails

Explain that verification is temporarily unavailable

Advertising

Browser blocks the script

Preserve the page layout and essential functionality

The expected behavior depends on the integration. An analytics failure may require no visible error message, while a payment SDK failure must prevent an unsafe transaction.

Your tests should verify these business requirements instead of checking only whether an error was logged.

Isolate Third-Party Integrations Behind a Small Interface

One of the most useful testing practices is to keep external scripts out of your main application logic.

Instead of accessing a provider's global JavaScript object from several React components, create a small wrapper that handles script loading and initialization.

For example, avoid spreading calls such as window.PaymentProvider, window.ChatWidget, or window.MapService throughout the application. These dependencies make components difficult to test because the components become tightly coupled to external implementation details.

A better structure separates responsibilities:

  • A script loader manages loading and failure detection.

  • An adapter communicates with the external SDK.

  • A React component manages loading, success, and error states.

  • Tests verify the application's behavior without making real network requests.

This approach also makes it easier to replace a provider or update its SDK.

Create a Reusable Script Loader

The following example uses TypeScript and returns a promise that resolves when a script loads or rejects when loading fails or exceeds a timeout.

TypeScript
export function loadExternalScript(
  src: string,
  timeoutMs = 5000
): Promise<void> {
  return new Promise((resolve, reject) => {
    const script = document.createElement("script");

    let settled = false;

    const cleanup = () => {
      window.clearTimeout(timeoutId);
      script.removeEventListener("load", handleLoad);
      script.removeEventListener("error", handleError);
    };

    const settle = (error?: Error) => {
      if (settled) {
        return;
      }

      settled = true;
      cleanup();

      if (error) {
        script.remove();
        reject(error);
      } else {
        resolve();
      }
    };

    const handleLoad = () => {
      settle();
    };

    const handleError = () => {
      settle(new Error("The external script could not be loaded."));
    };

    const timeoutId = window.setTimeout(() => {
      settle(new Error("The external script loading timed out."));
    }, timeoutMs);

    script.src = src;
    script.async = true;

    script.addEventListener("load", handleLoad);
    script.addEventListener("error", handleError);

    document.head.appendChild(script);
  });
}

This loader handles three outcomes: successful loading, a script error, and a timeout.

The settled flag prevents multiple events from resolving or rejecting the same promise more than once. For example, a timeout may occur before a delayed event is processed.

The loader deliberately does not remove a successfully loaded script. A provider may need that script to remain available after initialization.

There is also an important limitation: the browser's load event indicates that the script resource loaded, not that the provider's SDK initialized correctly. The application must verify initialization separately.

For production use, consider whether failed scripts should be retried, whether an existing script should be reused, and whether concurrent requests should share one loading promise. These details matter when several components depend on the same SDK.

Test Script Loading Failures with Vitest

Unit tests should not depend on a real third-party server. External network requests make tests slower and less predictable, and a provider outage should not cause your entire test suite to fail.

Instead, test the loader by simulating browser events.

The following example assumes that Vitest and a DOM testing environment such as jsdom are already configured.

TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
import { loadExternalScript } from "./loadExternalScript";

afterEach(() => {
  vi.restoreAllMocks();
  document.head
    .querySelectorAll('script[src="https://cdn.example.test/sdk.js"]')
    .forEach((script) => script.remove());
});

describe("loadExternalScript", () => {
  it("resolves when the script loads", async () => {
    const promise = loadExternalScript(
      "https://cdn.example.test/sdk.js"
    );

    const script = document.head.querySelector(
      'script[src="https://cdn.example.test/sdk.js"]'
    );

    expect(script).not.toBeNull();

    script!.dispatchEvent(new Event("load"));

    await expect(promise).resolves.toBeUndefined();
  });

  it("rejects when the script fails to load", async () => {
    const promise = loadExternalScript(
      "https://cdn.example.test/sdk.js"
    );

    const script = document.head.querySelector(
      'script[src="https://cdn.example.test/sdk.js"]'
    );

    script!.dispatchEvent(new Event("error"));

    await expect(promise).rejects.toThrow(
      "The external script could not be loaded."
    );

    expect(script!.isConnected).toBe(false);
  });
});

These tests verify how the loader responds to browser events without downloading anything from the example domain.

Notice that the tests do not attempt to verify whether the external provider is operational. That responsibility belongs to separate integration checks.

The example also illustrates why testing the integration boundary is useful: the tests can trigger specific outcomes without relying on unpredictable network conditions.

Test Timeout Scenarios

A script may neither load nor trigger an error event within a reasonable period. This can leave an application waiting indefinitely unless it implements a timeout.

The loader already includes a timeout, so test it using Vitest's fake timers.

TypeScript
import { afterEach, expect, it, vi } from "vitest";
import { loadExternalScript } from "./loadExternalScript";

afterEach(() => {
  vi.useRealTimers();
  vi.restoreAllMocks();
});

it("rejects when the script takes too long to load", async () => {
  vi.useFakeTimers();

  const promise = loadExternalScript(
    "https://cdn.example.test/sdk.js",
    3000
  );

  const assertion = expect(promise).rejects.toThrow(
    "The external script loading timed out."
  );

  await vi.advanceTimersByTimeAsync(3000);

  await assertion;
});

Fake timers allow the test to simulate three seconds passing without making the test wait for three seconds in real time.

One important detail is that the rejection assertion is attached before the timer advances. This ensures the promise rejection is handled by the test.

Timeout values should reflect the purpose of the integration. A small analytics script may need a different timeout policy from a payment SDK. Avoid choosing an extremely short timeout simply to make tests finish quickly.

Handle Failures in a React Component

A script loader is only one part of the solution. React components must also handle loading failures correctly.

Consider a component that loads a customer support SDK. The component should display a loading state while the script is loading, show a fallback if loading fails, and avoid preventing the rest of the application from working.

TypeScript
import { useEffect, useState } from "react";
import { loadExternalScript } from "./loadExternalScript";

type LoadStatus = "loading" | "ready" | "error";

export function SupportWidget() {
  const [status, setStatus] = useState<LoadStatus>("loading");

  useEffect(() => {
    let active = true;

    loadExternalScript(
      "https://cdn.example.test/support.js",
      5000
    )
      .then(() => {
        if (active) {
          setStatus("ready");
        }
      })
      .catch(() => {
        if (active) {
          setStatus("error");
        }
      });

    return () => {
      active = false;
    };
  }, []);

  if (status === "loading") {
    return <p role="status">Loading support...</p>;
  }

  if (status === "error") {
    return (
      <div role="alert">
        Support chat is currently unavailable.
        <a href="/contact">Contact our team</a>
      </div>
    );
  }

  return <button type="button">Open support chat</button>;
}

The active flag prevents the component from updating its state after it has been unmounted.

However, it does not cancel the underlying script request. If cancellation is required, the loader needs an explicit cancellation mechanism. Shared scripts also require careful handling because one component should not remove a script that another component still needs.

The example displays a button when the script has loaded. In a real application, the component should also verify that the SDK initialized successfully and connect the button to the provider's documented API.

Test the React Component Without Loading the Real Script

You should test the component's visible behavior separately from the loader's browser-event handling.

Vitest allows you to mock the loader so the component receives either a successful result or a rejected promise.

TypeScript
import { afterEach, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { SupportWidget } from "./SupportWidget";
import { loadExternalScript } from "./loadExternalScript";

vi.mock("./loadExternalScript", () => ({
  loadExternalScript: vi.fn()
}));

afterEach(() => {
  cleanup();
  vi.resetAllMocks();
});

it("shows a fallback when the support script fails", async () => {
  vi.mocked(loadExternalScript).mockRejectedValue(
    new Error("Script loading failed")
  );

  render(<SupportWidget />);

  expect(await screen.findByRole("alert")).toBeInTheDocument();

  expect(
    screen.getByRole("link", { name: "Contact our team" })
  ).toHaveAttribute("href", "/contact");
});

it("shows the support button when the script loads", async () => {
  vi.mocked(loadExternalScript).mockResolvedValue(undefined);

  render(<SupportWidget />);

  expect(
    await screen.findByRole("button", { name: "Open support chat" })
  ).toBeInTheDocument();
});

These tests focus on the component's contract rather than its internal implementation. They verify what the user sees when the external dependency succeeds or fails.

The examples assume that the test environment includes the jest-dom matchers for assertions such as toBeInTheDocument() and toHaveAttribute().

In a real project, make sure the test setup imports the appropriate matcher library. Also, use the mock APIs supported by your installed Vitest version.

Test More Than a Basic Network Error

A script can fail in several ways. A good test suite should cover the failure modes that matter to the integration.

Scenario

How to simulate it

What to verify

Network failure

Dispatch an error event

A fallback appears

Slow response

Advance fake timers

Loading does not continue indefinitely

Script blocked

Reject the loader promise

Core application functionality remains available

SDK initialization failure

Mock the initialization method to reject

The component does not report the SDK as ready

Missing global object

Leave the provider global undefined

A controlled error is handled

Provider method throws

Mock the method to throw

The application displays a safe response

Component unmounts during loading

Unmount before resolving the promise

No state update occurs after unmount

Repeated initialization

Trigger initialization from multiple consumers

Duplicate script loading or setup is avoided

Test Missing SDK Globals

Some providers expose their SDK through a global object. A script might load successfully but fail to expose the expected object because of an incompatible version or initialization problem.

For example:

TypeScript
export function initializeSupportWidget(): void {
  const provider = (
    window as Window & {
      SupportProvider?: {
        initialize: () => void;
      };
    }
  ).SupportProvider;

  if (!provider) {
    throw new Error("Support SDK is unavailable.");
  }

  provider.initialize();
}

A test can verify the missing-global case without making a network request.

TypeScript
import { afterEach, expect, it, vi } from "vitest";
import { initializeSupportWidget } from "./supportAdapter";

afterEach(() => {
  delete (
    window as Window & {
      SupportProvider?: unknown;
    }
  ).SupportProvider;

  vi.restoreAllMocks();
});

it("throws a controlled error when the SDK is missing", () => {
  expect(() => initializeSupportWidget()).toThrow(
    "Support SDK is unavailable."
  );
});

In a larger application, you may prefer to return a typed error result rather than throw an exception. The important point is to detect the missing dependency before the application tries to call an undefined method.

Test Third-Party Failures at the Right Level

Unit tests are useful, but they cannot reproduce every browser behavior associated with external scripts.

For example, jsdom does not fully reproduce browser networking, content security policy enforcement, extension behavior, or every script execution detail. Dispatching an error event verifies your application's response to that event; it does not prove that a real browser will behave identically under every failure condition.

Use multiple testing levels.

Unit Tests

Use unit tests for the script loader, provider adapter, timeout logic, and error handling.

Mock external dependencies and verify that the application responds correctly to predictable outcomes.

Component Tests

Use React Testing Library to verify loading messages, error alerts, fallback links, disabled controls, and other user-visible behavior.

These tests should not depend on a real third-party service.

Browser Integration Tests

Use a browser automation tool when you need to verify actual browser behavior. For example, intercept a provider's script request and make the request fail. Then verify that the application displays its fallback and remains usable.

Also test the relevant production configuration where practical. A content security policy can block a script even when the provider is otherwise available.

Browser tests are especially important for payment SDKs, authentication flows, CAPTCHA, and other integrations where the consequences of failure are significant.

Sandbox Tests

Some providers offer sandbox environments. Use them to validate provider-specific initialization and workflows.

For payments, never use real customer credentials or trigger live transactions as part of ordinary automated tests. Mock the SDK in unit tests and use the provider's sandbox for dedicated integration tests.

Best Practices for Reliable Tests

Keep the following practices in mind when designing your test suite.

  • Isolate third-party SDKs behind adapters instead of accessing global objects throughout the React application.

  • Test success, network errors, timeouts, missing globals, and initialization failures where relevant.

  • Mock external dependencies in unit tests instead of relying on live network requests.

  • Verify user-visible behavior, not merely whether an error was logged.

  • Use timeouts that reflect the integration's needs and provide a clear fallback when appropriate.

  • Clean up event listeners, timers, and temporary scripts to prevent tests from interfering with one another.

  • Prevent duplicate script loading when multiple components use the same provider.

  • Avoid retrying failed operations indefinitely. Use bounded retries only when they are appropriate for the provider.

  • Keep essential application functionality independent of optional integrations.

  • Use browser-level tests to verify behavior that a simulated DOM cannot accurately reproduce.

  • Ensure that a payment or authentication failure cannot accidentally be treated as a successful operation.

Common Mistakes to Avoid

One common mistake is testing only the successful loading path. A component may work perfectly when the provider is available but remain stuck in a loading state after a failure.

Another mistake is mocking the entire React component. Doing so may hide problems in its error handling and fallback UI. Mock the external dependency instead, then test the component's behavior.

Developers sometimes assume that a script's load event means the SDK is ready. This is not always true. The script may load but fail during initialization, so the provider's readiness contract must be checked separately.

Finally, avoid making every third-party failure visible as a blocking error. Analytics may fail silently, while a payment integration should clearly indicate that the operation cannot continue. The response should match the importance of the integration.

Advantages and Limitations

Testing third-party script failures improves application resilience, catches problems before release, and reduces dependence on external services during automated testing. It also makes component behavior more predictable and helps prevent loading indicators or buttons from becoming permanently unusable.

However, mocked tests cannot prove that a third-party service is currently available or that every browser policy is configured correctly. Provider APIs can change, sandbox environments may behave differently from production, and browser extensions can block scripts in ways that are difficult to reproduce consistently.

A balanced test strategy combines deterministic unit tests, component tests, browser-level checks, and sandbox integration tests where necessary.

Summary

Third-party scripts are an important source of uncertainty in React applications. Network errors, timeouts, blocked resources, missing SDK globals, and initialization failures can all affect the user experience.

The most effective approach is to isolate external integrations behind small adapters, test script-loading behavior independently, and verify how React components respond when those integrations fail. Use Vitest and React Testing Library for predictable automated tests, then use browser and sandbox tests for behavior that depends on the real environment.

Most importantly, design the application so that an optional external script cannot bring down an otherwise functional page. When a third-party service is essential, such as a payment provider, fail safely and make sure the application never treats an incomplete operation as successful.