Chapter 26Lesson 02~230 minutes

Selenium IDE, Record/Playback, and Migration to Maintainable Code: Guided Hands-On Workflow

Record one short loopback flow, inspect the resulting command/target/value sequence, break it with a controlled markup change, then migrate the scenario manually into maintainable Python WebDriver code.

Local labBrittle locatorExplicit waitPage Objectunittest

Learning objectives

  • Run a disposable local v1/v2 fixture suitable for IDE recording.
  • Inspect commands, targets, values, and synchronization before trusting playback.
  • Diagnose a controlled locator failure caused by markup change.
  • Repair the test with stable selectors and state-based waits.
  • Migrate the flow to Python WebDriver without copying recorder architecture verbatim.

1. Preflight and disposable fixture

The mandatory lab is entirely local and free. It binds to 127.0.0.1:8826, uses the synthetic name Ada, creates no account, and has two markup versions. The visible behavior is intentionally the same in v1 and v2, but one generated-looking button ID changes.

from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
import json

HOST, PORT = "127.0.0.1", 8826

PAGE = """<!doctype html><html><head><meta charset='utf-8'><title>IDE Migration Lab</title></head><body>
<main>
  <h1>IDE Migration Lab</h1>
  <label>Name <input id='display-name' data-testid='display-name' autocomplete='off'></label>
  <button id='{button_id}' data-testid='save-profile'>Save profile</button>
  <p id='status' data-testid='status' aria-live='polite'>idle</p>
</main>
<script>
const button = document.querySelector('[data-testid="save-profile"]');
button.addEventListener('click', () => {{
  const value = document.querySelector('[data-testid="display-name"]').value.trim();
  const status = document.querySelector('[data-testid="status"]');
  status.textContent = 'saving';
  setTimeout(() => {{ status.textContent = value ? 'saved:' + value : 'error:name required'; }}, 180);
}});
</script></body></html>"""

class Handler(BaseHTTPRequestHandler):
    def log_message(self, *args):
        pass
    def send_bytes(self, status, content_type, raw):
        self.send_response(status)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(raw)))
        self.end_headers(); self.wfile.write(raw)
    def do_GET(self):
        u = urlparse(self.path)
        if u.path == "/health":
            raw = json.dumps({"ok": True, "variants": ["v1", "v2"]}).encode()
            return self.send_bytes(200, "application/json", raw)
        if u.path in ("/v1", "/v2"):
            # The changing generated-looking id models a brittle recorder-selected locator.
            button_id = "save-profile-427" if u.path == "/v1" else "save-profile-982"
            raw = PAGE.format(button_id=button_id).encode()
            return self.send_bytes(200, "text/html; charset=utf-8", raw)
        self.send_error(404)

if __name__ == "__main__":
    ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()

Save as fixture_server.py, run python fixture_server.py, then verify http://127.0.0.1:8826/health. Stop if that endpoint is not loopback. Keep the server terminal visible so the target is unambiguous.

2. Record the v1 flow in the current IDE

Open the installed Selenium IDE build, create a disposable project, set the base URL to http://127.0.0.1:8826, and record only this flow:

  1. open /v1;
  2. type Ada into Name;
  3. click Save profile;
  4. observe the status reach saved:Ada.

Immediately stop recording. Inspect every command rather than pressing playback repeatedly. The conceptual learning artifact below mirrors the command/target/value shape; your installed IDE may serialize IDs/metadata differently.

{
  "id": "academy-ide-migration",
  "version": "2.0",
  "name": "IDE Migration Learning Artifact",
  "url": "http://127.0.0.1:8826",
  "tests": [{
    "id": "profile-flow",
    "name": "save profile v1",
    "commands": [
      {"command": "open", "target": "/v1", "value": ""},
      {"command": "type", "target": "id=display-name", "value": "Ada"},
      {"command": "click", "target": "id=save-profile-427", "value": ""},
      {"command": "wait for element visible", "target": "css=[data-testid='status']", "value": "3000"},
      {"command": "assert text", "target": "css=[data-testid='status']", "value": "saved:Ada"}
    ]
  }],
  "suites": [],
  "urls": ["http://127.0.0.1:8826/v1"]
}
Do not force your recording to match the sample

The lesson is about semantics. Recorder-selected locators can differ by IDE build and page inspection. Record what your current IDE produced, then evaluate it.

3. Add a real assertion and a readiness condition

A recorder can capture interactions faster than application semantics. The fixture deliberately sets status to saving and only later to saved:Ada. A fixed pause guesses elapsed time; a state-based wait asks for the condition the assertion actually needs.

In IDE, use the current documented element-wait/assertion commands available in your build. The sample artifact uses wait for element visible plus assert text, but visibility alone does not guarantee the asynchronous save completed. If your IDE supports a text/state wait appropriate to the target, prefer that. Otherwise keep the IDE demonstration small and make the semantic wait explicit in the migrated code.

4. Break the recorder-style locator deliberately

Change only the target path from /v1 to /v2 and replay. In v2, the button ID changes from save-profile-427 to save-profile-982, while the semantic data-testid="save-profile" contract remains stable.

If the recorded target was the generated-looking ID, expect a “target not found”/locator-style failure at the click step. Preserve that first failure. Do not add a delay; elapsed time cannot make a nonexistent ID appear. If your recorder chose the stable data-testid selector already, manually substitute the brittle ID in a copy of the learning artifact to reproduce the failure safely.

5. Repair intent before exporting

Curate the selector to css=[data-testid='save-profile']. Then replay v2. This repair is causal: it changes the selection contract, not timing. Document the before/after target and the DOM fact that justified the change.

Now compare the broken WebDriver-style translation below. It demonstrates why mechanical export is not enough:

# Deliberately brittle: it preserves a generated-looking recorder locator.
from selenium import webdriver
from selenium.webdriver.common.by import By

browser = webdriver.Chrome()
try:
    browser.get("http://127.0.0.1:8826/v2")
    browser.find_element(By.ID, "display-name").send_keys("Ada")
    browser.find_element(By.ID, "save-profile-427").click()  # v2 changed this id
finally:
    browser.quit()

6. Manual migration to maintainable Python

The primary course binding remains Python. Rather than depending on the IDE exporter’s framework shape, migrate the scenario manually so lifecycle and abstraction ownership match Chapters 13–15.

import unittest
from pathlib import Path
import tempfile, shutil
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait

BASE_URL = "http://127.0.0.1:8826"

class ProfilePage:
    NAME = (By.CSS_SELECTOR, "[data-testid='display-name']")
    SAVE = (By.CSS_SELECTOR, "[data-testid='save-profile']")
    STATUS = (By.CSS_SELECTOR, "[data-testid='status']")

    def __init__(self, driver):
        self.driver = driver

    def open(self, variant="v2"):
        self.driver.get(f"{BASE_URL}/{variant}")
        WebDriverWait(self.driver, 3).until(
            lambda d: d.find_element(*self.STATUS).text == "idle"
        )
        return self

    def save_name(self, name):
        field = self.driver.find_element(*self.NAME)
        field.clear(); field.send_keys(name)
        self.driver.find_element(*self.SAVE).click()
        WebDriverWait(self.driver, 3).until(
            lambda d: d.find_element(*self.STATUS).text.startswith(("saved:", "error:"))
        )

    def status(self):
        return self.driver.find_element(*self.STATUS).text

class ProfileFlowTest(unittest.TestCase):
    def setUp(self):
        self.profile = Path(tempfile.mkdtemp(prefix="ide-migration-"))
        options = webdriver.ChromeOptions()
        options.add_argument("--headless=new")
        options.add_argument(f"--user-data-dir={self.profile}")
        self.driver = webdriver.Chrome(options=options)

    def tearDown(self):
        try:
            self.driver.quit()
        finally:
            shutil.rmtree(self.profile, ignore_errors=True)

    def test_save_profile(self):
        page = ProfilePage(self.driver).open("v2")
        page.save_name("Ada")
        self.assertEqual("saved:Ada", page.status())

if __name__ == "__main__":
    unittest.main()

Important state ownership:

  • the test fixture owns browser creation, temporary profile, and cleanup;
  • ProfilePage owns stable selectors, page opening, interaction, and the wait condition nearest the UI state it understands;
  • the test owns the business assertion saved:Ada;
  • Selenium Manager resolves the local driver by default;
  • no raw IDE project data or real credentials are required at runtime.

7. Before/after verification and challenge

Capture a small evidence record for both the IDE learning run and WebDriver run: IDE build, Selenium version, browser/version, v1/v2 URL, the recorded and repaired button target, WebDriver session ID, final status text, and cleanup result. A screenshot is optional because the decisive evidence here is the locator/DOM contract and final state.

Challenge: the status element is always visible, but its text changes asynchronously. Which control is correct after migration: a 1-second sleep, visibility wait, or a wait for the required status state? Choose from the mental model before editing code.

Answer: wait for the state needed by the assertion. Visibility is insufficient because the element is visible while it says idle or saving; a fixed sleep is a timing guess.

Knowledge checks

Answer from the operating model, then reveal the explanation.

The v2 click fails instantly because id=save-profile-427 is absent. Is this synchronization?

Why keep the assertion in the unittest instead of ProfilePage.save_name()?

The status element is visible while it says saving. Why is visibility the wrong final wait?

Can a successful IDE playback prove the Python migration is correct?

What should happen to the temporary browser profile after both pass and failure?

Summary and next bridge

  • Record against a controlled loopback target and inspect before replaying repeatedly.
  • Preserve first failure and classify locator failure separately from timing failure.
  • Curate semantic selectors instead of inheriting generated-looking recorder choices.
  • Migrate intent and lifecycle—not a line-for-line command list.
  • Verify the WebDriver result independently and clean disposable state.

Lesson 3 compares recording, export, manual migration, locator curation, debugging, and CI architecture as explicit design choices.

Next lesson

Selenium IDE, Record/Playback, and Migration to Maintainable Code: Configuration, Design Patterns, and Trade-Offs

Continue with Selenium IDE, Record/Playback, and Migration to Maintainable Code: Configuration, Design Patterns, and Trade-Offs. It builds directly on the state, evidence, and operating assumptions established here, so carry those constraints forward rather than treating the next page as an isolated topic.

Primary references and version notes

Version baseline — August 2026

The WebDriver examples pin selenium==4.47.0 and Python 3.10+. Selenium Manager remains the normal local driver-resolution path. Selenium IDE is versioned separately: the SeleniumHQ repository currently marks v4.0.1-beta.14 as its latest GitHub release, published July 20, 2024. Official IDE Code Export documentation lists C# NUnit, Java JUnit, JavaScript Mocha, and Python pytest, but the mandatory course path still verifies the installed IDE build and migrates manually so recorded/exported code is never treated as authoritative architecture.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.