Test Environments#

When building a robust test suite, you often need to run tests across different environments like development, testing, staging, and production. This page explains how to create and use test fixtures to manage environment-specific configurations in Playwright.

Understanding Test Environments#

A typical application moves through several environments during its lifecycle:

  • Development (Dev): Where new features are actively built and tested

  • Testing/QA: Where QA teams verify functionality

  • Staging: A production-like environment for final validation

  • Production (Prod): The live environment used by end users

Each environment might have different URLs, authentication methods, and available features. Your tests need to adapt to these differences.

Creating Environment Fixtures#

Fixtures in Playwright let you set up configurations that can be reused across tests. Let’s create fixtures for different environments:

// fixtures/environments.ts
import { test as base } from "@playwright/test";

// Define environment-specific data types
export type EnvironmentConfig = {
  baseUrl: string;
  apiUrl: string;
  credentials: {
    username: string;
    password: string;
  };
  featureFlags: Record<string, boolean>;
};

// Define our extended test fixtures
type TestFixtures = {
  environmentConfig: EnvironmentConfig;
  authenticatedPage: Page;
};

// Create a base environment configuration
const defaultConfig: EnvironmentConfig = {
  baseUrl: "http://localhost:3000",
  apiUrl: "http://localhost:3001/api",
  credentials: {
    username: "test-user",
    password: "test-password",
  },
  featureFlags: {
    newFeature: false,
  },
};

// Create environment-specific configurations
const environments: Record<string, EnvironmentConfig> = {
  dev: {
    ...defaultConfig,
    baseUrl: "https://dev.myapp.com",
    apiUrl: "https://dev-api.myapp.com",
  },
  test: {
    ...defaultConfig,
    baseUrl: "https://test.myapp.com",
    apiUrl: "https://test-api.myapp.com",
    featureFlags: {
      ...defaultConfig.featureFlags,
      newFeature: true,
    },
  },
  staging: {
    ...defaultConfig,
    baseUrl: "https://staging.myapp.com",
    apiUrl: "https://staging-api.myapp.com",
    credentials: {
      username: "staging-user",
      password: process.env.STAGING_PASSWORD || "default-password",
    },
  },
  prod: {
    ...defaultConfig,
    baseUrl: "https://myapp.com",
    apiUrl: "https://api.myapp.com",
    credentials: {
      username: "prod-user",
      password: process.env.PROD_PASSWORD || "default-password",
    },
  },
};

// Create the test fixture with environment configuration
export const test = base.extend<TestFixtures>({
  // Provide the environment configuration to each test
  environmentConfig: async ({}, use) => {
    // Get the environment from environment variable or default to 'dev'
    const envName = process.env.TEST_ENV || "dev";
    const config = environments[envName] || environments.dev;

    console.log(`Running tests in ${envName} environment`);
    await use(config);
  },

  // Pre-authenticated page fixture
  authenticatedPage: async ({ page, environmentConfig }, use) => {
    // Navigate to the login page
    await page.goto(`${environmentConfig.baseUrl}/login`);

    // Authenticate using environment credentials
    await page
      .getByLabel("Username")
      .fill(environmentConfig.credentials.username);
    await page
      .getByLabel("Password")
      .fill(environmentConfig.credentials.password);
    await page.getByRole("button", { name: "Login" }).click();

    // Wait for authentication to complete
    await page.waitForURL(`${environmentConfig.baseUrl}/dashboard`);

    // Provide the authenticated page to the test
    await use(page);

    // Optional: Clean up after the test (logout, clear cookies, etc.)
    await page.goto(`${environmentConfig.baseUrl}/logout`);
  },
});

export { expect } from "@playwright/test";

Configuration by Environment#

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

export default defineConfig({
  use: {
    baseURL: process.env.BASE_URL || "http://localhost:3000",
  },
});

Environment Variables#

# .env.local
BASE_URL=http://localhost:3000
API_URL=http://localhost:8000
TEST_USER=user@example.com

Running Tests in Different Environments#

# Development
npx playwright test

# Staging
BASE_URL=https://staging.app.com npx playwright test

# Production
BASE_URL=https://app.com npx playwright test

Environment Configuration File#

// config/environment.ts
export const env = {
  dev: {
    baseURL: "http://localhost:3000",
    apiURL: "http://localhost:8000",
  },
  staging: {
    baseURL: "https://staging.app.com",
    apiURL: "https://api.staging.app.com",
  },
  prod: {
    baseURL: "https://app.com",
    apiURL: "https://api.app.com",
  },
};

export const getEnv = () => {
  const environment = process.env.TEST_ENV || "dev";
  return env[environment];
};

Tip

Use dotenv to load environment variables from .env files for local development.

Using Environment Fixtures in Tests#

With our fixtures defined, we can now write tests that automatically adapt to different environments:

// tests/example.spec.ts
import { test, expect } from "../fixtures/environments";

test("user dashboard loads correctly", async ({ page, environmentConfig }) => {
  await page.goto(`${environmentConfig.baseUrl}/dashboard`);

  // Check dashboard elements
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();

  // Test environment-specific behavior
  if (environmentConfig.featureFlags.newFeature) {
    await expect(page.getByText("New Feature")).toBeVisible();
  }
});

test("API returns correct data", async ({ request, environmentConfig }) => {
  const response = await request.get(`${environmentConfig.apiUrl}/users/me`);
  expect(response.status()).toBe(200);

  const data = await response.json();
  expect(data.username).toBe(environmentConfig.credentials.username);
});

// Using authenticated page fixture
test("authenticated user can access protected content", async ({
  authenticatedPage,
  environmentConfig,
}) => {
  // Page is already authenticated from the fixture
  await authenticatedPage.goto(`${environmentConfig.baseUrl}/protected`);

  // Verify access to protected content
  await expect(authenticatedPage.getByText("Protected Content")).toBeVisible();
});

Environment-Specific Configuration in playwright.config.ts#

You can also configure your playwright.config.ts file to handle different environments:

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

// Get environment from command line
const environment = process.env.TEST_ENV || "dev";

// Environment-specific configurations
const environmentConfig = {
  dev: {
    baseURL: "https://dev.myapp.com",
    testDir: "./tests",
    retries: 0,
  },
  test: {
    baseURL: "https://test.myapp.com",
    testDir: "./tests",
    retries: 1,
  },
  staging: {
    baseURL: "https://staging.myapp.com",
    testDir: "./tests",
    retries: 1,
  },
  prod: {
    baseURL: "https://myapp.com",
    testDir: "./tests/production", // Separate production tests
    retries: 2,
    timeout: 60000, // Longer timeouts for production
  },
};

const config = environmentConfig[environment] || environmentConfig.dev;

export default defineConfig({
  ...config,
  use: {
    baseURL: config.baseURL,
    screenshot: "only-on-failure",
    trace: "on-first-retry",
  },
  reporter: [
    ["html"],
    ["junit", { outputFile: `test-results/junit-${environment}.xml` }],
  ],
});

Setting Up Different Credential Sources#

For secure handling of credentials across environments:

// fixtures/credentials.ts
import * as fs from "fs";

export async function getCredentials(
  environment: string
): Promise<{ username: string; password: string }> {
  switch (environment) {
    case "dev":
      // Local development credentials
      return {
        username: "dev-user",
        password: "dev-password",
      };

    case "test":
      // Fetch from environment variables
      return {
        username: process.env.TEST_USERNAME || "test-user",
        password: process.env.TEST_PASSWORD || "test-password",
      };

    case "staging":
    case "prod":
      // Fetch from a secure storage file (e.g., encrypted .env file)
      try {
        const envFile = `.env.${environment}`;
        if (fs.existsSync(envFile)) {
          const fileContent = fs.readFileSync(envFile, "utf8");
          const envVars = parseEnvFile(fileContent);
          return {
            username: envVars.USERNAME,
            password: envVars.PASSWORD,
          };
        }
      } catch (error) {
        console.error(`Error loading credentials for ${environment}:`, error);
      }

      // Fall back to environment variables
      return {
        username:
          process.env[`${environment.toUpperCase()}_USERNAME`] ||
          `${environment}-user`,
        password:
          process.env[`${environment.toUpperCase()}_PASSWORD`] ||
          "default-password",
      };
  }
}

function parseEnvFile(content: string): Record<string, string> {
  const result: Record<string, string> = {};
  const lines = content.split("\n");

  for (const line of lines) {
    const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/);
    if (match) {
      const key = match[1];
      let value = match[2] || "";

      // Remove surrounding quotes if they exist
      value = value.replace(/^['"](.*)['"]$/, "$1");
      result[key] = value;
    }
  }

  return result;
}

Best Practices for Environment Testing#

  1. Keep Environment Logic Separate: Use fixtures and configuration files to encapsulate environment-specific logic.

  2. Use Environment Variables: Store sensitive information like passwords in environment variables rather than in code.

  3. Conditional Tests: Some tests might only be relevant in certain environments. Use conditional testing:

    test("feature flag test", async ({ environmentConfig }) => {
      test.skip(
        !environmentConfig.featureFlags.newFeature,
        "Test only runs when newFeature flag is enabled"
      );
    
      // Test code here
    });
    
  4. Environment-Specific Data: Create data helpers that provide appropriate test data for each environment.

  5. Smoke Tests for Production: Consider having a separate set of non-destructive tests for production:

    test("production smoke test", async ({ page, environmentConfig }) => {
      test.skip(
        environmentConfig.baseUrl !== "https://myapp.com",
        "Only run in production"
      );
    
      // Non-destructive test that doesn't modify data
      await page.goto("/");
      await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();
    });
    
  6. Retry Strategy: Configure more retries for less stable environments like production:

    test.describe("production tests", () => {
      // Only increase retries for production environment
      if (process.env.TEST_ENV === "prod") {
        test.use({ retries: 3 });
      }
    
      // Test cases...
    });
    

By leveraging Playwright’s fixtures and environment-specific configurations, you can build a flexible testing framework that works across your entire deployment pipeline, from development through production.