There's a whole class of .NET web app that keeps a business running and that nobody wants to touch. WebForms with UpdatePanel postbacks. A Web Site project — not a Web Application project — that builds by magic and breaks if you look at it wrong. An early Wisej line-of-business tool on the intranet. Something on a framework version nobody supports anymore. It works. It ships invoices, schedules shifts, closes the books. And it has zero automated tests, because every time someone scopes test automation for it, they're told the same thing: you need modern hooks first. data-testid attributes on every control. A component test harness. A frontend build step. Modernize the app before you can test it.
That advice is wrong, and it's expensive, because it inverts the order that actually keeps you safe. You test the old app first, black-box, exactly as it renders today — and the suite you get is what makes the eventual modernization survivable.
Black-box means the framework version doesn't matter
Here's the load-bearing idea. A browser-level test drives the rendered page — the HTML, the events, the network round trips the user's browser actually makes. It does not import your framework, link against an SDK, or care what compiled the markup. WebForms 4.5, Wisej 2.x, an ASP.NET MVC app on a .NET Framework version nobody wants named in standup — from the browser's side of the wire, they're all just a page that responds to clicks.
That means none of the usual prerequisites are prerequisites:
- No SDK or instrumentation. You don't add a test package to the app. You don't recompile it. You don't deploy a special build.
- No
data-testidretrofit. Nice to have, never required. More on locating controls below. - No framework upgrade. The version that stopped getting attention is fine. The test drives what it renders.
If the app opens in a browser and a human can use it, it's testable today, in its current state, with no code change. That's the whole unlock.
The postback pattern is a solved problem
The thing people fear most about testing WebForms is the postback. Click a button, the whole page (or an UpdatePanel region) round-trips to the server, and the DOM you were holding a reference to gets replaced. Test tooling from the Selenium-and-explicit-sleeps era handled this badly — you'd click, guess at a sleep duration, and hope the new DOM had landed.
Modern Playwright-style web-first assertions retry until a condition is true or a timeout expires. That maps onto a full-page postback almost perfectly: you don't wait for a duration, you wait for the result.
// Trigger a postback, then assert on what it produced.
// The assertion retries across the entire round trip — no sleep.
await page.getByRole('button', { name: 'Calculate total' }).click();
await expect(page.getByText(/Total: \$[\d,]+\.\d{2}/)).toBeVisible();
An UpdatePanel partial postback is the same shape — the region swaps, the assertion keeps re-checking until the new content is there. If you want to be explicit about the server call itself, wait on the response instead of the DOM:
const post = page.waitForResponse((r) => r.request().method() === 'POST' && r.ok());
await page.getByRole('button', { name: 'Save' }).click();
await post;
await expect(page.getByText('Saved')).toBeVisible();
The reason this matters for old .NET apps specifically: postbacks are slow and variable — a loaded app server, a chatty ViewState, a report that takes two seconds on a cold cache. Condition-based waiting absorbs all of that; fixed sleeps never could, which is why the old suites flaked and got deleted. It's the same discipline that kills flake anywhere — the web-first assertion pattern doing the load-bearing work.
Designer-generated control names, and how to locate them
The next objection: "our controls have names like ctl00$MainContent$txtInvoiceAmount, we can't write selectors against that." Correct — you shouldn't. Auto-generated IDs are the definition of a brittle selector; a master-page change or a control moving containers rewrites them.
Anchor to what the user perceives instead, in the same priority order you'd use on any app: the accessible role and name first, a label association second, visible text as a last resort. A field the user reads as "Invoice amount" is reachable that way regardless of what the designer named the control:
// Not: page.locator('#ctl00_MainContent_txtInvoiceAmount')
await page.getByLabel('Invoice amount').fill('1250.00');
await page.getByRole('button', { name: 'Post' }).click();
When a control has no real label — an unlabeled grid cell, an icon-only toolbar button, common on old LOB screens — you scope through a stable landmark (the dialog title, a section heading, a nearby caption) rather than through DOM position. The full reasoning is in locators that survive a redesign, and it applies double here: the markup of a legacy app is exactly the markup most likely to get quietly refactored under you, so the selector has to key on meaning, not structure.
Wisej apps have one extra wrinkle worth naming: Wisej keeps hidden MDI forms in the DOM, so a designer name like txtUsername can appear on several forms at once. The fix is the same principle — scope through the owning dialog (getByRole('dialog', { name: 'Login' })) rather than reaching for a bare name or a positional .nth(). If you enable the Wisej.NET plugin on a project, generated tests also get framework-aware helpers that read a virtualized DataGridView from its backing model instead of scraping the ~20 rows the DOM actually holds — the difference between "the filter set but the grid shows 0 rows" and reading the true row count. But that's a bonus on the black-box baseline, not a requirement of it.
Masked inputs and conditional dialogs
Two more things old .NET forms lean on heavily, both handled:
Masked / auto-formatting inputs. Phone, currency, and date masks run their formatting logic on real keystrokes. A test that shoves a value straight into the DOM skips the mask handler and either fails or, worse, submits an unformatted value the server rejects. Typing the field keystroke-by-keystroke lets the mask run exactly as it does for a user, so the formatted value is what actually gets posted.
Conditional dialogs. The "You have unsaved changes" confirm(), the session-timeout warning, the intermittent "A newer version is available" banner — legacy apps are full of dialogs that appear sometimes. Native JS dialogs (alert/confirm/prompt) and conditional blocking modals are handled by arming the handler before the action that might trigger them, so a dialog that shows up only under certain data doesn't wedge the run. And because every scenario gets a fresh browser context, a cookie or consent banner you dismiss replays its dismissal every run rather than leaking across tests.
The suite is what makes the rewrite safe
Here's the part that's really about politics, not tooling. The reason to put tests on the old app is not to keep the old app forever. It's that a green regression suite, written against current behavior, is the only honest contract for a modernization. When someone finally gets budget to move that WebForms screen to Blazor, or to lift the intranet tool onto a current framework, the tests describe what the app does — in user terms, not implementation terms — and they keep running against the new implementation because they never depended on the old one's internals.
You write them once, against the app that exists. They stay green through the rewrite, or they go red and tell you exactly which behavior it broke. That's the argument that gets the work funded: not "let's test the legacy app," but "let's not modernize blind." And since the true cost sits in maintenance, not authoring, generating and running the suite for you rather than staffing it changes the math. Getting coverage on the current app before the change lands is shift-left in its most literal form: the bug you catch is the one the rewrite would have shipped.
Getting started on an app you can't change
The workflow is deliberately boring, which is the point. You point TestVibe at the app's URL, describe a flow in plain language ("log in, open an invoice, change the amount, save, confirm the new total shows"), and it generates a real Playwright spec, verifies it by running against the live app in an isolated cloud sandbox, and shows you the trace. No package added to your solution, no build change, no framework upgrade on the critical path. The old ERP screen and the six-month-old marketing site go through the identical door.
Have a .NET app that everyone's afraid to touch? That's exactly the app that most needs a suite before anyone touches it. Get early access and put a net under it first.