I want to escape from the hell of manual testing. Dirty trial and error to lead E2E testing to “true automation” with Playwright
2am. Only the light from the monitor illuminates the cluttered desk.
What I have in front of me now is a front-end screen that has just finished implementing a new feature, and a hopeless amount of regression test items for it.
"Press this button, fill out this form, and check to see if there are any errors..."
Repeating this manually every day has reached its limit. Especially in SPA (Single Page Applications) that involve complex state transitions, the task of manually repeating clicks every time a specific path is passed has become a ``mandatory task'' that reduces the creativity of engineers.
In such despair, I discovered Playwright.
At first, I was confused about Cypress. However, I was convinced that Playwright was the best choice for the current development flow due to its ease of multi-browser compatibility, high execution speed, and above all, its thorough implementation of the concept of "Auto-waiting."
In this article, I am not simply talking about the superficial story of ``introducing a tool,'' but I am writing down in the most honest form the key points of implementation and sticking points that I discovered while stabilizing E2E testing (metaphorically) while actually bleeding in the field.
Why “Playwright”: Comparison with existing technology
First of all, I will summarize the conflict and conclusion as to why I chose Playwright over other tools. E2E testing has a long history, and each tool has its advantages and disadvantages.
| Features | Selenium | Cypress | Playwright |
|---|---|---|---|
| Execution Speed | Low (via WebDriver) | Medium to High (in-browser execution) | High (direct control, fast context) |
| Waiting | Requires explicit waiting | Auto-waiting (somewhat) | Strong auto-waiting (very robust) |
| Multi-browser | Very powerful | Limited (mainly Chromium-based) | Supports Webkit, Firefox, and Chromium by default |
| Mobile emulation | Possible but complicated | Simple | Advanced emulation included as standard |
| Learning cost | High (because it is general-purpose) | Low | Medium (API is clean) |
After all, we want to write tests, not fight testing tools. Playwright abstracts away the most difficult tasks for engineers: ``waiting for elements to appear'' and ``monitoring network status.'' This difference in ideology greatly influences stress during the operation phase.
Walls faced in the field: Fighting flaky tests
The first obstacle you will run into when introducing E2E testing is the flaky behavior of ``It passed yesterday, but it fails today.'' This occurs due to fluctuations in the CI environment or network, but the cause is often an "inappropriate standby strategy."
1. Using `sleep` is the biggest sin
What beginners often do is insert a fixed-time wait like `page.waitForTimeout(3000)` just because an element is not found. This is the worst solution and fundamentally destroys the reliability of the test.Playwright automatically retries until the element reaches a certain state (Visible, Enabled, etc.), but if there is a complex asynchronous process (e.g. updating the DOM after waiting for an API response), you should perform ``wait linked to an action''.
2. Philosophy of choosing a locator
You should avoid writing CSS selectors or XPath directly. This is because they depend too much on "convenience of implementation." Playwright's recommendation for using `getByRole` and `getByText` is not just about convenience.These are selectors based on "accessibility (A11y)" and match what the user is seeing. This makes the tests less likely to break even if the HTML structure changes slightly.
// Bad example: Depends on CSS classes or complex paths
await page.click('.btn-submit-01_x92'); // dies if the class name changes
// Good example: locator based on user behavior (role)
await page.getByRole('button', { name: 'Submit' }).click();
`
Practical Structuring: Introducing the Page Object Model (POM)
As the number of test codes increases, maintainability naturally becomes an issue. It is not realistic to modify dozens of tests just because the selector of a particular button has changed.
The Page Object Model (POM) is adopted here. Define page (or component) operations as classes and separate them from the test body. This makes it possible to separate the responsibilities of ``what to do'' and ``how to operate.''
The following is a specific implementation structure using the login flow as an example.
// models/LoginPage.ts
import { Page, Locator }/ from '@playwright/test';
export class LoginPage {
private readonly page: Page;
private readonly usernameInput: Locator;
private readonly passwordInput: Locator;
private readonly loginButton: Locator;
constructor(page: Page) {
this.page = page;
// By defining it in the constructor, you can type-safely describe the operation.
this.usernameInput = page.getByLabel('username');
this.passwordInput = page.getByLabel('password');
this.loginButton = page.getByRole('button', { name: 'Login' });
}
async navigate() {
await this.page.goto('https://example.com/login');
}
async login(username: string, password: string) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.loginButton.click();
}
}
`
This structure makes the test code much easier to read, as shown below.
// tests/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../models/LoginPage';
test('User can log in correctly', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.navigate();
await loginPage.login('test_user', 'password123');
// Verify the state after login
await expect(page).toHaveURL(/.*dashboard/);
await expect(page.getByText('Welcome')).toBeVisible();
});
`
By abstracting in this way, even if there is a slight modification to the UI, all tests can be handled by simply changing the definition of the `LoginPage` class. This is an essential defense measure in large-scale development.
Network control and mocking (Mocking)
Another big trap in E2E testing is the instability of the backend API. If your environment relies on external APIs or microservices that are still being built, your tests will fail.
Playwright has a powerful weapon called `page.route()`. This allows you to intercept specific API requests and return a pre-prepared response.
This mocking feature is a lifesaver in situations where you don't want to stop E2E testing of the front end until the back end is ready. It is particularly effective when intentionally generating and verifying error-related behavior (such as 500 errors and 403 Forbidden).
// Example of intercepting and mocking specific API requests
await page.route('**/api/v1/user-profile', async (route) => {
const json = { id: 1, name: 'Mock User' };
await route.fulfill({ json });
});
`
Integrating into CI and solving “invisible errors”
Tests that run in the local environment fail in a CI environment such as GitHub Actions. This is a common pain in E2E automation. There are many causes, but they can be summarized into three main points:
1. Headless mode differences:
In a headless environment, rendering timing and font handling may be slightly different. Although Playwright is designed to absorb this difference, its behavior may still change in special environments such as inside Docker containers.
2. Resource contention:
Since parallel execution is often performed on CI, simultaneous access to the same data will result in an error. When preparing test data, a design such as using a "unique ID" is required.
3. Timeouts and network delays:
In slower than local CI environments, default timeouts (typically 30 seconds, etc.) may not be met.
The most powerful weapon to deal with these is Playwright Trace Viewer.
When CI fails, configure it to output a "trace file" that includes all screenshots, videos, console logs, and network requests at that moment. This dramatically reduces the amount of time it takes to find out why a CI fails.
GitHub Actions configuration example (excerpt)
`jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run tests
run: npx playwright test
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: build/playwright-report/
`
Finally: Automation is not an “ending” process, but a “continuing” process
Don't be satisfied with just introducing E2E testing. As the product grows and features are added, the test code must also be maintained.
At first, you can use a script that just confirms that it works. However, as the size of the team grows, it becomes necessary to design (utilize POM and mocks) to transform it into a robust asset.
I hope that this memo, written in the silence of late at night, will encourage someone to break away from manual testing. The road to automation is difficult, but the sense of security once you get things going is priceless.