Skip to content

UI-licious vs Playwright ​

Microsoft's Playwright is a modern browser automation library known for fast execution and browser-level control, representing a significant evolution in web testing.

This guide compares UI-licious and Playwright, highlighting differences in test authoring, locator resilience, coroutine synchronization, and automated telemetry.

1. User-Centric Selectors vs Hardcoded DOM Locators ​

The most fundamental difference between UI-licious and Playwright is how elements are identified and resolved.

Playwright: Hardcoded DOM Locators ​

In Playwright, tests target elements by binding to specific DOM properties, roles, or locator chains:

ts
// Playwright: Hardcoded locators bound to DOM structure
await page.locator('.checkout-container').getByRole('button', { name: 'Submit' }).click();

While Playwright provides modern locator helpers (getByRole, locator), these are still hardcoded queries evaluated against the DOM hierarchy:

  • If a component is refactored, nested inside an intermediate wrapper, or converted into a custom component, locator chains like .checkout-container >> button break.
  • Complex forms often require multi-hop locator chains (e.g. page.locator('form-group').filter({ hasText: 'Email' }).locator('input')) that are tightly coupled to the component's markup.
  • XPath expressions in Playwright cannot cross Shadow DOM boundaries, complicating tests for applications built with Web Components or design systems.

Additionally, Playwright enforces strict mode by default. If a locator matches more than one element on the page (for example, if there are multiple "Submit" or "Edit" buttons), Playwright throws a runtime error (strict mode violation: locator resolved to N elements) instead of interacting. This forces testers to write overly complex, hyper-specific locator chains (.filter(...), .nth(...), or deeply nested selectors) to ensure a single exact match—making scripts harder to read, maintain, and adapt when the UI changes.

UI-licious: User-Centric Semantic Selectors ​

UI-licious targets elements based on what the user perceives on the screen:

js
// UI-licious: Target elements by visual text and labels
I.fill("Email", "[email protected]")
I.click("Submit")

Rather than binding to a static selector path at author-time, UI-licious dynamically resolves elements at runtime:

  • Inspects visible labels, <label for>, placeholder text, ARIA attributes, and spatial text proximity.
  • Automatically pierces open Shadow DOM boundaries without requiring custom queries or chained locators.

When multiple elements share similar labels or text (such as several "Add to Cart" or "Edit" buttons), UI-licious doesn't throw strict mode errors or force you into complex locator gymnastics. Instead, it automatically resolves the ambiguity to the best candidate element based on factors such as:

  • Match precision: Prioritizing an exact text match (e.g. "Sign In") over broader text matches ("Sign in to view your dashboard").
  • Element semantics: Giving higher relevance to interactive elements (<button>, <a>, <input>) over generic containers (<div>, <span>).
  • Context & proximity: Prioritizing elements closer to recently interacted elements (see the Targeting Elements Guide).

Because UI-licious tests what the user sees rather than how the HTML is structured, your tests survive frontend refactors, layout changes, and design system updates.

2. Coroutine Synchronization vs Pervasive Async/Await ​

Another major difference in the scripting experience is how asynchronous execution is managed:

Playwright: Async/Await on Every Line ​

Because every Playwright browser action is asynchronous, every test function must be marked async, and nearly every single statement must be preceded by await:

ts
// Playwright: 'await' required before every single interaction
await page.goto("https://store.example.com");
await page.getByRole("link", { name: "Catalog" }).click();
await page.getByLabel("Email").fill("[email protected]");
await page.getByRole("button", { name: "Submit" }).click();
await expect(page.getByText("Success")).toBeVisible();

If you accidentally omit a single await keyword—such as forgetting await before a .click()—JavaScript immediately proceeds to the next line before the click has completed. This results in race conditions, erratic test flakiness, or unhandled promise rejections that are difficult to diagnose.

UI-licious: Coroutine Synchronization ​

UI-licious executes test commands using coroutine synchronization. The test runtime handles asynchronous operations behind the scenes, allowing you to write clean, linear procedural code:

js
// UI-licious: Natural, linear execution without 'await'
I.goTo("https://store.example.com")
I.click("Catalog")
I.fill("Email", "[email protected]")
I.click("Submit")
I.see("Success")

Commands execute in strict, predictable sequence from top to bottom. You never have to worry about missing an await, reducing cognitive load and eliminating an entire category of asynchronous timing bugs.

3. Built-In Visual Auto-Waiting vs Actionability Checks ​

Both tools aim to eliminate timing flakiness, but they approach synchronization differently.

Playwright: Actionability Checks & Explicit Wait Helpers ​

Playwright performs automated actionability checks before actions (verifying that an element is attached, visible, stable, and enabled).

However, in dynamic web applications, actionability checks alone are often not enough:

  • Asynchronous Data & Page Transitions: If an action triggers a background API call or a route change, tests often need explicit synchronization helpers (await page.waitForResponse(...), await page.waitForLoadState('networkidle'), or await page.waitForSelector(...)).
  • Transient UI States: Rapid UI animations, loading spinners, and debounced form fields can still cause race conditions if the target element temporarily detaches or re-renders.

UI-licious: Smart Visual Auto-Waiting ​

In UI-licious, smart visual auto-waiting is built into every command:

js
// Automatically waits for the element to appear and become visible
I.click("Place Order")

Before performing any action, UI-licious automatically:

  • Polls the page and waits for candidate elements matching the target to appear in the DOM.
  • Waits for candidate elements to become visible on the page.

While UI-licious does not perform Playwright-style actionability checks (such as verifying whether an element is disabled or covered by another element), its automatic polling for visibility resolves the vast majority of timing issues and rendering delays without requiring explicit waitFor... calls.

4. Automated Step Telemetry vs Manual Logging Boilerplate ​

When tests fail or need debugging, engineers need rich diagnostic data: screenshots, current page URLs, document titles, and active tab contexts.

Playwright: Manual Telemetry Code ​

In Playwright, collecting diagnostic information during a test requires explicit scripting:

ts
// Playwright: Adding telemetry and state logging manually
test('checkout journey', async ({ page }) => {
    await page.goto('https://store.example.com/cart');
    
    // Manual screenshot before interaction
    await page.screenshot({ path: 'screenshots/before-checkout.png' });
    console.log('Current URL:', page.url(), 'Title:', await page.title());
    
    await page.getByRole('button', { name: 'Proceed to Checkout' }).click();
    await page.waitForLoadState('networkidle');
    
    // Manual screenshot after step
    await page.screenshot({ path: 'screenshots/checkout-step.png' });
});

This manual instrumentation:

  • Clutters test scripts with non-test code, making them harder to read.
  • Adds maintenance overhead for custom screenshot paths, file naming, and reporting hooks.
  • Leads to inconsistent debugging information across different team members' tests.

UI-licious: Zero-Boilerplate Automated Telemetry ​

In UI-licious, telemetry is captured automatically at every single command:

js
// UI-licious: Pure test logic
I.goTo("https://store.example.com/cart")
I.click("Proceed to Checkout")

Behind the scenes, the test runner automatically records:

  • High-Resolution Screenshots: Captured at every step (with the target element highlighted before clicks).
  • Page URL & Title: The active URL and document title are logged with every command.
  • Open Tabs & Windows: Automatically tracks window handles and tab switches.
  • Step Duration & Status: Execution time, network activity, and console errors are captured automatically.

Your test scripts remain clean, concise, and focused entirely on the business flow.

5. Code Comparison: Playwright vs UI-licious ​

Here is a side-by-side comparison of a checkout flow in Playwright vs UI-licious:

Playwright (TypeScript) ​

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

test('submit order', async ({ page }) => {
    await page.goto('https://store.example.com');

    await page.getByRole('link', { name: 'Catalog' }).click();
    await page.locator('.product-card', { hasText: 'Wireless Headphones' })
        .getByRole('button', { name: 'Add to Cart' })
        .click();

    await page.getByRole('button', { name: 'Cart' }).click();
    await page.getByRole('button', { name: 'Proceed to Checkout' }).click();

    await page.getByLabel('Full Name').fill('Alex Johnson');
    await page.getByLabel('Email').fill('[email protected]');
    await page.getByLabel('Shipping Address').fill('456 Elm Street');

    await page.getByRole('button', { name: 'Place Order' }).click();
    await expect(page.getByText('Order Confirmed')).toBeVisible();
});

UI-licious ​

js
I.goTo("https://store.example.com")

I.click("Catalog")
I.click("Wireless Headphones")
I.click("Add to Cart")

I.click("Cart")
I.click("Proceed to Checkout")

I.fill("Full Name", "Alex Johnson")
I.fill("Email", "[email protected]")
I.fill("Shipping Address", "456 Elm Street")

I.click("Place Order")
I.see("Order Confirmed")

See Also ​