Stabilizing Playwright Tests in CI: Dependency Projects and Web-First Assertions
Chained dependencies in Playwright prevent test execution in unstable environments, saving CI resources and reducing false positives.
First, authentication via API is performed with storageState persistence. Then, a health check verifies API availability. Only after this do the main UI tests run.
Advantages over globalSetup:
- Full traces with network requests, screenshots, and console logs.
- Fail-fast behavior: if setup fails, dependent projects are halted immediately.
- Isolation: individual stages can be tested using
npx playwright test --project=auth-setup.
Configuration in playwright.config.ts:
export default defineConfig({
projects: [
{
name: 'auth-setup',
testMatch: /.*\.auth\.setup\.ts/,
},
{
name: 'healthcheck',
testMatch: /.*\.health\.setup\.ts/,
dependencies: ['auth-setup'],
},
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
dependencies: ['healthcheck'],
},
],
});
API-based authentication cuts setup time dramatically: a POST request takes 50–100 ms versus 2–5 seconds for UI login.
Example auth-setup test:
test('setup', async ({ request }) => {
await request.post('/api/login', { data: { user, pass } });
await request.storageState({ path: '.auth/user.json' });
});
Dependency graphs eliminate race conditions during parallel test runs.
Choosing Locators Wisely
Locator selection directly impacts test resilience to UI changes. Separate them by purpose: use getByTestId for actions, and getByRole or getByLabel for assertions.
Best practices:
- Actions (click, input):
page.getByTestId('add-to-cart-button').click()— independent of text or styling. - Text/state validation:
expect(page.getByRole('heading')).toHaveText('My Account'). - Input fields:
page.getByLabel('Email').fill('[email protected]'). - Counters:
expect(locator).toHaveText('3').
CSS selectors should only be used as fallbacks. Training your team on proper locator usage pays off tenfold at scale.
Web-First Assertions vs Flakiness
Snapshot checks like await loc.isVisible() are prone to race conditions. Web-first assertions with polling deliver stability.
Comparison of Assertions:
| Check | Snapshot (no wait) | Web-First (polling) |
|-------|--------------------|---------------------|
| Visibility | await loc.isVisible() | expect(loc).toBeVisible() |
| Text | await loc.textContent() | expect(loc).toHaveText('Text') |
| Count | await loc.count() | expect(loc).toHaveCount(3) |
| Attribute | await loc.getAttribute('href') | expect(loc).toHaveAttribute('href', '/path') |
| Checkbox | await loc.isChecked() | expect(loc).toBeChecked() |
| State | await loc.isEnabled() | expect(loc).toBeEnabled() |
Polling retries every 100ms until timeout, ignoring temporary UI flickers.
Hydration and force: true
Hydration mismatch occurs in SSR/SSG apps when server-rendered markup exists but JS handlers aren’t attached yet.
Wait for hydration markers:
await page.waitForSelector('.hydrated', { state: 'attached' })await expect(page.locator('#global-loader')).toBeHidden()
force: true disables actionability checks (visible, stable, enabled, event reception), turning the test into a raw DOM script. Use sparingly and always with a comment—ideal for edge cases like file inputs.
Key Takeaways
- Dependency Projects build a dependency graph, preventing tests from running on unready environments.
- Web-first assertions with polling eliminate 80% of flakiness caused by race conditions.
- Separating locators by role increases resilience to UI refactoring.
- API authentication speeds up setup 20–50x compared to UI login.
- Traces over screenshots provide full context for failures in CI.
— Editorial Team
No comments yet.