ScreenshotNeo

BlogHow-to

How to Call a Playwright Test from Another Test

Playwright tests are independent by design. Learn when to use helpers, fixtures, test.step(), project dependencies, or a deliberate shared page.

By the ScreenshotNeo team1 October 20268 min read

Short answer: do not call one declared Playwright test from another. Playwright Test is designed around independently runnable tests. Extract reusable behavior into a regular helper, provide shared setup through a fixture, group a sequence with test.step(), or order setup and dependent test projects with project dependencies. A shared page across serial tests is possible, but it is a deliberate trade-off that reduces isolation and affects retries.

This guide shows each pattern, when to choose it, complete examples, failure modes, and the boundary between test-level reuse and project-level setup.

1. Why a Playwright test should not call another test

A declared test is a runner unit. Playwright schedules it, supplies fixtures, records its result, retries it when configured, and may run it in parallel with other tests. Calling a test from another test would couple those runner responsibilities and make ordering and ownership unclear.

Playwright’s parallelism guidance states: “Above all, keep your tests isolated from one another.” A test that depends on another test’s side effects can fail when workers run tests in a different order or at the same time. Prepare the required state in each test or in a fixture instead.

Use the following decision table:

Need Use Scope
Reuse a few actions Regular helper function Inside each test
Share setup and resources Custom fixture with test.extend() Test or worker lifecycle
Show a named sequence in one report test.step() Inside one test
Run setup before a group of tests Project dependencies Project level
Reuse one browser page deliberately beforeAll/afterAll with serial mode Special, coupled case

2. Reuse actions with a regular helper

For a short, stateless action, extract an ordinary function. Each test remains independently discoverable, reportable, retryable, and responsible for its own assertions.

import { test, expect, type Page } from '@playwright/test';

async function login(page: Page, email: string, password: string) {
  await page.goto('https://example.com/login');
  await page.getByLabel('Email').fill(email);
  await page.getByLabel('Password').fill(password);
  await page.getByRole('button', { name: 'Sign in' }).click();
}

test('profile is visible after login', async ({ page }) => {
  await login(page, 'alice@example.com', 'correct-password');
  await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible();
});

test('orders are visible after login', async ({ page }) => {
  await login(page, 'alice@example.com', 'correct-password');
  await page.getByRole('link', { name: 'Orders' }).click();
  await expect(page.getByRole('heading', { name: 'Orders' })).toBeVisible();
});

The helper is not a test and does not create a second test result. Keep assertions in the calling test when the same action can lead to different checks. Pass the Page, data, and other dependencies as arguments instead of hiding global state.

3. Share setup with a custom fixture

Fixtures are Playwright Test’s native mechanism for reusable setup. A fixture can prepare data, log in a context, create a client, and tear it down after await use(...). Fixtures can be composed and reused across test files. Test-scoped fixtures are set up and torn down for each test; worker-scoped fixtures live for the worker.

// fixtures.ts
import { test as base, expect, type Page } from '@playwright/test';

type Fixtures = {
  loggedInPage: Page;
};

export const test = base.extend<Fixtures>({
  loggedInPage: async ({ page }, use) => {
    await page.goto('https://example.com/login');
    await page.getByLabel('Email').fill('alice@example.com');
    await page.getByLabel('Password').fill('correct-password');
    await page.getByRole('button', { name: 'Sign in' }).click();
    await expect(page.getByRole('heading', { name: 'Home' })).toBeVisible();
    await use(page);
  },
});

export { expect } from '@playwright/test';
// account.spec.ts
import { test, expect } from './fixtures';

test('profile loads', async ({ loggedInPage }) => {
  await loggedInPage.goto('https://example.com/profile');
  await expect(loggedInPage.getByRole('heading', { name: 'Profile' })).toBeVisible();
});

test('orders load', async ({ loggedInPage }) => {
  await loggedInPage.goto('https://example.com/orders');
  await expect(loggedInPage.getByRole('heading', { name: 'Orders' })).toBeVisible();
});

The runner still executes two independent tests. It owns the fixture lifecycle, so a failed setup is reported as setup failure and teardown runs according to fixture scope.

See the official Playwright fixtures documentation for fixture extension, composition, and scope.

4. Group work inside one test with test.step()

Use a step when the work should appear as a named unit in the current test report. A step can contain actions, assertions, and nested steps, but it does not turn a step into a separately callable test.

import { test, expect } from '@playwright/test';

test('checkout flow', async ({ page }) => {
  await test.step('Log in', async () => {
    await page.goto('https://example.com/login');
    await page.getByLabel('Email').fill('alice@example.com');
    await page.getByLabel('Password').fill('correct-password');
    await page.getByRole('button', { name: 'Sign in' }).click();
  });

  await test.step('Add an item', async () => {
    await page.getByRole('link', { name: 'Products' }).click();
    await page.getByRole('button', { name: 'Add to cart' }).first().click();
  });

  await test.step('Verify checkout', async () => {
    await page.getByRole('link', { name: 'Cart' }).click();
    await expect(page.getByRole('heading', { name: 'Cart' })).toBeVisible();
  });
});

Choose a step for report readability, not for sharing state between test declarations.

The Playwright Test API documents the step API and nested reporting behavior.

5. Run setup before another group with project dependencies

When an entire setup project must finish before dependent projects start, configure project dependencies. This is appropriate for actions such as seeding a database or creating an authenticated storage state once for a project. It is not a replacement for calling a test from another test.

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  projects: [
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
    },
    {
      name: 'chromium',
      use: { browserName: 'chromium' },
      dependencies: ['setup'],
    },
  ],
});
// auth.setup.ts
import { test as setup, expect } from '@playwright/test';

setup('authenticate', async ({ page }) => {
  await page.goto('https://example.com/login');
  await page.getByLabel('Email').fill('alice@example.com');
  await page.getByLabel('Password').fill('correct-password');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page.getByRole('heading', { name: 'Home' })).toBeVisible();
  await page.context().storageState({ path: 'playwright/.auth/user.json' });
});

Projects can represent browsers, devices, environments, or setup groups. Dependencies establish project-level ordering; the dependent tests remain separate tests. Read the official projects documentation.

6. The special case: one page shared by serial tests

Playwright’s retry guidance documents a technique that creates a page in beforeAll, closes it in afterAll, and runs a group serially. This can preserve state between tests, but it couples their order and failure behavior.

import { test, expect, type Page } from '@playwright/test';

test.describe.configure({ mode: 'serial' });

let page: Page;

test.beforeAll(async ({ browser }) => {
  const context = await browser.newContext();
  page = await context.newPage();
  await page.goto('https://example.com');
});

test.afterAll(async () => {
  await page.context().close();
});

test('creates a draft', async () => {
  await page.getByRole('button', { name: 'New draft' }).click();
  await expect(page.getByText('Draft created')).toBeVisible();
});

test('publishes the draft', async () => {
  await page.getByRole('button', { name: 'Publish' }).click();
  await expect(page.getByText('Published')).toBeVisible();
});

Use this only when preserving state is essential. If the first test fails, the serial group can be skipped or affected, and retries are less independent. Isolated tests with fixture setup are usually easier to rerun and diagnose. See Playwright’s retries documentation.

7. Common errors and fixes

Symptom Cause Fix
“A test cannot be nested” or a test declaration behaves unexpectedly A test() call was placed inside another test or helper invoked by it. Move reusable actions into a plain function or fixture. Use test.step() for a named sequence.
Tests pass alone but fail in a suite One test relies on another test’s data, cookies, or navigation state. Create state in each test, use a fixture, or use a setup project dependency.
Failures appear only with workers or sharding Execution order and process boundaries expose shared mutable state. Remove cross-test side effects; isolate accounts and data. Do not depend on a shared in-memory page.
Fixture teardown does not run as expected The fixture scope does not match the resource lifetime, or code after use() is missing. Put cleanup after await use(resource) and choose test or worker scope intentionally.
Serial tests become skipped after an earlier failure Serial mode couples the group. Split the flow into independent tests or accept the coupling as a documented special case.
Setup runs repeatedly and slows the suite Expensive work is test-scoped when it could be worker-scoped or a setup project. Measure the resource lifetime, then move safe immutable setup to worker scope or project setup.

8. Performance, reliability, and cost considerations

  • Performance: helpers add negligible runner overhead. Fixtures can avoid duplicated setup when their scope safely permits it. Worker-scoped resources reduce repetition but must be safe for tests sharing a worker.
  • Reliability: independent tests tolerate parallel execution, retries, and sharding. Cross-test state introduces order dependence and makes failures harder to reproduce.
  • Data isolation: use unique records or reset state per test. A shared login session is different from shared business data; keep mutable data isolated even when authentication is reused.
  • Reporting: helpers are usually invisible in the report, while test.step() exposes meaningful phases. Fixtures report setup and teardown as runner-managed work.
  • Cost: Playwright itself has no per-screenshot billing in these patterns. If browser setup is the expensive part of a separate screenshot workflow, an API can remove that infrastructure.

9. Or skip the browser setup

For screenshot capture, ScreenshotNeo provides a single GET request that returns a PNG, JPEG, WebP, or PDF. Cookie and consent banners, newsletter popups, and chat widgets are removed before capture. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing result. It also provides an MCP server with take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients.

See the ScreenshotNeo API documentation for parameters and response details.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

It supports full-page and element captures, device presets and custom viewports, retina scale, dark mode, PDF options, custom CSS and JavaScript, clicks, selector or network-idle waits, blocked resources, headers, cookies, user agents, authorization, timezone, geolocation, transparent backgrounds, resizing, TTL caching, signed links, asynchronous jobs with signed webhooks, bulk capture of up to 100 URLs per call, usage reporting, and an OpenAPI specification. Existing parameter names used by other screenshot APIs also work.

The Free plan includes 1,000 screenshots per month with no card. Paid plans start at $5 for 3,000 screenshots; every feature is on every plan. Create a free ScreenshotNeo account.

10. FAQ

Can I import a test from another file and call it?

You can import ordinary functions, fixtures, or page-object methods. Keep the imported unit separate from a declared test(); invoke the behavior, not another test declaration.

Should login be in beforeEach or a fixture?

Use a fixture when login is a named dependency needed by many tests or requires setup and teardown. A small beforeEach can be adequate for local, file-specific setup.

When are project dependencies better than fixtures?

Use project dependencies when a setup project must complete before an entire group of projects. Use fixtures when each test needs a resource with a managed lifecycle.

Does test.step() create a retryable unit?

No. The enclosing test is the retry unit. A step improves reporting inside that test.

Can serial tests share authentication?

They can share a page or context in a deliberately serial group, but isolated tests with storage state or a fixture usually provide safer retries and parallel execution.