The best testing tool is the one your CI pipeline can run. You can record tests visually, self-heal broken selectors, and manage coverage through a point-and-click interface. But the pipeline doesn't know any of that. The pipeline knows Playwright. It knows Cypress. It knows npx playwright test and npx cypress run.
This is why export matters more than any other feature in a visual testing tool. If you can't get your tests out, you're locked in. Every visual testing platform that charges $450/month for cloud execution is betting you won't leave. Every tool that stores tests in a proprietary format nobody else can read is counting on switching costs to keep you.
PiperTest's export is the opposite bet. It's designed to make leaving easy. One click, and your visual test becomes clean Playwright or Cypress code that you own, commit, and run wherever you want. The export isn't a feature. It's an escape hatch that makes every other feature safe to use.
Why does test portability matter?
The testing tool market has a pattern. A tool launches with a generous free tier. Teams build test suites on it. Prices go up. The team realizes their 200 tests are locked in a proprietary format with no export. Migration means rewriting everything from scratch.
Cypress-to-Playwright migration is a documented phenomenon in 2026. Dedicated portals like cy2pw.com exist because teams hit Cypress's scaling limits - no free parallel execution, Chromium-only in practice, $67-267+/month for Cloud - and need to move. Teams report 2-4 weeks for a medium-sized suite migration, even with AI-assisted rewriting tools like Copilot handling the repetitive parts.
PiperTest sidesteps this entirely. Your tests exist in two forms simultaneously: the visual PiperTest format (for authoring and self-healing) and the exported code (for CI execution). If you stop using PiperTest tomorrow, your Playwright tests keep running. Nothing breaks. Nothing needs migrating. The tests you exported are standard framework code.
This isn't theoretical. Teams using Testim, mabl, and other cloud testing platforms discovered the hard way that proprietary test formats create real switching costs. A team that invested six months building 300 tests in a cloud platform can't move those tests to Playwright without rewriting every single one. PiperTest's export means that investment is portable from day one.
How does selector mapping work?
PiperTest stores selectors in a unified AX format: role:button:Sign In, label:Email, text:Welcome, testid:submit-btn. Each selector type has a deterministic mapping to both Playwright and Cypress.
The mapping is a pure function. Same input, same output, every time. No heuristics, no AI interpretation, no guessing. The export renderer parses each PiperTest selector, identifies its type (role, label, text, testid, css), and calls the appropriate framework-specific generator.
Here's what happens under the hood for each selector type:
Role selectors are the most common because PiperTest's recorder prefers them. role:button:Sign In splits into role (button) and name (Sign In). Playwright gets page.getByRole('button', { name: 'Sign In' }). Cypress gets cy.findByRole('button', { name: 'Sign In' }). Both target the accessibility tree rather than CSS, which means the exported selectors are as stable as the PiperTest originals.
Label selectors target form fields by their associated label text. label:Email becomes page.getByLabel('Email') in Playwright and cy.findByLabelText('Email') in Cypress. This is the correct selector for any input field that has a proper <label> element - which is every input field in an accessible application.
Text selectors match visible text content. text:Welcome to the dashboard becomes page.getByText('Welcome to the dashboard') in Playwright and cy.contains('Welcome to the dashboard') in Cypress. These are useful for headings, paragraphs, and any element identified primarily by its visible text.
TestID selectors use data-testid attributes. testid:submit-btn becomes page.getByTestId('submit-btn') in Playwright and cy.findByTestId('submit-btn') in Cypress. If your app uses data-testid attributes, these survive any visual refactor because they're explicit anchors added by developers.
CSS selectors are the fallback when no semantic selector exists. css:.custom-widget becomes page.locator('.custom-widget') in Playwright and cy.get('.custom-widget') in Cypress. PiperTest only falls back to CSS when the accessibility tree doesn't provide a usable identifier - rare for well-built applications, common for custom web components with closed shadow roots.
What about hierarchical selectors?
Real applications have ambiguous elements. Two forms on the same page, each with a "Submit" button. Three navigation sections, each with a "Home" link. PiperTest handles this with hierarchical scoping: role:form:Login > role:button:Submit means "the Submit button inside the Login form."
The export renderer handles hierarchical selectors by chaining locators. In Playwright, the ancestor becomes the scope:
page.getByRole('form', { name: 'Login' }).getByRole('button', { name: 'Submit' })In Cypress, it chains with Testing Library methods:
cy.findByRole('form', { name: 'Login' }).findByRole('button', { name: 'Submit' })The ancestor-descendant relationship is preserved exactly. If PiperTest scoped a selector to a specific form during recording, the exported code scopes to that same form. Ambiguity in PiperTest means ambiguity in the export, which means the exported test catches the same edge cases.
How do actions map between formats?
Every PiperTest action type has a direct equivalent in both frameworks. The mapping covers the full interaction vocabulary:
Navigate. PiperTest's navigate action becomes await page.goto('https://example.com/login') in Playwright and cy.visit('https://example.com/login') in Cypress. If a base URL is configured, it's prepended automatically.
Click. Maps to await page.getByRole('button', { name: 'Sign In' }).click() in Playwright and the equivalent .click() chain in Cypress.
Fill. PiperTest's fill becomes .fill('value') in Playwright and .clear().type('value') in Cypress. The Cypress version explicitly clears first because Cypress's .type() appends by default. This detail matters - without the .clear(), re-running a Cypress test against a pre-filled form would concatenate values instead of replacing them.
Press. Keyboard actions become await page.keyboard.press('Enter') in Playwright and cy.get('body').type('{enter}') in Cypress. Each framework has different syntax for special keys, and the renderer handles the translation.
Hover. Maps to .hover() in Playwright and .trigger('mouseover') in Cypress. Cypress doesn't have a native hover command, so the renderer uses the trigger approach.
Wait. Becomes .waitFor() in Playwright and .should('exist') in Cypress. Both wait for the element to appear in the DOM before proceeding.
Scroll. Maps to await page.mouse.wheel(0, 300) in Playwright and cy.scrollTo(0, 300) in Cypress.
How do assertions export?
PiperTest's seven assertion types each have framework-specific equivalents:
Visible. await expect(locator).toBeVisible() in Playwright. .should('be.visible') in Cypress.
Hidden. await expect(locator).toBeHidden() in Playwright. .should('not.be.visible') in Cypress.
Text. await expect(locator).toContainText('expected') in Playwright. .should('contain.text', 'expected') in Cypress.
URL. await expect(page).toHaveURL(/pattern/) in Playwright. cy.url().should('match', /pattern/) in Cypress.
Count. await expect(locator).toHaveCount(3) in Playwright. .should('have.length', 3) in Cypress.
Attribute. await expect(locator).toHaveAttribute('href', '/dashboard') in Playwright. .should('have.attr', 'href', '/dashboard') in Cypress.
Each assertion uses the framework's native expectation API. The exported tests don't import custom assertion libraries or helper functions. They're standard tests that any developer familiar with the framework can read immediately.
What happens to temporal assertions during export?
Temporal assertions are PiperTest's time-aware verification system. An always assertion checks that a condition holds continuously across multiple steps. An eventually assertion checks that a condition becomes true within a deadline. A next assertion verifies a condition at the immediately following step.
Neither Playwright nor Cypress has native temporal assertion support. Playwright has waitFor. Cypress has retry-ability. Neither has a concept of "this condition must hold for the entire test" or "this must become true within 3 seconds while other steps execute."
The export renderer handles this honestly. Temporal assertions export as a standard one-time check with a comment explaining the temporal semantics:
// TEMPORAL: always(visible) - evaluated across steps in PiperTest; one-time check below
await expect(page.getByRole('banner')).toBeVisible();The comment tells the developer what PiperTest was doing - continuous evaluation across steps - and that the exported code only performs a point-in-time check. If the temporal behavior matters for CI, the developer can add explicit assertions at multiple points in the test or use Playwright's polling utilities to approximate the behavior.
This is a deliberate design choice. Exporting something that looks like it works but doesn't actually replicate the temporal behavior would be worse than being explicit about the limitation. The comment preserves the intent. The developer decides how to handle it in CI.
How do mocks export?
PiperTest supports three mock actions: fulfill (return a custom response), fail (abort the request), and modify (change the URL or headers of a request). Each maps cleanly to both frameworks.
Fulfill mocks become page.route() in Playwright and cy.intercept() in Cypress:
// Playwright
await page.route('**/api/users', route => route.fulfill({
status: 200,
body: '{"users": []}'
}));
// Cypress
cy.intercept('**/api/users', {
statusCode: 200,
body: '{"users": []}'
});Fail mocks abort the request entirely:
// Playwright
await page.route('**/api/users', route => route.abort('failed'));
// Cypress
cy.intercept('**/api/users', { forceNetworkError: true });Modify mocks change request properties while allowing the request to continue:
// Playwright
await page.route('**/api/users', route => route.continue({
url: 'https://staging.example.com/api/users'
}));
// Cypress
cy.intercept('**/api/users', (req) => {
req.url = 'https://staging.example.com/api/users';
});Custom headers, status codes, and response bodies are all preserved in the export. A PiperTest that mocks three API endpoints and verifies error handling exports to code that sets up the same three mocks with the same responses.
What does the generated code actually look like?
Here's a complete PiperTest login flow and its Playwright export:
PiperTest steps:
navigate https://app.example.com/login
fill label:Email [email protected]
fill label:Password secret123
click role:button:Sign In
assert text role:heading = "Dashboard"Exported Playwright code:
import { test, expect } from '@playwright/test';
test.describe('Login Flow', () => {
test('main flow', async ({ page }) => {
// Navigate to login page
await page.goto('https://app.example.com/login');
// Fill email field
await page.getByLabel('Email').fill('[email protected]');
// Fill password field
await page.getByLabel('Password').fill('secret123');
// Click sign in button
await page.getByRole('button', { name: 'Sign In' }).click();
// Verify dashboard heading
await expect(page.getByRole('heading')).toContainText('Dashboard');
});
});That's code you'd write by hand. No generated variable names. No CSS selector fallbacks. No // auto-generated, do not edit warnings. Each step has a comment from the PiperTest step description, which means the generated code is self-documenting.
The Cypress equivalent is equally clean:
describe('Login Flow', () => {
it('main flow', () => {
// Navigate to login page
cy.visit('https://app.example.com/login');
// Fill email field
cy.findByLabelText('Email').clear().type('[email protected]');
// Fill password field
cy.findByLabelText('Password').clear().type('secret123');
// Click sign in button
cy.findByRole('button', { name: 'Sign In' }).click();
// Verify dashboard heading
cy.findByRole('heading').should('contain.text', 'Dashboard');
});
});How does this compare to Playwright codegen?
Playwright's codegen is the closest comparable feature. You run npx playwright codegen, interact with a browser, and it generates test code. The difference is in what gets recorded and how stable it is.
Playwright codegen captures DOM interactions and generates selectors from the DOM. The output frequently includes over-specific locators - CSS classes, nth-child selectors, deeply nested paths. As one analysis noted, "raw codegen output tends to include extra navigation, brittle selectors, and missing assertions." The generated code is a starting point that needs manual cleanup before it's production-ready.
PiperTest's recorder captures AX tree interactions and generates AX selectors. The output uses role:button:Sign In instead of .btn-primary-lg. When you export to Playwright, these become getByRole() calls - the same selectors Playwright's own documentation recommends as best practice. The exported code is already following Playwright's recommended selector strategy because PiperTest records at the accessibility level by default.
Playwright codegen also doesn't generate assertions. It captures actions - clicks, fills, navigations - but doesn't know what you want to verify. PiperTest's recorded assertions export as real Playwright expect() calls. The exported test is complete: actions and verifications, not just a click log.
Can I edit the exported code?
Yes. The exported code is standard Playwright or Cypress. Add custom logic, wrap it in fixtures, integrate it with your existing test utilities. The export is a starting point, not a locked artifact.
A common workflow: record the happy path in PiperTest, export to Playwright, then add parameterized test data, custom authentication setup, or environment-specific configuration that PiperTest's visual editor doesn't cover. The exported test becomes the foundation. Custom code adds the specifics.
This is the right division of labor. PiperTest handles the repetitive part - recording 30 steps of clicking, filling, and asserting. The developer handles the unique part - data providers, auth tokens, environment branching. Neither wastes time on the other's work.
What about the export via MCP?
The test_export MCP tool generates the same code programmatically. An AI agent can record a test with test_save, run it with test_run, and export it with test_export - all through MCP without opening the PiperTest UI.
The MCP export returns a code block with the filename (login-flow.spec.ts for Playwright, login-flow.cy.ts for Cypress) so the AI can write the file directly to the project. This enables fully automated test generation: the AI explores the app, generates PiperTest steps, validates them, and exports CI-ready code.
Try it
Download ToolPiper from the Mac App Store. Record a test against any web application. Click Export, choose Playwright or Cypress, and paste the result into your test directory. Run npx playwright test and watch it pass.
The export is the proof that PiperTest doesn't lock you in. It's also the bridge between visual authoring and CI execution. Record visually, run everywhere.
This is part of a series on AI-powered testing workflows. For self-healing selectors, see Self-Healing Test Selectors. For the visual recorder, see Test Recorder for Browser on Mac. For temporal assertions, see Temporal Assertions.