Getting Started

How to automate Fun UI Playground: selector policy, stable ids/data-testids, flash convention, and the same login test in Playwright, Selenium and Cypress.

Point your automation framework at any challenge and go — no signup, no API keys, no state to reset. Every page is built to be automated: this guide covers the conventions that make your locators boring, in the best possible way.

Why this exists

Fun UI Playground is a practice site for test engineers, in the spirit of the-internet.herokuapp.com — themed with the same pop-culture silliness as its siblings, funapi.dev (the API-side playground) and fundata.dev (the test-data playground). No account required and no ads. Optional sign-in only syncs progress across devices.

The selector policy

Every challenge follows the same four rules, so what you learn on one page transfers to all of them:

  • Semantic HTML first. Real <label for>, <button>, <table>, role and aria-* attributes — role-based locators work everywhere.
  • Stable ids and test ids. Every interactive element carries a kebab-case id and data-testid, prefixed with the challenge id: login-username, dnd-ring. One loudly-labeled exception — Challenging DOM’s bid buttons regenerate their id on every load, on purpose: locate them by role and text instead.
  • One flash convention. Outcomes land in <div id="flash" data-testid="flash" role="alert"> with .flash-success or .flash-error — one locator, site-wide.
  • Deterministic data. Tables, feeds and lists render the same rows every visit. Two loudly-labeled exceptions say so on the page itself: Pizza Order Toasts and The Bounty Board.

Each challenge page also has an Automation notes panel listing its stable selectors and at least one assertable outcome. Bug-hunt pages keep their test ids too, but never name the planted bugs.

Six steps from zero to a passing test

  1. Pick a challenge. Open the catalog, filter by category or difficulty, and choose a challenge. Nothing needs to be installed on the site side and no account is required.
  2. Read its automation notes. Every challenge page lists its stable ids and data-testids plus at least one assertable outcome, so you know what to locate before writing a line of test code.
  3. Point your framework at the page URL. Navigate to the challenge URL from Playwright, Selenium, Cypress, WebdriverIO or Puppeteer. The login example on this page is the same flow in all five.
  4. Locate by id, test id or role. Prefer the documented id or data-testid, or a role-based locator. Avoid positional CSS and unscoped text matches — the catalog contains challenges built to punish both.
  5. Assert the outcome, not the click. Check the site-wide flash element, a URL change, or the value the page reports. An action that "did not throw" is not a passing test.
  6. Shrink the waits for CI. Append ?delay=… to timer-driven pages and use /api/slow?ms=… when you need precise latency, so a realistic exercise still runs fast in a pipeline.

Hello, playground

The same login test in five frameworks — swap the base URL for localhost when running the repo locally:

Playwright (JS/TS)

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

test('the barista lets staff in', async ({ page }) => {
  await page.goto('https://funui.dev/challenges/login');
  await page.getByTestId('login-username').fill('gunther');
  await page.getByTestId('login-password').fill('CentralPerk123!');
  await page.getByTestId('login-submit').click();
  await expect(page).toHaveURL(/\/challenges\/login\/secure/);
  await expect(page.getByTestId('flash')).toHaveClass(/flash-success/);
});

Selenium (Python)

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

driver = webdriver.Chrome()
driver.get("https://funui.dev/challenges/login")
driver.find_element(By.ID, "login-username").send_keys("gunther")
driver.find_element(By.ID, "login-password").send_keys("CentralPerk123!")
driver.find_element(By.ID, "login-submit").click()
WebDriverWait(driver, 5).until(EC.url_contains("/challenges/login/secure"))
flash = driver.find_element(By.ID, "flash")
assert "flash-success" in flash.get_attribute("class")

Cypress

describe('Central Perk staff login', () => {
  it('lets staff into the secure area', () => {
    cy.visit('https://funui.dev/challenges/login');
    cy.get('[data-testid="login-username"]').type('gunther');
    cy.get('[data-testid="login-password"]').type('CentralPerk123!');
    cy.get('[data-testid="login-submit"]').click();
    cy.url().should('include', '/challenges/login/secure');
    cy.get('[data-testid="flash"]').should('have.class', 'flash-success');
  });
});

WebdriverIO

describe('Central Perk staff login', () => {
  it('lets staff into the secure area', async () => {
    await browser.url('https://funui.dev/challenges/login');
    await $('#login-username').setValue('gunther');
    await $('#login-password').setValue('CentralPerk123!');
    await $('#login-submit').click();
    await browser.waitUntil(async () => (await browser.getUrl()).includes('/challenges/login/secure'));
    await expect($('[data-testid="flash"]')).toHaveElementClass('flash-success');
  });
});

Puppeteer

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://funui.dev/challenges/login');
await page.locator('#login-username').fill('gunther');
await page.locator('#login-password').fill('CentralPerk123!');
await page.locator('#login-submit').click();
await page.waitForFunction(() => location.pathname.includes('/challenges/login/secure'));
const flashClass = await page.$eval('[data-testid="flash"]', el => el.className);
if (!flashClass.includes('flash-success')) throw new Error('login failed');
await browser.close();

The live backend

Challenges tagged 🔌 live backend use real HTTP behavior served by a tiny stateless API under /api (CORS open, nothing stored):

Running locally? Start it with cd functions && npm run local. The site stays usable while it is offline; only 🔌-tagged exercises need it.

EndpointBehavior
GET /api/healthliveness check — { ok: true, service: "funui" }
GET /api/basic-authHTTP Basic Auth (admin / secret); 401 + WWW-Authenticate otherwise
GET /api/status/:codeany allow-listed status code; 301/302 genuinely redirect to /api/status/200
GET /api/slow?ms=3000responds after ms milliseconds (clamped 0–10000)
GET /api/limitedreal rate limiter — 5 requests per IP / 30s, then 429 with a counting-down Retry-After
GET /api/download/:filewaybill.txt · manifest.csv · cargo.json with Content-Disposition: attachment
POST /api/uploadechoes the received byte count and content type; stores nothing

Speed knobs for CI

Every timer-driven page accepts ?delay=… to shrink its built-in waits: Dynamic Loading, Dynamic Controls, Tooltips, Obscured Elements, Streaming Feed, Session Timeout and the Autocomplete Combobox debounce. On the network side, /api/slow?ms=… lets you dial latency precisely, and /api/limited is a real rate limiter for retry/backoff drills. Everything else responds instantly — if your test needs a sleep, that’s the challenge working as intended.

Bug hunts

The bug-hunt pages each hide an exactly-stated number of intentional bugs — defined as deviations from the spec printed on the page. Hunt manually or write assertions against the spec and watch them fail; the spoilers are gated behind a confirmation click, and your findings checklist is saved locally.

Frequently asked questions

What selector conventions does Fun UI Playground use?

Every challenge follows the same four rules: semantic HTML first (real <label for>, <button>, <table> and aria-* attributes); a stable id and data-testid on every interactive element, prefixed with the challenge id (one loudly-labeled exception, Challenging DOM, regenerates its ids on purpose); one site-wide flash convention where outcomes land in a single #flash element with .flash-success or .flash-error; and deterministic data, so tables, feeds and lists render the same rows on every visit (two loudly-labeled exceptions: Notifications and Dynamic Content).

Does Fun UI Playground have a live backend for real HTTP behavior?

Yes. Challenges tagged "live backend" use real HTTP behavior served by a tiny stateless API under /api, with CORS open and nothing stored — including HTTP Basic Auth, HTTP Digest Auth, arbitrary status codes, a configurable response delay, a real rate limiter, file downloads with a real Content-Disposition header, and file upload.

How do I speed up timer-driven challenges for CI runs?

Every timer-driven page accepts a ?delay=… query parameter to shrink its built-in waits, including Dynamic Loading, Dynamic Controls, Tooltips, Obscured Elements, Streaming Feed and Session Timeout. On the network side, /api/slow?ms=… lets you dial latency precisely.

What are the bug-hunt pages?

The bug-hunt pages each hide an exactly-stated number of intentional bugs, defined as deviations from the spec printed on the page. You can hunt manually or write assertions against the spec and watch them fail — spoilers are gated behind a confirmation click.

Can I use Fun UI Playground in a CI pipeline?

Yes. The site is public, needs no signup or API key and stores no per-visitor state that a test could collide with, so a suite can run against funui.dev from any CI provider. Use ?delay=… to shrink built-in waits, and /api/slow and /api/limited when latency or rate limiting is the thing under test.

Where to practice

Updated

Fun UI Playground — all challenges, bug hunts, getting started, glossary, about.