---
title: "Export Tests to Playwright and Cypress From Mac in One Click"
description: "Export PiperTest recordings to clean Playwright or Cypress code. Deterministic selector mapping, mock rendering, temporal comments. Zero vendor lock-in."
date: 2026-04-02
author: "Ben Racicot"
tags: ["Testing", "Playwright", "Cypress", "Browser Automation", "Privacy", "macOS"]
type: "article"
canonical: "https://modelpiper.com/blog/export-tests-playwright-cypress/"
---

# Export Tests to Playwright and Cypress From Mac in One Click

> Export PiperTest recordings to clean Playwright or Cypress code. Deterministic selector mapping, mock rendering, temporal comments. Zero vendor lock-in.

## TL;DR

PiperTest exports recorded browser tests to idiomatic Playwright or Cypress code with deterministic selector mapping. role:button:Sign In becomes page.getByRole('button', { name: 'Sign In' }) in Playwright and cy.findByRole('button', { name: 'Sign In' }) in Cypress. Mocks map to page.route() and cy.intercept(). Temporal assertions emit // TEMPORAL: comments. The exported code is clean enough to commit directly. Author visually, run in CI with Playwright. Zero lock-in.

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           user@test.com
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('user@test.com');

    // 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('user@test.com');

    // 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](https://modelpiper.com) from modelpiper.com/download. 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](/workflows/ai-testing). For self-healing selectors, see [Self-Healing Test Selectors](/blog/self-healing-test-selectors). For the visual recorder, see [Test Recorder for Browser on Mac](/blog/test-recorder-browser-mac). For temporal assertions, see [Temporal Assertions](/blog/temporal-assertions-testing)._

## FAQ

### Does the exported code require any custom dependencies?

No. Playwright exports use only `@playwright/test`. Cypress exports use `cy` globals and `@testing-library/cypress` for role, label, and testid selectors. Both are standard dependencies that most test suites already include. No PiperTest-specific packages are needed to run the exported code.

### What happens to self-healing in the exported code?

Self-healing is a runtime feature of PiperTest's test runner. The exported code doesn't self-heal because Playwright and Cypress don't have that capability natively. However, the exported selectors use accessibility-based locators (getByRole, getByLabel) which are inherently more stable than CSS selectors. The AX-based selector strategy carries over, even though the active healing doesn't.

### Can I export the same test to both Playwright and Cypress?

Yes. PiperTest stores tests in its own format, and the export renderer can generate code for either framework from the same source. One test, two exports. This is useful during migrations - export to Cypress for your current pipeline and to Playwright for the pipeline you're building.

### How are temporal assertions handled in the export?

Temporal assertions (always, eventually, next) export as a standard one-time assertion with a `// TEMPORAL:` comment explaining the original temporal semantics. Neither Playwright nor Cypress has native temporal assertion support, so the comment preserves the intent while the code performs a point-in-time check. Developers can add explicit multi-point assertions in the exported code if temporal verification matters for CI.

### Is the export deterministic?

Yes. The same PiperTest steps always produce the same exported code. The selector mapping is a pure function with no randomness, no AI interpretation, and no heuristics. This means you can regenerate the export after editing steps and get predictable diffs in version control.
