← All posts
Guide

Testing jQuery-era apps like they still matter

Somewhere in your company there is an app built between 2009 and 2016 that quietly runs a department. jQuery in the page. Bootstrap modals. A jQuery UI datepicker. select2 dropdowns. A DataTables grid with server-side paging. inputmask on the phone and currency fields. The markup has no data-testid anywhere, and the interesting buttons have IDs like #btnSave7. Nobody wants to touch it, everybody depends on it, and it has almost no automated test coverage because every attempt was miserable.

The misery was real and specific. These apps break the assumptions a modern end-to-end test is built on — not because they're badly written, but because they predate the conventions test tools now assume. Once you name the failure modes, each one has a fix — that's this guide.

Why it was miserable

The historical pain with jQuery-era apps comes down to two things: the widgets aren't native controls, and the markup wasn't written with testing in mind. Both are solvable, but only if you stop pretending the page is plain HTML.

Masked inputs eat DOM-set values

An inputmask field looks like a text box and isn't one. The library intercepts keystrokes, formats them, and rejects anything that doesn't fit the pattern. So the fast way most test tools fill a field — setting input.value directly through the DOM — does the wrong thing. The mask's keystroke handlers never fire, the formatted value never materializes, and the field ends up empty or validation rejects a value that "looks" right in the DOM but never passed through the mask.

// What a naive fill does — sets .value, skips the mask entirely
element.value = '2025551234';   // mask never runs; field reads blank or invalid

The fix is to type like a human: key by key, so every keydown/input handler the mask registered runs and the field formats itself the way it would for a real user.

// Real keystrokes: the mask formats "(202) 555-1234" as you'd expect
await page.getByLabel('Phone').pressSequentially('2025551234');

The same rule covers currency masks, date masks, and the auto-advancing "type the area code and it jumps to the next segment" fields. If your test framework fills by assignment, none of them work. If it types, they all do. TestVibe detects masked and auto-formatting inputs and types them keystroke-by-keystroke for exactly this reason — the mask logic has to run or the assertion downstream is testing a lie.

Fake dropdowns need their real gesture

A select2 (or Chosen, or Bootstrap-select) control renders a styled <span> that looks like a dropdown and hides the real <select> off-screen. Calling selectOption on the underlying element is a no-op the user could never trigger — it changes the hidden select without running the widget's own open/search/pick machinery, and often the visible label never updates.

You have to drive the gesture the user performs: click to open the rendered list, optionally type to filter, then click the option.

await page.locator('#country + .select2').click();          // open the widget
await page.getByRole('option', { name: 'Netherlands' }).click();
await expect(page.locator('#country + .select2')).toContainText('Netherlands');

The principle generalizes to every "component that wraps a native control": test the thing the user touches, not the hidden element it syncs to.

Modals stack and leave remnants

Bootstrap modals are notorious for this. Open one, close it, open another, and the first one's markup can linger in the DOM — hidden but present, with the same IDs and button text. A selector like page.click('#btnConfirm') now matches two elements, or matches the stale one, and you get a flake that looks real. Stacked modals (a confirm dialog over an edit dialog) make it worse: a bare text selector can land on the wrong layer entirely.

Scope every action through the visible dialog, and assert it's gone before moving on.

const dialog = page.getByRole('dialog', { name: 'Edit customer' });
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Save' }).click();
await expect(dialog).toBeHidden();   // don't act until this layer is actually gone

Scoping to the owning dialog is the single most useful habit here — the same discipline behind resilient locators generally.

Toasts auto-dismiss before you can assert

The success confirmation is a toast that fades out after three seconds. A test that clicks Save then looks for the toast is in a race it often loses on a loaded CI box — the notification is already gone by the time the locator resolves. People "fix" this with a sleep — too slow, and still sometimes too late.

The correct move is to arm the waiter before the action that triggers the toast, so the moment it appears you've already caught it.

const toast = page.getByText('Customer saved').waitFor({ state: 'visible' });
await dialog.getByRole('button', { name: 'Save' }).click();
await toast;   // armed before the click; the fade-out can't outrun it

TestVibe arms the visibility waiter before the triggering action for exactly this class of ephemeral UI, so an auto-dismissing banner is still caught deterministically.

Rich editors live in an iframe

If the app has a "description" or "notes" field powered by TinyMCE, the editable surface is inside an iframe, and the content you want to type or assert lives in a separate document. A locator scoped to the main frame will never find it, and hand-written suites often just skip the field.

You have to cross the frame boundary, then use real key events inside it.

const editor = page.frameLocator('iframe.tox-edit-area__iframe').locator('body');
await editor.click();
await editor.pressSequentially('Follow up next quarter.');
await expect(editor).toContainText('Follow up next quarter');

TestVibe records and replays elements inside iframes — including rich text editors — with stable frame-scoped locators, so the editor is just another field in the flow, not the reason the flow gets abandoned.

The selector problem, solved without a rewrite

Every technique above still runs into the same wall: the markup has no test hooks. No data-testid, no semantic classes you'd trust, just #btnSave7 and <div class="col-md-6">. The old advice was to go add attributes — which means a pull request against a fifteen-year-old codebase nobody wants to reopen, touching hundreds of views for tests that don't exist yet. That project never gets funded.

The way out is to stop anchoring to markup you can't change and anchor to the rendered page instead. Roles, accessible names, labels, and visible text are what the browser actually presents to a user — stable against exactly the churn that breaks brittle CSS selectors.

// Not this — tied to a generated ID that means nothing and may change
await page.click('#btnSave7');

// This — tied to what the user reads on the button
await page.getByRole('button', { name: 'Save' }).click();

The button labeled "Save" is findable by that label whether its ID is #btnSave7 or #btnSave12, whether it's wrapped in one more <div> after a layout tweak, whether the CSS class got renamed in a theme refresh. TestVibe generates locators from the rendered page — role, label, and text first — types masked fields keystroke-by-keystroke, and follows elements into iframes the way the sections above describe. Every generated test is verified against a live run of the app before you see it, so a scoping mistake on a stacked modal fails verification instead of shipping as a flake. See testing forms for the form-specific mechanics, and why tests flake for the deeper argument about anchoring assertions to behavior instead of structure.

The suite is what de-risks the rewrite

The reason to test the jQuery app isn't to prolong its life — it's that the app is the specification. Fifteen years of edge cases and "don't touch that, it breaks payroll" live in its behavior and nowhere else. When someone finally proposes rewriting it, the first question is always "how do we know the new one does what the old one did?" — and without an executable description of the current behavior, the honest answer is: you don't, you find out in production.

A suite that drives the real flows — the masked fields, the select2 pickers, the stacked modals, the toast confirmations — is that executable description. Run it against the legacy app today and it documents the truth; run it against the rewrite tomorrow and it tells you where the new build diverges from the behavior people depend on. The tests you write to cover the old app are the safety net you rewrite it over.

The jQuery app is still running the business. That's the reason to test it like it matters, not the excuse to keep putting it off.

Want a suite over your legacy app without retrofitting a single test attribute? Get early access, or read how AI generation works in the docs.

Early access

Ready for tests that write themselves?