How to Automate Loading States Testing (Step-by-Step)

How to Automate Loading States Testing (Step-by-Step) is a critical skill for any QA or development team aiming to deliver a responsive and reliable user experience. Loading states, often manifested a

By · June 18, 2026 · 16 min read · How-To Guides

How to Automate Loading States Testing (Step-by-Step) is a critical skill for any QA or development team aiming to deliver a responsive and reliable user experience. Loading states, often manifested as spinners, skeletons, progress bars, or temporary blank screens, are ubiquitous in modern applications. They bridge the gap between user action and system response, indicating that work is in progress. While seemingly simple, incorrect or poorly handled loading states can lead to user frustration, perceived performance issues, and even functional defects if subsequent actions are attempted before the UI is ready. This guide provides a comprehensive, step-by-step approach to automating the testing of these states, covering everything from initial strategy to continuous integration and reporting, ensuring your application provides a smooth and predictable experience even when fetching data or performing complex operations.

Understanding Loading States and Why They Matter

Loading states are visual cues that inform users about ongoing background processes. They manage user expectations, prevent premature interactions with an unprepared UI, and enhance perceived performance. From a technical perspective, loading states are often tied to asynchronous operations like API calls, database queries, or heavy computations.

The Impact of Untested Loading States

Failing to adequately test loading states can have several negative consequences:

When Automation Pays Off for Loading States

While manual testing can catch basic loading state issues, its effectiveness diminishes rapidly with application complexity, the number of loading scenarios, and the need for consistent, repeatable checks. Automation becomes invaluable for:

Devising a Comprehensive Loading States Test Strategy

Before diving into code, a well-defined strategy is paramount. This involves identifying critical loading scenarios, defining expected behaviors, and determining the scope of automation.

Identifying Key Loading Scenarios

Start by cataloging all instances where loading states are expected. This often involves:

  1. Initial Page/Screen Load: The very first time a user accesses a view.
  2. Data Fetching: API calls for lists, details, search results, form pre-population.
  3. Form Submission: After clicking a "Submit" or "Save" button.
  4. Action Initiation: Any user action that triggers a backend process (e.g., applying a filter, deleting an item).
  5. Navigation: Between different sections or pages of the application.
  6. File Upload/Download: Progress indicators for large file operations.
  7. Real-time Updates: When subscribing to live data feeds.

Defining Expected Behaviors and Success Criteria

For each identified scenario, articulate what constitutes correct behavior. This should cover:

Example Test Matrix for Loading States

A structured test matrix helps prioritize and track coverage.

Scenario IDFeature/ComponentTriggerExpected Loading StateSuccess Criteria (Visibility, Duration, Interaction)Expected Final StateTest TypeAutomation Candidate
LS-001Product List PageInitial LoadSkeleton Loader (List)Appears immediately, covers list area, prevents clicks on list items.Populated list of products.Functional, UXHigh
LS-002Product DetailClick ProductSpinner (Modal)Appears centrally in modal, disables modal actions, disappears when data loads.Product details rendered.Functional, UXHigh
LS-003Search ResultsType & Enter SearchProgress Bar (Top)Appears at top of viewport, disappears on results.Filtered product list.Functional, PerformanceMedium
LS-004Add to CartClick "Add"Button SpinnerButton text changes to "Adding...", spinner inside, button disabled.Cart icon updates, button enabled, text "Add".Functional, UXHigh
LS-005User Profile SaveClick "Save"Global Overlay SpinnerFull screen overlay, prevents all interaction, disappears on success/error."Profile Updated" toast/error.Functional, UXHigh
LS-006Image UploadUpload FileProgress Bar (Inline)Appears next to filename, updates % progress.Thumbnail preview, "Upload Complete".Functional, PerformanceMedium

Choosing the Right Automation Framework and Tooling

The choice of framework depends on your application's technology stack (web, mobile, desktop), team expertise, and existing automation infrastructure.

Web Applications

Mobile Applications

Key Considerations for Loading States Automation

Building Stable and Maintainable Loading State Tests

Writing effective automated tests for loading states requires more than just interacting with UI elements. It demands careful consideration of timing, state management, and error handling.

Step 1: Setting Up Your Environment and Framework

Let's use Playwright for a web application example, given its strengths in waiting and network control.


# Install Playwright
npm init playwright@latest
# Choose TypeScript or JavaScript, point to 'tests' folder, etc.
# Install a network proxy tool if needed for more advanced network control,
# though Playwright's route is often sufficient.

Step 2: Crafting a Robust Locator Strategy

Loading indicators often appear and disappear. Their locators must be resilient.

Step 3: Handling Waits and Preventing Flakiness

This is the most critical aspect of loading states testing. Flakiness occurs when tests fail intermittently due to timing issues.

#### Playwright's Auto-Waiting

Playwright automatically waits for elements to be visible, enabled, and in a stable state before performing actions. This significantly reduces the need for explicit sleep() or waitForTimeout().


// Playwright automatically waits for the button to be enabled before clicking
await page.getByTestId('add-to-cart-button').click();

#### Explicit Waits for Loading Indicators

While auto-waiting is great, you often need to explicitly wait for loading indicators to *appear* and then *disappear*.


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

test('should show and hide skeleton loader on product list page', async ({ page }) => {
    // 1. Intercept network request to simulate loading
    await page.route('**/api/products', async route => {
        // Delay the response by 2 seconds
        await new Promise(f => setTimeout(f, 2000));
        await route.continue();
    });

    await page.goto('/products');

    // 2. Assert loading indicator appears
    // Playwright waits for the element to exist and be visible
    const skeletonLoader = page.getByTestId('product-list-skeleton');
    await expect(skeletonLoader).toBeVisible();

    // 3. Assert loading indicator disappears
    // Playwright waits for the element to be hidden or removed from DOM
    await expect(skeletonLoader).toBeHidden();

    // 4. Assert content loaded
    await expect(page.getByText('Product A')).toBeVisible();
    await expect(page.getByText('Product B')).toBeVisible();
});

test('should show spinner on add to cart button and then update cart count', async ({ page }) => {
    await page.goto('/product/123'); // Navigate to a product detail page

    // Intercept the API call for adding to cart
    await page.route('**/api/cart/add', async route => {
        // Simulate a 1.5 second network delay
        await new Promise(f => setTimeout(f, 1500));
        await route.continue();
    });

    const addToCartButton = page.getByTestId('add-to-cart-button');
    const cartCount = page.getByTestId('cart-count');

    await expect(addToCartButton).toBeEnabled();
    const initialCartCount = await cartCount.textContent();

    await addToCartButton.click();

    // 1. Assert spinner is visible (e.g., inside the button)
    const buttonSpinner = page.getByTestId('add-to-cart-spinner');
    await expect(buttonSpinner).toBeVisible();
    // 2. Assert button is disabled during loading
    await expect(addToCartButton).toBeDisabled();

    // 3. Assert spinner disappears
    await expect(buttonSpinner).toBeHidden();
    // 4. Assert button is re-enabled
    await expect(addToCartButton).toBeEnabled();
    // 5. Assert cart count updated (assuming it increments by 1)
    await expect(cartCount).toHaveText((parseInt(initialCartCount || '0') + 1).toString());
});

#### Custom Wait Functions for Complex Scenarios

Sometimes, waiting for an element's visibility isn't enough. You might need to wait for a specific attribute change or a network request to complete.


// Wait for a specific network request to finish (Playwright)
test('wait for specific API call to complete', async ({ page }) => {
    await page.goto('/dashboard');
    const [response] = await Promise.all([
        page.waitForResponse(response => response.url().includes('/api/dashboard-data') && response.status() === 200),
        page.click('text=Refresh Dashboard') // Action that triggers the API call
    ]);
    // Now you can assert the dashboard data is loaded
    await expect(page.getByTestId('dashboard-chart')).toBeVisible();
});

// Wait for an element to have a specific class (e.g., 'loaded')
await page.waitForSelector('.my-component.loaded', { state: 'visible' });

Step 4: Data Setup and Teardown for Predictable States

Reliable loading state tests depend on predictable application states, which often requires controlled data.

#### Strategies for Data Management

#### Example: Simulating a Server Error During Loading


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

test('should show error message when product list fails to load', async ({ page }) => {
    // Intercept the API call and return a 500 error
    await page.route('**/api/products', route => {
        route.fulfill({
            status: 500,
            contentType: 'application/json',
            body: JSON.stringify({ message: 'Internal Server Error' }),
        });
    });

    await page.goto('/products');

    // Assert that the skeleton loader appeared and then disappeared (or was replaced by error)
    // Depending on your UI, the skeleton might disappear or the error might overlay it.
    const skeletonLoader = page.getByTestId('product-list-skeleton');
    await expect(skeletonLoader).toBeHidden(); // Or checkIfPresentAndHidden()

    // Assert the error message is displayed
    await expect(page.getByText('Failed to load products. Please try again later.')).toBeVisible();
    // Assert no product data is visible
    await expect(page.getByText('Product A')).not.toBeVisible();
});

Advanced Techniques for Robust Loading State Testing

Beyond basic visibility checks, several advanced techniques can enhance the quality and coverage of your loading state tests.

Simulating Network Conditions

Testing loading states under various network conditions is crucial for real-world performance.

Testing Interactions During Loading (Disabling UI)

A common bug is allowing users to interact with disabled elements or trigger multiple actions while a loading state is active.


test('should prevent interaction with disabled elements during form submission', async ({ page }) => {
    await page.goto('/settings/profile');

    await page.fill('#username-input', 'new_username');
    await page.fill('#email-input', 'new@example.com');

    // Intercept the save API call and delay it
    await page.route('**/api/profile/save', async route => {
        await new Promise(f => setTimeout(f, 3000));
        await route.continue();
    });

    const saveButton = page.getByTestId('save-profile-button');
    const usernameInput = page.locator('#username-input');

    await saveButton.click();

    // Assert button is disabled and spinner is visible
    await expect(saveButton).toBeDisabled();
    await expect(page.getByTestId('save-button-spinner')).toBeVisible();

    // Attempt to click the disabled button again
    await saveButton.click({ timeout: 1000, force: true }).catch(() => {
        // Expecting this click to either fail or do nothing due to disabled state.
        // Playwright's default click might throw if element is disabled, or you need { force: true }
        // and then verify no additional network requests were made.
    });

    // Attempt to type into an input field (assuming it should be disabled/read-only)
    await expect(usernameInput).toBeDisabled(); // Or .toHaveAttribute('readonly', '')

    // Wait for the save operation to complete
    await expect(saveButton).toBeEnabled();
    await expect(page.getByText('Profile updated successfully!')).toBeVisible();
});

Visual Regression Testing for Loading States

Sometimes, the *appearance* of a loading state matters as much as its functionality. Visual regression testing can catch unintended layout shifts, misaligned spinners, or incorrect color schemes.

  1. Trigger a specific loading state (e.g., using network delays).
  2. Take a screenshot of the loading indicator.
  3. Compare it against a baseline image.
  4. Ensure screenshots are taken at a consistent point in the loading lifecycle.

Accessibility Testing for Loading Indicators

Automated accessibility checks can verify proper ARIA attributes for screen readers.

Ensure your loading indicators use role="status", aria-live="polite", and aria-busy="true" where appropriate.

The Role of Autonomous QA Platforms in Loading State Testing

While manual test creation and scripting are powerful, platforms like SUSATest offer an alternative, particularly for initial exploration and baseline regression.

How SUSATest Approaches Loading States

SUSATest is an autonomous QA platform designed to explore applications without requiring pre-written scripts. For loading states, this approach offers unique benefits:

  1. Automatic Discovery of Loading Sequences: When SUSATest explores an application (e.g., an APK or web URL), it performs actions like taps, scrolls, and clicks. If an action triggers a loading state (spinner, skeleton), SUSATest's AI observes this. It understands that:

This observation automatically forms part of its learned interaction sequences.

  1. Persona-Based Interaction: SUSATest tests with various user personas (e.g., "Impatient User", "Curious User"). An "Impatient User" might attempt rapid interactions during a loading state, which SUSATest would log as a potential UX issue if the application doesn't handle it gracefully (e.g., multiple API calls, UI errors). A "Curious User" might explore every path, ensuring all loading states are encountered.
  1. Cross-Session Learning: As SUSATest runs across multiple builds, it remembers previously explored screens and the loading patterns associated with them. If a loading state that was previously fast suddenly becomes slow, or an expected loading indicator fails to appear, it can flag this as a regression.
  1. Automatic Regression Script Generation: After its autonomous exploration, SUSATest can generate executable regression scripts (e.g., Appium for Android, Playwright for Web). These generated scripts *include* the waits and assertions for loading states that it observed during its exploration. This means you get a baseline of loading state tests without writing a single line of code.

    # Example SUSATest CLI command to start autonomous exploration
    pip install susatest-agent
susatest-agent test https://mywebapp.com --persona impatient --format junit --output playwright

The generated Playwright script would then contain await expect(page.getByTestId('my-spinner')).toBeVisible(); followed by await expect(page.getByTestId('my-spinner')).toBeHidden(); based on its observations.

  1. Comprehensive Issue Detection: In addition to verifying loading states, SUSATest simultaneously checks for crashes, ANRs (Application Not Responding), dead buttons, accessibility violations (WCAG), and other UX friction points *during* its exploration, providing a holistic view of application quality, including how loading states contribute to or detract from it.

While SUSATest cannot *simulate* specific network conditions or *mock* exact API responses in the same way a handcrafted Playwright test can, it excels at discovering and establishing a baseline for the *presence*, *visibility*, *duration*, and *interaction-blocking* behavior of loading states as they naturally occur under varying user behaviors. It's an excellent way to bootstrap your loading state automation and ensure broad coverage without the initial scripting overhead.

Running Loading State Tests in CI/CD

Integrating loading state tests into your CI/CD pipeline is essential for continuous quality assurance.

Pipeline Integration Steps

  1. Dedicated Stage: Create a specific stage in your CI/CD pipeline for UI/E2E tests, separate from unit and integration tests.
  2. Environment Setup: Ensure the environment (Docker container, VM) has all necessary dependencies (Node.js, Playwright browsers, Appium server, Android SDK, XCode).
  3. Application Deployment: Deploy a fresh instance of your application (frontend and a testable backend, potentially with mocked data) to a staging or test environment.
  4. Test Execution: Run your tests.
  5. 
        # Example for Playwright
        npx playwright test --project=chromium --workers=4
    
        # Example for Appium (typically run via a test runner like Mocha/Jest)
        # mocha tests/mobile/loading.test.js
    
  6. Reporting: Generate clear, actionable reports.

Best Practices for CI/CD

Reporting and Monitoring Loading State Test Results

Effective reporting helps teams understand the state of loading states and prioritize fixes.

Key Metrics to Report

Reporting Tools

###

Test Your App Autonomously

Upload your APK or URL. SUSA explores like 11 real users — finds bugs, accessibility violations, and security issues. No scripts. New to the category? Start with what autonomous product intelligence & QA means.

Try SUSA Free