How to Automate Camera Integration Testing (Step-by-Step)

How to Automate Camera Integration Testing (Step-by-Step) begins with understanding what you actually need to verify when a camera module interacts with the rest of the app. Camera integration is not

March 20, 2026 · 16 min read · How-To Guides

How to Automate Camera Integration Testing (Step-by-Step) begins with understanding what you actually need to verify when a camera module interacts with the rest of the app. Camera integration is not just about checking that the preview surface appears; it involves confirming that focus, exposure, flash, orientation, and the data returned to the UI behave correctly across device variants and OS versions. Manual testing of these aspects is time‑consuming and error‑prone because each device may expose different camera APIs, hardware quirks, or permission flows. Automation pays off when you need to run the same matrix of checks on every build, especially in a continuous‑integration pipeline where regressions can be caught before they reach users. The following guide walks through a complete, reproducible process: from deciding when to automate, picking the right framework, crafting reliable locators, synchronizing with asynchronous camera events, managing test data, executing in CI, and reporting results. Real‑world code snippets in Appium, Espresso, and Playwright illustrate each step, and a short checklist at the end helps you validate that nothing was missed.

1. When Automation Pays Off for Camera Features

1.1 Cost of Manual Repetition

Testing camera behavior manually requires a tester to launch the app, grant permissions, point the lens at a test chart, verify focus taps, check flash activation, and confirm that the captured image is correctly displayed or uploaded. Repeating this for every pull request on a matrix of, say, five Android OS versions and four screen densities quickly exceeds a full‑time tester’s capacity. Automation replaces the repetitive taps and visual checks with scripted assertions that run in seconds on a device farm.

1.2 Risk Areas That Benefit Most

When any of these areas have a history of regressions, the return on investment for automated camera integration tests becomes clear.

1.3 When to Hold Off

If your app only uses the camera for a simple barcode scan that never changes, and you have a stable third‑party scanner library with its own test suite, you may choose to rely on unit tests of the wrapper and skip UI‑level camera automation. The guideline is: automate when the camera interaction touches your own code, UI, or business logic; otherwise, trust the provider’s verification.

2. Choosing a Test Framework

2.1 Mobile‑Native Options

FrameworkLanguageStrengths for CameraWeaknesses
AppiumJava, JavaScript, Python, RubyCross‑platform (Android/iOS), works on real devices and emulators, can inject accessibility IDs, supports gestures needed for focus taps.Slightly slower startup, requires server setup, camera permission handling can be flaky on some emulators.
EspressoJava/KotlinAndroid‑only, runs as part of the instrumentation test suite, fast execution, direct access to Android Camera2 API for validation.Requires Android source or test APK, cannot test iOS, limited to UI thread interactions.
XCUITestSwift/Objective‑CiOS‑only, deep integration with AVFoundation, can query capture session properties.Same platform limitation as Espresso.

2.2 Web‑Based Options (for Progressive Web Apps or hybrid WebViews)

FrameworkLanguageStrengthsWeaknesses
PlaywrightJavaScript/TypeScript, Python, .NETAuto‑waits, built‑in video trace, can prompt for media device permissions, works with Chromium, Firefox, WebKit.Requires a browser that supports getUserMedia; mobile emulation may not reflect exact camera hardware quirks.
Selenium WebDriverJava, JavaScript, Python, C#Broad grid support, can connect to real mobile browsers via Appium’s Selendroid mode.Verbose waits, less built‑in media‑device handling than Playwright.
CypressJavaScriptExcellent developer experience, automatic retries.Does not support native mobile camera APIs; only works within browser context.

2.3 Hybrid Approach

If your app uses a camera preview inside a WebView (e.g., an Ionic or React Native component that falls back to HTML5 getUserMedia), you can combine Espresso/Playwright: Espresso drives the native container, while Playwright runs inside the WebView context to assert on the video stream. This adds complexity but yields full‑stack coverage.

2.4 Decision Matrix

Choose the framework that matches the following criteria:

  1. Platform coverage – need Android only → Espresso; need both Android and iOS → Appium.
  2. Speed requirement – sub‑second feedback → Espresso/XCUITest; can tolerate a few seconds → Appium/Playwright.
  3. Permission handling – if you must test runtime dialogs on real hardware → Appium (real devices) or Espresso with grantPermission ADB command.
  4. Existing test stack – if you already have a Playwright suite for web regression, extending it to camera tests reduces context switching.

For the examples below we will use Appium (Java) because it demonstrates cross‑platform concepts while staying close to the native Android camera API, and we will also show a Playwright snippet for web‑based camera scenarios.

3. Setting Up the Execution Environment

3.1 Device Lab or Emulator Farm

Real devices are indispensable for camera testing because emulators often simulate a static image feed that does not respond to focus or flash commands. However, a mixed strategy works well: run quick sanity checks on emulators (e.g., verifying that the preview surface appears) and reserve the full matrix of focus, exposure, and flash tests for a device farm (Firebase Test Lab, AWS Device Farm, or an in‑house lab).

3.2 Installing Dependencies

For Appium (Java) on a CI agent:


# Install Node.js and Appium server
npm install -g appium
# Install Java JDK (>=11) and Maven
sudo apt-get install openjdk-11-jdk maven
# Add Android SDK tools to PATH
export ANDROID_HOME=$HOME/Android/Sdk
export PATH=$PATH:$ANDROID_HOME/emulator:$ANDROID_HOME/tools:$ANDROID_HOME/tools/bin:$ANDROID_HOME/platform-tools
# Verify
adb version
appium --version

For Playwright (Node.js):


npm init -y
npm install -D @playwright/test
npx playwright install   # downloads Chromium, Firefox, WebKit binaries

3.3 Granting Camera Permissions Automatically

On Android you can pre‑grant the permission via ADB before launching the test:


adb shell pm grant io.susatest.demo android.permission.CAMERA
adb shell pm grant io.susatest.demo android.permission.RECORD_AUDIO

On iOS with XCUITest you can add the NSCameraUsageDescription key to the Info.plist and rely on the system dialog; Appium can then accept the alert automatically:


// Appium Java example
driver.findElement(By.id("com.android.permissioncontroller:id/permission_allow_button")).click();

In Playwright you can pass the --use-fake-device-for-media-stream flag or respond to the permission prompt:


const { chromium } = require('playwright');
(async () => {
  const browser = await chromium.launch({ headless: false });
  const context = await browser.newContext({
    permissions: ['camera']   // auto‑grants camera permission
  });
  const page = await context.newPage();
  await page.goto('https://example.com/camera');
  // … test steps …
})();

3.4 Preparing a Consistent Test Scene

Camera algorithms depend heavily on the scene in front of the lens. To make tests repeatable, place a printed test chart (e.g., ISO 12233 resolution chart) at a fixed distance (30 cm) and fixed lighting (500 lux). If you cannot control ambient light, use a light‑box enclosure. For emulators you can push a static image via the adb emu camera command:


adb emu camera list
adb emu camera 0 static-image:///path/to/testchart.jpg

This ensures every run sees the same visual input, eliminating variance caused by scene changes.

4. Designing Stable Locators for Camera UI

4.1 Avoiding Fragile Coordinates

Hard‑coding tap coordinates (x, y) breaks when the device screen size or orientation changes. Instead, rely on accessibility IDs, content descriptions, or unique resource IDs that survive layout changes.

4.2 Example: Android Camera Preview Button

Suppose the app has a “Capture” button with the ID capture_btn. In Espresso you would write:


onView(withId(R.id.capture_btn))
    .check(matches(isDisplayed()))
    .perform(click());

If the button lacks an ID, add a content description in the source:


<Button
    android:id="@+id/capture_btn"
    android:contentDescription="@string/capture_desc"
    ... />

Then locate by description:


onView(withContentDescription("Capture photo"))
    .perform(click());

4.3 iOS Equivalent with XCUITest


let captureButton = app.buttons["Capture"]
XCTAssertTrue(captureButton.exists)
captureButton.tap()

4.4 Web Locators with Playwright

For a video element that shows the preview:


<video id="preview" autoplay muted playsinline></video>

Locator:


await page.locator('#preview').waitFor({ state: 'attached' });

If the video is injected via a shadow DOM, you can pierce it:


await page.locator('video/shadow-deep #preview').waitFor();

4.5 Handling Dynamic Overlays

Camera apps often show a toast or a snackbar after a capture (e.g., “Saving…”). These elements appear and disappear quickly. Use a short timeout combined with until (Espresso) or waitForSelector with a state: 'detached' condition (Playwright) to assert that the toast appears then vanishes:


// Espresso
onView(withText("Saving…"))
    .withTimeout(500, TimeUnit.MILLISECONDS)
    .check(matches(isDisplayed()));
// after a brief pause, verify it's gone
onView(withText("Saving…"))
    .withTimeout(500, TimeUnit.MILLISECONDS)
    .check(matches(not(isDisplayed())));

// Playwright
await expect(page.locator('text=Saving…')).toBeVisible({ timeout: 250 });
await expect(page.locator('text=Saving…')).toBeHidden({ timeout: 250 });

Stable locators reduce flakiness caused by UI redesigns and make the test suite maintainable across releases.

5. Handling Waits and Synchronization

5.1 Sources of Asynchrony

5.2 Explicit Waits vs. Implicit Waits

Avoid implicit waits (driver.manage().timeouts().implicitlyWait) because they apply globally and can mask real timing issues. Use explicit waits that target a specific condition.

#### Appium Java Example – Waiting for Preview to Appear


WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
wait.until(ExpectedConditions.visibilityOfElementLocated(By.id("preview_surface")));

#### Espresso – Idling Resource for Camera Initialization

Create a custom IdlingResource that signals when the camera’s StateCallback reports STATE_OPENED:


public class CameraIdlingResource implements IdlingResource {
    private volatile ResourceCallback callback;
    private boolean opened = false;

    @Override
    public String getName() {
        return CameraIdlingResource.class.getName();
    }

    @Override
    public boolean isIdleNow() {
        boolean idle = opened;
        if (idle && callback != null) {
            callback.onTransitionToIdle();
        }
        return idle;
    }

    @Override
    public void registerIdleTransitionCallback(ResourceCallback callback) {
        this.callback = callback;
    }

    // Called from your test app when CameraDevice.StateCallback receives STATE_OPENED
    public void setOpened(boolean opened) {
        this.opened = opened;
    }
}

Register it in the test:


CameraIdlingResource res = new CameraIdlingResource();
IdlingRegistry.getInstance().register(res);
try {
    // test steps that require the camera to be ready
} finally {
    IdlingRegistry.getInstance().unregister(res);
}

#### Playwright – Playwright Video to ensure`


await page.waitForFunction');
const video = document.getElementById('preview');
 return video.readyState >= 2; // HAVE_CURRENT_DATA
});```
### 5.3 Dealing with Variable Frame Rates  
Some devices deliver preview frames at 15 fps, others at 30 fps. If your test validates that a barcode appears in the preview, you may need to sample multiple frames. A simple approach:  

// Pseudocode for Appium + Android

for (int i = 0; i < 5; i++) {

String base64 = driver.getScreenshotAs(OutputType.BASE64);

// decode and run barcode detection

if (detected) break;

Thread.sleep(100);

}



In Playwright you can repeatedly evaluate the video element’s `currentTime` or capture a canvas snapshot:  

for (let i = 0; i < 5; i++) {

const frame = await page.screenshot({ path: frame-${i}.png, type: 'png' });

// run OCR or barcode lib on the image

if (success) break;

await page.waitForTimeout(100);

}



### 5.4 Timeout Values  
Set timeouts based on empirical measurements:  
- Permission dialog: 2‑4 s.  
- Camera open: 3‑6 s.  
- Focus convergence: 1‑2 s (tap to focus may add extra).  
- Image save: 2‑5 s depending on storage speed.  

Document these values in a `constants.java` or `config.ts` file so they can be tuned per device class.

## 6. Data Setup, Teardown, and State Management  

### 6.1 Test Data for Camera Scenarios  
Camera tests often need a known image to compare against the captured result. Store reference images in your version control under `src/test/resources/reference/` and load them at runtime.  

BufferedImage reference = ImageIO.read(new File("src/test/resources/reference/chart.jpg"));



For tests that verify upload behavior, mock the backend with a lightweight server (e.g., WireMock or MockServer) that returns a predetermined URL or error code.  

### 6.2 Cleaning Up MediaStore Entries  
Each capture that writes to `MediaStore.Images.Media` adds a row. Left‑over rows can cause false positives in later tests that query the gallery. After each test, delete the file you just created:  

// Assuming you captured a file with known path

String savedPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) + "/test.jpg";

File file = new File(savedPath);

if (file.exists()) {

boolean deleted = file.delete();

// Also notify MediaScanner to remove the entry

Intent mediaScanIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);

mediaScanIntent.setData(Uri.fromFile(file));

InstrumentationRegistry.getInstrumentation().getTargetContext().sendBroadcast(mediaScanIntent);

}



On iOS, use `PHPhotoLibrary` to delete the asset:  

PHPhotoLibrary.shared().performChanges({

PHAssetChangeRequest.deleteAssets([asset] as NSArray)

}, completionHandler: { success, error in

// handle

})



### 6.3 Resetting Camera State  
Some camera apps keep the last used exposure compensation or flash mode. Before each test, reset to a known default:  

// Using Camera2 API via a test helper exposed through a debug interface

testHelper.setExposureCompensation(0);

testHelper.setFlashMode(CameraMetadata.FLASH_MODE_OFF);



If no such helper exists, launch the app with a clean intent that forces a fresh launch (`Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK`).  

### 6.4 Parallel Execution Considerations  
When running multiple camera tests concurrently on the same device, they will compete for the hardware sensor, leading to `CameraAccessException`. The solution is to either:  
- Serialize camera tests on a given device using a Jenkins lock step or a GitHub Actions concurrency group.  
- Allocate a dedicated device per parallel thread (device farms make this easy).  

Document the concurrency limit in your CI configuration.

## 7. Writing a Complete Camera Integration Test (Step‑by‑Step)  

Below is a full test written in **Java with Appium** that verifies:  
1. The app launches and shows the camera preview.  
2. Tapping to focus works and the focus state changes to `FOCUSED`.  
3. Capturing a photo saves a file to `Pictures/` and the file size is > 0.  
4. The flash can be toggled on and the resulting image is brighter than the no‑flash version (simple pixel‑average check).  

### 7.1 Test Class Skeleton  

public class CameraIntegrationTest {

private AppiumDriver driver;

private WebDriverWait wait;

private final String APP_PACKAGE = "io.susatest.demo";

private final String APP_ACTIVITY = ".MainActivity";

@BeforeEach

public void setUp() throws Exception {

DesiredCapabilities caps = new DesiredCapabilities();

caps.setCapability("platformName", "Android");

caps.setCapability("deviceName", "Pixel_4_API_33");

caps.setCapability("appPackage", APP_PACKAGE);

caps.setCapability("appActivity", APP_ACTIVITY);

caps.setCapability("automationName", "UiAutomator2");

caps.setCapability("noReset", true); // keep app data between tests if desired

driver = new AndroidDriver<>(new URL("http://localhost:4723/wd/hub"), caps);

wait = new WebDriverWait(driver, Duration.ofSeconds(15));

// Grant permission if not already granted

driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(2));

try {

driver.findElement(By.id("com.android.permissioncontroller:id/permission_allow_button")).click();

} catch (Exception ignored) {}

}

@AfterEach

public void tearDown() {

if (driver != null) {

driver.quit();

}

}



### 7.2 Helper Methods  

private MobileElement findPreview() {

return wait.until(ExpectedConditions.visibilityOfElementLocated(By.id("preview_surface")));

}

private void tapToFocus(Point point) {

new TouchAction<>(driver)

.tap(point.getX(), point.getY())

.perform();

// Wait for focus state change via a custom attribute exposed by the app

wait.until(drv -> {

String focusState = drv.findElement(By.id("focus_state_text")).getText();

return focusState.equalsIgnoreCase("focused");

});

}

private File captureImage() throws IOException {

MobileElement captureBtn = wait.until(ExpectedConditions.elementToBeClickable(By.id("capture_btn")));

captureBtn.click();

// Wait for the toast that says “Saved”

wait.until(ExpectedConditions.visibilityOfElementLocated(By.xpath("//*[contains(@text,'Saved')]")));

// Determine the latest file in Pictures/

File pictures = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES);

File[] files = pictures.listFiles((dir, name) -> name.startsWith("test_") && name.endsWith(".jpg"));

assert files != null && files.length > files.length == 0 : "No capturedFile = files[files.length - 1];

return capturedFile;

}

private double averageBrightness(File imageFile) throws IOException {

BufferedImage img = ImageIO.read(imageFile);

long sum = 0;

int count = 0;

for (int y = 0; y < img.getHeight(); y++) {

for (int x = 0; x < img.getWidth(); x++) {

int rgb = img.getRGB(x, y);

int r = (rgb >> 16) & 0xff;

int g = (rgb >> x < img.getWidth(); x++) {

int rgb = img.getRGB(x, y);

int r = (rgb >> 16) & 0xff;

int g = (rgb >> 8) & 0xff;

int b = rgb & 0xff;

int brightness formula);

sum

count++;

}

}

return (double) sum /rgb & 0xff;

int gray = (r + g + b) / 3;

sum += gray;

count++;

}

}

return sum / (double) count;

}



### 7.3.  

    @Test
    public void testFocusCaptureAndFlash() throws Exception {
        // 1. Verify preview is shown
        MobileElement preview = findPreview();
        assertTrue(preview.isDisplayed(), "Camera preview should be visible");

        // 2. Tap to focus at centre of preview
        Rectangle previewRect = preview.getRect();
        Point centre = new Point(
                previewRect.getX() + previewRect.getWidth() / 2,
                previewRect.getY() + previewRect.getHeight() / 2);
        tapToFocus(centre);

        // 3. Capture without flash (ensure flash mode off via helper)
        // Assume we have a test-only API exposed via a debug button
        driver.findElement(By.id("debug_flash_off")).click();
        File noFlashImg = captureImage();
        assertTrue(noFlashImg.length() > 0, "Captured file should not be empty");

        // 4. Capture with flash
        driver.findElement(By.id("debug_flash_on")).click();
        File flashImg = captureImage();
        assertTrue(flashImg.length() > 0, "Flash image should not be empty");

        // 5. Simple brightness check – flash image should be brighter
        double noFlashBright = averageBrightness(noFlashImg);
        double flashBright = averageBrightness(flashImg);
        assertTrue(flashBright > noFlashBright + 5,
                String.format("Flash image not sufficiently brighter (noFlash=%.1f, flash=%.1f)",
                        noFlashBright, flashBright));
    }
}

7.3 Explanation of Each Step

StepPurposeKey Technique
Launch app & grant permissionGuarantees the test starts from a clean stateDesiredCapabilities + handling system permission dialog
Verify previewConfirms the camera hardware opened successfullyExpectedConditions.visibilityOfElementLocated
Tap to focusTests touch‑to‑focus logic and verifies focus stateCustom TouchAction + waiting for a UI element that reports focus
Capture image (no flash)Baseline image for later comparisonClick capture button, wait for “Saved” toast, locate newest file
Toggle flash & captureValidates flash control pathwayDebug UI to set flash mode, repeat capture
Brightness assertionSimple oracle that flash increased exposureCompute average pixel intensity; expect a measurable delta

The test is deliberately deterministic: the same scene, same lighting, same device orientation. If any step fails, the error message points directly to the problematic interaction (permission, preview, focus, capture, flash).

7.4 Adapting the Test for Playwright (Web Camera)

If your product uses the HTML5 getUserMedia API inside a WebView or a PWA, the equivalent Playwright test looks like this:


const { test, expect } = require('@playwright/test');

test.describe('Camera integration (Web)', () => {
  test('can request camera, see preview, and capture image', async ({ page }) => {
    // 1. Grant permission via context
    const context = await browser.newContext({
      permissions: ['camera']
    });
    const page = await context.newPage();
    await page.goto('https://example.com/camera-demo');

    // 2. Wait for video element to become visible
    const video = page.locator('video#preview');
    await expect(video).toBeVisible({ timeout: 5000 });

    // 3. Simulate a click to capture (assuming a button with id=capture)
    await page.locator('#capture_btn').click();

    // 4. Wait for the captured image to appear in an img tag
    const img = page.locator('img#captured');
    await expect(img).toBeVisible({ timeout: 5000 });

    // 5. Verify the image is not a placeholder (naturalWidth > 0)
    const naturalWidth = await img.evaluate(el => el.naturalWidth);
    expect(naturalWidth).toBeGreaterThan(0);
  });
});

The same principles—explicit waits, permission handling, and a clear oracle—apply.

8. Running the Tests in Continuous Integration

8.1 CI Platform Choices

8.2 Sample GitHub Actions Workflow (Appium + Android Emulator)


name: Camera Integration Tests

on:
  push:
    branches: [ main ]
  pull_request:

jobs:
  camera-test:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v3
      - name: Set up JDK
        uses: actions/setup-java@v3
        with:
          distribution: temurin
          java-version: '11'
      - name: Install Node & Appium
        run: |
          npm install -g appium
          appium &
          sleep 5   # give server time to start
      - name: Set up Android SDK
        uses: android-actions/setup-android@v2
      - name: Start Emulator
        run: |
          echo "no" | avdmanager create avd -n test -k "system-images;android-33;google_apis;x86_64"
          emulator -avd test -no-window -no-audio &
          # wait for device to boot
          adb wait-for-device
          adb shell getprop sys.boot_completed | while read line; do if [ "$line" != "1" ]; then sleep 5; fi; done
      - name: Grant Camera Permission
        run: |
          adb shell pm grant io.susatest.demo android.permission.CAMERA
      - name: Run Tests
        run: |
          mvn test -Dtest=CameraIntegrationTest
      - name: Pull Screenshots on Failure
        if: failure()
        run: |
          adb exec-out screencap -p /tmp/failure.png || true
          echo "##vso[task.attachments]failure.png"

Key points:

8.3 Running on a Real Device Farm (Firebase Test Lab Example)


gcloud firebase test android run \
  --type instrumentation \
  --app app-debug.apk \
  --test app-test.apk \
  --device model=Pixel4,version=33,locale=en,orientation=portrait \
  --timeout 90s

Test Lab automatically provides video recordings and logs, which are invaluable for debugging camera‑specific issues like preview freezes or focus hunting.

8.4 Parallelism and Resource Limits

If you have a fleet of 8 devices, you can split the test class using Maven Parallel Surefire or TestNG’s parallel="methods" attribute. Ensure each test method acquires a lock on the camera sensor via a semaphore if you ever run more tests than devices on a single node.

8.5 Flake Detection

Enable retry on failure only for flaky steps (e.g., permission dialog) and treat the whole test as failed if any retry is needed. In JUnit 5 you can use the @Retry extension from junit5-retro or implement a custom Extension that counts attempts.

9. Reporting, Debugging, and Continuous Improvement

9.1 Collecting Artifacts

9.2 Comparing Images

Use perceptual hashing (pHash) or SSIM to detect unintended changes in image quality caused by a regression in the image‑processing pipeline. A simple Python script using imagehash:


from PIL import Image
import imagehash

def compare(reference, test, cutoff=5):
    ref_hash = imagehash.phash(Image.open(reference))
    test_hash = imagehash.phash(Image.open(test))
    return (ref_hash - test_hash) <= cutoff

If the distance exceeds the threshold, flag the build as unstable.

9.3 Dashboard Integration

Push results to your existing test reporting system (e.g., Allure, ReportPortal, or JUnit XML). Include custom attachments:

These artifacts let a reviewer visually confirm that the flash changed exposure as expected.

9.4 Continuous Improvement Loop

  1. Analyze flaky tests – look at logs for patterns (e.g., repeated permission dialog timeouts).
  2. Stabilize locators – if a test fails because an element moved, add a content description or accessibility ID.
  3. Expand the matrix – add new device models that exhibit unique camera quirks (e.g., ultra‑wide lenses, periscope zoom).
  4. Automate baseline updates – when a genuine improvement (e.g., better night‑mode algorithm) is verified, promote the new reference image to the baseline after manual sign‑off.

10. Autonomous Exploration as a Bootstrap for Camera Tests

Modern autonomous QA platforms can explore an app without any test scripts, discovering screens, interacting with UI elements, and generating regression scripts automatically

Test Your App Autonomously

Upload your APK or URL. SUSA explores like 10 real users — finds bugs, accessibility violations, and security issues. No scripts.

Try SUSA Free