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.
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.
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.
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.
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.
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:
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.
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.
Join the conversation! Your thoughts help the community grow.