Mastering Playwright Config: My DSAlgo Portal Test Suite
When I first opened playwright.config.js for my DSAlgo Portal project, I thought it was just another settings file. Copy some boilerplate, adjust a few paths, done.
I was so wrong it hurts to think about it.
The config file turned out to be the entire blueprint of my test execution strategy. It’s where I solved parallelization, eliminated repeated logins, and orchestrated 130+ tests to run in perfect sequence. Once I understood it, my test suite went from a chaotic mess to a well-oiled machine.
Let me show you what I built.
The Three Layers That Changed Everything
I learned to think about my config in three layers, like a layer cake (but way more useful).
Layer 1: The Execution Foundation
This is where I set the ground rules:
export default defineConfig({ workers: process.env.CI ? 2 : 4, retries: process.env.CI ? 0 : 0,
use:{
baseURL: process.env.BASE_URL || 'https://dsportalapp.herokuapp.com',
screenshot: 'only-on-failure',
video: 'retain-on-failure', }, });Workers = parallelization. Locally, I run 4 workers because my laptop can handle it. In CI (GitHub Actions), I drop to 2 because cloud environments are flakier. This one line gave me fast local runs and stable CI.
BaseURL changed my life. Instead of writing the full URL 100+ times:
await page.goto('https://dsportalapp.herokuapp.com/login'); // ■ EverywhereI write:
await page.goto('/login'); // ■ Just the pathWant to test staging? Just change BASE_URL in .env. Same code, different environment. Screenshots and videos only on failure = saving gigabytes of disk space and making failure debugging instant. I don’t need 100 videos of passing tests.
Layer 2: Global Setup and Teardown
This is where I hit gold:
export default defineConfig({
globalSetup: './global-setup.js',
globalTeardown: './global-teardown.js', });My global-setup.js logs in ONCE before any test runs:
await page.goto(process.env.BASE_URL + '/login'); await page.getByLabel('Username:').fill(process.env.TEST_USERNAME);await page.getByLabel('Password:').fill(process.env.TEST_PASSWORD); await page.getByRole('button', { name: 'Login' }).click(); await context.storageState({ path:'.auth/user.json' });It saves the logged-in session to .auth/user.json.
Then, every authenticated test reuses it:
{ name: 'phase5-array',
use: { storageState: '.auth/user.json', // ← Boom, already logged in }, }No more logging in 100 times.
Time saved: 5 seconds × 100 tests = 8+ minutes. Every. Single. Run.
My global-teardown.js cleans up afterward: logs out, deletes the auth file, clears cookies. Leaves no traces.
Layer 3: Projects (Where the Magic Happens)
This is the layer that transformed everything. Projects let me split my 130+ tests into logical execution phases with dependencies:
projects: [ { name: 'phase1-noauth',
testMatch: /.*01_homepage\.feature/,
grep:/@noauth/, },
{ name: 'phase2-registration',
testMatch: /.*02_registration\.feature/,
dependencies: ['phase1-noauth'], // ← Waits for phase1 to finish }, ]That dependencies line creates a waterfall. Phase2 won’t start until phase1 completes.
Here’s my entire execution flow:
phase1-noauth → phase2-registration → phase3-signin → phase4-authenticated-homepage ↓[phase5: 7 modules run in PARALLEL] ↓ security-testsPhase5 is where it gets powerful: 7 data structure modules (Array, Stack, Queue, LinkedList, Tree,Graph, DataStructures) all start together after phase4 and run in parallel with my 4 workers.
Before: Sequential testing took 10 minutes.
After: Parallel phase5 with dependencies = under 5 minutes locally.
Execution time: cut in half.
The Pattern I Wish I’d Known Day One
Use grep and grepInvert to slice one feature file into multiple projects:
// Phase 3: Run signin functionality tests
{ name: 'phase3-signin',
testMatch: /.*03_signin\.feature/,
grep: /@signin/,
grepInvert: /@security/, // ← Skip @security tagged scenarios
}
// Security tests: Run ONLY @security scenarios
{ name:'security-tests',
testMatch: /.*03_signin\.feature/,
grep: /@security/, // ← Only @security tagged scenarios }Same feature file. Two different execution contexts. Different timing. Different purposes.
I don’t need separate files for signin tests vs security tests. I just tag scenarios differently and let grep handle the rest.
Mind = blown.
My Current Mistakes (Yes, I’m Still Learning)
Mistake 1: Commented Out Dependency
{ name: 'phase3-signin', //dependencies: ['phase2-registration'], // ← Commented out! }I had to comment this out because phase2 has failing tests (invalid input validation scenarios). With the dependency enabled, when phase2 fails, Playwright skips phase3, phase4, and ALL of phase5.
My temporary workaround: Run phase3 independently. It works because globalSetup creates the login session anyway.
What I should do: Add ignoreTestFailures: true to phase2 so known failures don’t block the entire pipeline. I just haven’t applied it to this version yet.
Mistake 2: Not Using Environment Variables Initially
I learned this the hard way. My first config had:
// ■ Bad - hardcoded use: { baseURL: 'https://dsportalapp.herokuapp.com', }Switching to staging? Change the code. Testing locally? Change the code again. Merge conflict hell.
Now:
// ■ Good - flexible use: { baseURL: process.env.BASE_URL ||
'https://dsportalapp.herokuapp.com', }One .env file change. No code changes. Works everywhere.
The Config Pattern That Saved Me
Here’s my complete structure:
export default defineConfig({
// Execution controls
workers: process.env.CI ? 2 : 4,
//Lifecycle hooks
globalSetup: ‘./global-setup.js’,
globalTeardown: ‘./global-teardown.js’,
// Base settings
use: { baseURL: process.env.BASE_URL,
screenshot: ‘only-on-failure’,
video: ‘retain-on-failure’, },
// Projects with dependencies
projects: [ { name: ‘phase1-noauth’ },
{ name: ‘phase2-registration’,
dependencies: [‘phase1-noauth’], },
{ name: ‘phase3-signin’,
// dependencies: [‘phase2-registration’], ← fixing this },
{ name: ‘phase4-authenticated-homepage’,
dependencies: [‘phase3-signin’],
use: { storageState: ‘.auth/user.json’ }, }, // … 7phase5 modules in parallel { name: ‘security-tests’, // ← needs dependencies on all
phase5 }, ], });This structure:
• Runs setup once
• Tests unauthenticated features first
• Tests authenticated features with saved session
• Parallelizes independent modules
• Different behavior in CI vs local
The Numbers That Matter
Before understanding the config:
• Tests ran in random order
• Login repeated 100+ times
• Total execution: ~10 minutes
• CI failures: frequent and confusing
After architecting with projects:
• Tests run in logical sequence
• Login happens once globally
• Total execution: ~5 minutes locally
• CI failures: much clearer (when config is complete)
Execution time: 60% faster.
What I’m Fixing Next
In my other project versions, I’ve already applied:
1. ignoreTestFailures: true on phase2, phase3, phase4, and all phase5 modules
2. Dependencies on security-tests to make it truly run last
3. Changed CI workers from 2 back to 1 for even more stabilityThis version (V1) still has the issues because I’m learning incrementally. But that’s the point — your config evolves with your understanding.
Key Takeaways
1. Use globalSetup for expensive one-time operations (saves 8+ minutes per run)
2. Use projects with dependencies to control execution order (no more random chaos)
3. Use storageState to skip repeated logins (massive time saver)
4. Use environment variables so the same code works in dev, staging, and production
5. Different worker counts for CI vs local keeps you sane
The config file isn’t just settings. It’s your test suite’s architecture. Master it, and everything else falls
into place.
*Testing the DSAlgo Portal taught me: spend time on your config file. It’s the foundation. Get it right,
and your tests become faster, more reliable, and infinitely more maintainable.*.


