This episode covers running tests in isolated browser contexts, handling secrets and environment variables safely, protecting test data and credentials, and preventing state leakage between tests.

An insecure test suite can be the entry point for much bigger problems: credentials leaking into the code, user data piling up between tests, or production secrets embedded in artifacts. This episode 13 covers security and isolation — two foundations that make a suite safe and deterministic.
Isolation via BrowserContext lets every test run in its own world, while secret management practices ensure credentials never end up in the repository. You'll learn concrete patterns for storing, injecting, and protecting sensitive data in end-to-end tests.
Without isolation, two tests running in parallel can interfere with each other: they share cookies, localStorage, or sessions — causing random, hard-to-reproduce failures. Playwright solves this by giving every test a new context by default.
A context is the isolation unit: cookies, storage, and browser state never leak between contexts. This is what makes Playwright tests safe to run in parallel without worrying about overwriting each other.
For tests that need a login session, don't log in repeatedly in every test. Prepare the storage state once and load it in other tests:
import { test } from '@playwright/test';
test('prepare login state', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('secret123');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.context().storageState({ path: 'artifacts/state.json' });
});page.context().storageState({ path: 'artifacts/state.json' }) saves the cookies and localStorage to a file. Other tests then load it as the context base:
use: {
storageState: './artifacts/state.json',
},With storageState in the configuration, every test starts with a login session without re-running the login flow — faster and more stable.
Rule number one: secrets are never written into committed files. Playwright reads environment variables from the process that runs it, so all sensitive values live in the CI environment or a local .env file that's in .gitignore.
TEST_USER_EMAIL=user@example.com
TEST_USER_PASSWORD=secret123
TEST_ADMIN_EMAIL=admin@example.comThis file is read by Node.js when tests run. Make sure .env.local is in .gitignore — committing a secret is one of the most expensive mistakes in software engineering.
import { test, expect } from '@playwright/test';
const email = process.env.TEST_USER_EMAIL!;
const password = process.env.TEST_USER_PASSWORD!;
test('login with credentials from env', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL(/dashboard/);
});process.env.TEST_USER_EMAIL is read from the environment when the test runs. Values never appear as literals in the code, so it's safe to commit.
There's a subtle trap: text typed into form inputs will show up in traces and videos. If credentials are used in a test that captures artifacts, the secret can leak through the recording. The solution: use test accounts that are safe to expose, or make those secrets specific to the test environment.
test.use({ trace: 'off', video: 'off' });test.use({ trace: 'off', video: 'off' }) disables recording for tests that handle sensitive data, so credentials don't end up in artifacts.
Production credentials must never be used for testing — the risk is too high and it's a bad practice. Use dedicated test accounts whose data and permissions are controlled, created via seeding or fixtures provided by the backend team.
For secrets that genuinely need to be stored, use a platform secret manager (for example GitHub Secrets, GitLab CI variables, or Vault) and rotate them regularly. The fewer people who know a secret's value, the smaller its attack surface.
Use realistic fictional data: an email like user+test@example.com, card numbers provided for testing (like the 4242 pattern on payment sandboxes), and fake addresses. Avoid using real user data, whether from production or database exports.
Tests that create data — orders, accounts, entries — should clean it up when finished, or use data that's unique per test:
import { test, expect } from '@playwright/test';
test('register a new user', async ({ page }) => {
const uniqueEmail = `user-${Date.now()}@example.com`;
await page.goto('/register');
await page.getByLabel('Email').fill(uniqueEmail);
await page.getByRole('button', { name: 'Register' }).click();
await expect(page.getByText('Account created')).toBeVisible();
});${Date.now()}@ produces a unique value per execution, preventing data collisions between tests that run repeatedly. Unique values are the simplest way to prevent state leakage with identical data.
Ideally, every test environment has its own database that can be reset at any time. If that's not possible, make sure tests use a clear prefix or namespace for the data they create, so test traces are easy to distinguish and clean up.
TEST_ENV=staging npx playwright testThe command TEST_ENV=staging npx playwright test runs the suite with an environment variable that determines the target — staging uses the staging database, not production.
Episode 13 made your suite safe and deterministic: every test runs in an isolated BrowserContext so there's no state leakage, secrets are stored in the environment and never enter the repository, production credentials are never used, and storage state speeds up tests that need login without sacrificing isolation.
Key takeaways:
In the next episode we'll discuss CI/CD integration — integrating Playwright into GitHub Actions, GitLab CI, and Jenkins, the difference between headless and headed runs in CI, managing test artifacts like traces and screenshots, as well as parallel execution and matrix runs.