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.
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:
- open
/v1; - type
Adainto Name; - click Save profile;
- 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"]
}
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;
-
ProfilePageowns 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?
No. The locator contract is wrong for v2; adding time does not create that element. Repair the selector using stable DOM evidence.
Why keep the assertion in the unittest instead of ProfilePage.save_name()?
The page object should provide page services/state; the test should retain the scenario’s failure meaning.
The status element is visible while it says saving. Why is visibility the wrong final wait?
Because the needed state is completion. Wait for saved:/error: (or the exact expected business state) rather than merely for visibility.
Can a successful IDE playback prove the Python migration is correct?
No. Verify the migrated test independently with its own session, capabilities, URL/DOM state, assertion result, and cleanup.
What should happen to the temporary browser profile after both pass and failure?
The owning fixture must quit the driver and remove the disposable profile in failure-safe cleanup.
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.
Primary references and version notes
- Selenium downloads — current stable WebDriver client/Grid release baseline.
- SeleniumHQ/selenium-ide — current Selenium IDE repository, installation model, and release history.
- Selenium IDE releases — verify the exact IDE build before relying on UI or export behavior.
- Selenium IDE Code Export — official export workflow and documented target frameworks.
- Selenium IDE Commands — documented command semantics including element waits and assertions.
- WebDriver waits — synchronization principles used after migration.
- Page Object Models — service-oriented abstraction and component composition used in the migrated design.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.