“Essential” automation of E2E testing with Playwright: From on-site struggles to integration into CI

As a professional editor, I carefully reviewed the draft you submitted.

The original text is very logical and has excellent content with a smooth flow from pain points to solutions. In order to make this even more readable as a "technical blog," we have refined it by focusing on the following points.

1. Terminology Optimization: Keep industry standard terminology like "Flaky Test" and "Hydration" relevant and add context.
2. Code integrity: Corrected slight discrepancies in variable names in the draft (such as `usernameInput` and `username_input`) to improve practicality.
3. Improved readability: Important points are emphasized in bold and in lists, and the structure is structured so that the main points can be understood even when skimming.
4. Additional technical depth: Not only ``why not'' but also ``what should I do?'' Alternatives are more specifically described.

The following is the revised and proofread [finished version].


“Essential” automation of E2E testing with Playwright: From on-site struggles to integration into CI

"Isn't it fine as long as we can run the E2E test?"

If you think so, please stop for a moment. In front-end development sites, the more a project grows, the more important E2E (End-to-End) testing becomes. However, at the same time, the number of situations in which people despair over the high maintenance costs is also increasing.

Especially in React and Next.js applications that have many dynamic elements, running a simple script is not enough. Tests fail every time the selector changes slightly (Flaky Test), rendering fails at a certain time because it cannot be done in time, or "a mysterious bug that works locally but only reproduces in the CI environment"... Being swayed by these things will significantly reduce your productivity as an engineer.

Playwright, which we will be talking about this time, is a tool that provides a very powerful weapon against these challenges. I'll take a deep dive into why I switched from Cypress and Selenium to Playwright, and what to keep in mind in order to build "robust" tests in practice, using real-world experience.


1. Why Playwright now? (Competitive comparison and reasons for selection)

First of all, there are several tools for E2E testing. However, there is a clear reason why Playwright is rapidly increasing its share as a trend in recent years.

FeaturesSeleniumCypressPlaywright
Browser SupportAlmost all browsers (via WebDriver)Chrome, Firefox, Edge (no WebDriver)Chromium, WebKit, Firefox (native implementation)
Auto-waitingBasic None (need to manually write standby processing)Yes (needs to be modified for some operations)Powerful auto-waiting function included as standard
Execution speedLow to medium speedMedium speedHigh speed (parallel execution optimization is progressing)
Debugging supportPoor (depends on logs and videos)Good (Time Travel function, etc.)Very powerful (Trace Viewer, Codegen, etc.)

The biggest reasons why I chose Playwright were its ``high level of abstraction for browser operations'' and ``overwhelming ease of debugging.''

Of particular note is Trace Viewer. When a test fails in CI, you can check in detail what step it failed in, what was the state of the DOM at that moment, and how network requests were being flown, as if you were traveling through time. This feature dramatically reduces investigation time to identify the cause.

Also, it is very pleasant to see that Play1wright smartly solves the stress of ``restricting execution within a single browser'', which sometimes occurs with Cypress, and ``complicated synchronization processing (Wait)'', which is difficult with Selenium, as a standard feature.

2. Practical Design: Thorough Use of Page Object Model (POM)

A common trap for people who are just starting to write E2E tests is writing selectors directly in the test code.
For example, just because the ID of the login button was changed, 10 test cases had to be modified. This is the first step towards the "collapse of maintainability." The best way to prevent this is Page Object Model (POM).

When implementing POM in Playwright, you can dramatically improve the readability of test code by not only creating classes but also appropriately abstracting methods.

Implementation example: Login function page object

// models/LoginPage.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  private readonly usernameInput: Locator;
  private readonly passwordInput: Locator;
  private readonly loginButton: Locator;

  constructor(page: Page) {
    this.page = page;
    // By defining a selector here, you can consolidate all corrections in one place when the UI changes
    this.usernameInput = page.locator('#login-username');
    this.passwordInput = page.locator('#login-password');
    this.loginButton = page.locator('button[type="submit"]');
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(username: string, password: string) {
    // By grouping actions as methods, test code can focus on "what you want to do"
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
  }

  async getErrorMessage(): Promise<string | null> {
    // Can also include processing to wait for the required state before assertion
    return (await this.page.locator('.error-message').textContent()) || null;
  }
}
`

By adopting this structure, the test body becomes very clean and easy to convey the intent, as shown below.

// tests/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../models/LoginPage';

test.describe('E2E test of authentication function', () => {
  test('Successful login with correct credentials', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('test_user', 'password123');
    
    // Check the state after page transition. Playwright's auto-wait eliminates the need for explicit Wait.
    await expect(page).toHaveURL(/.*dashboard/);
  });

  test('Incorrect password causes error', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('test_user', 'wrong_pass');
    
    const error = await loginPage.getErrorMessage();
expect(error).toBe('Invalid credentials');
  });
});
`

By using this structure, even if the front-end component structure or CSS class name changes, the impact on all tests can be absorbed by simply modifying the `LoginPage` class definition.

3. Technical deep dive for “stable” testing

The biggest causes of flaky E2E tests are ``network delay'' and ``rendering asynchrony''.

Know the limits of auto-waiting

Playwright has a very nice auto-weight feature, but it's not a magic trick. For example, even if a button is displayed, redrawing (hydration) using JavaScript may not be completed behind the scenes, or the button may proceed to the next step without waiting for an API response.

In such cases, you should definitely avoid using a ``fixed time wait'' like `page.waitForTimeout(3000)`. ** This only unnecessarily extends the test execution time, and far from being a fundamental solution, it only serves to hide race conditions.

Instead, you should take the following approach:

1. Using Locator: If you use `page.locator()`, Playwright will automatically wait until the element is displayed and actionable before performing an action.
2. Network Wait: If you need to wait for an API response, incorporate `page.waitForResponse()` or logic to wait until a specific state. If you have dynamic content loading, monitoring network conditions is the most robust method.
3. Using Assertion: Assertions such as `expect(locator).toBeVisible()` retry until the condition is met. Make active use of Playwright assertions and incorporate a mechanism to wait for state changes.

Philosophy of selecting selectors

You should avoid writing "CSS selectors" or "XPath" directly. Especially in environments such as React and Next.js where class names are obfuscated during the build process, a class name like `button.btn-primary_x1y2z3` will quickly break.

The correct answer is to use User-facing locators (based on user-visible elements) as recommended by Playwright.

  • `page.getByRole('button', { name: 'Login' })`

  • `page.getByText('Confirm order')`

  • `page.getByPlaceholder('Enter your email address')`


By using these, you can write robust test code that is unaffected by small changes in the design (such as changing colors or changing CSS classes).

4. Tips for integrating and operating in a CI environment

Running E2E tests locally is different from running them in a CI environment such as GitHub Actions. Here, many engineers face the problem of ``For some reason, CI alone fails.''

Headless mode trap

By default, Playwright runs in headless mode. However, in certain browsers and OS environments, rendering behavior specific to headless mode may occur. Especially when running on CI, be aware of the following settings.
  • Running in a Docker environment: When running Playwright with GitHub Actions etc., it is best to use the official `mcr.microsoft.com/playwright` image. This includes all necessary browser binaries and dependent libraries.
  • Use of Artifacts: Make sure to save the report (HTML report) when CI fails and the trace file mentioned above as an artifact. This allows you to track issues that occur on CI in detail later.

GitHub Actions implementation example

`

# .github/workflows/playwright.yml
name: Playwright Tests
on: [push, pull_request]

jobs:
  test:
    timeout-minutes: 60
runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - uses: actions/setup-node@v3
      with:
        node-version: 18
        cache: 'npm'
    - name: Install dependencies
      run: npm ci
    - name: Install Playwright Browsers
      run: npx playwright install --with-deps
    - name: Build Project (e.g., next build)
      run: npm run build
    - name: Run Playwright tests
      uses: msyou/action-playwright@v1
      with:
        # Set to save reports and traces here
        playwright_report: true
    - uses: actions/upload-artifact@v3
      if: always()
      with:
        name: playwright-report
        path: build/playwright-report/
`

Parallel execution and resource optimization

Playwright runs tests in parallel by default. This is very powerful for reducing time in CI, but it eats up server resources (memory) if there are too many browsers running at the same time. Especially in a CI environment with limited resources, appropriately limiting the number of `workers` can prevent unstable errors (such as Out of Memory) and ensure stability.

5. Summary: E2E testing is an investment for “trust”

Implement Playwright, structure with POM, and employ appropriate selectors and wait strategies. This entire process may seem tedious at first. However, the peace of mind that CI passes, and the psychological safety that comes from preventing regressions from introducing bugs, is of immeasurable value to the development team.

E2E testing is more than just an automation script. It is an infrastructure that builds trust that ``this function is working correctly.'' Playwright will be a very powerful weapon for that purpose.

We recommend starting with a small use case (e.g. just logging in or filling out a specific form) and gradually expanding your scope. As a front-line engineer, it's a smart strategy to avoid trying to completely automate everything from the beginning, and instead protect the areas that will hurt you the most.


Related Resources

  • Playwright official documentation (it is very comprehensive, so we recommend reading this first)
  • [Detailed explanation on how to use Playwright Trace Viewer]

🤖 Developer blog/public content

The developer's personal blog is also available, so please take a look. muttyan.com blog

Amazon great free trial/benefits summary