Definition
- Basic login test – Navigates to the login page, fills username and password with stable locators, clicks submit, and asserts a reliable post-login signal such as the dashboard URL and a known heading.
- Environment base URL – Set in Playwright config from an environment variable so the same test runs against dev, QA, staging, or production by changing one setting.
- Externalized secrets – Credentials read from environment variables injected by the CI secret store, never committed to source control.
- Reused storage state – Log in once in a setup project, save browser cookies and local storage, and configure other tests to start already authenticated.
- Auto-waiting – Playwright's web-first assertions like
expect(locator).toBeVisible()automatically wait and retry, replacing hard sleeps that are both slow and flaky. - Negative assertions – Invalid credentials must show the correct error, keep the user on the login page, and not reveal any authenticated content.
Imagine a robot that logs into the National Bank of India app the same way Rajesh does: it opens the app, goes to the login screen, types the username and password, clicks sign in, and then checks that his dashboard appeared. To make this robot reliable, you do not glue the website address into it, you tell it which environment to use; you do not write Rajesh's real password in the script, you keep it in a safe place and hand it in at run time; you let the robot log in once and remember the signed-in state so the other hundred tests skip the login screen; you let the robot wait smartly for the page to be ready instead of blindly counting to five seconds; and you also teach it to try a wrong password and confirm the app correctly says "invalid credentials" and refuses entry.
Understanding the Concepts
What does the basic Playwright login test look like?
The basic test drives a real browser: it navigates to the login page, locates the username and password fields with stable locators, fills them, clicks the submit button, and asserts the successful outcome.
// tests/login/login.spec.ts
import { test, expect } from '@playwright/test';
test('Successful login redirects to dashboard', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Username').fill(process.env.TEST_USER!);
await page.getByLabel('Password').fill(process.env.TEST_PASS!);
await page.getByRole('button', { name: 'Sign In' }).click();
// Assert reliable post-login signals
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
await expect(page.getByText('Account Balance')).toBeVisible();
});
How do you configure environment-specific base URLs?
Instead of hard-coding the address, you set a baseURL in the Playwright configuration, often read from an environment variable. The same test runs against any environment by changing one setting.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'firefox', use: { browserName: 'firefox' } },
{ name: 'webkit', use: { browserName: 'webkit' } },
],
});
# Run against staging
BASE_URL=https://staging.nationalbank.in npx playwright test
# Run against production
BASE_URL=https://nationalbank.in npx playwright test
How do you externalize credentials and secrets?
You never commit usernames, passwords, or tokens into the test code or repository. Instead you read them from environment variables injected by the CI secret store.
# .github/workflows/e2e.yml
jobs:
e2e:
runs-on: ubuntu-latest
env:
BASE_URL: ${{ vars.STAGING_URL }}
TEST_USER: ${{ secrets.TEST_USER }}
TEST_PASS: ${{ secrets.TEST_PASS }}
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test
// Never do this:
const username = 'rajesh@nationalbank.in'; // WRONG: hard-coded
const password = 'SecretPass123!'; // WRONG: in source control
// Always do this:
const username = process.env.TEST_USER!; // From CI secrets
const password = process.env.TEST_PASS!; // From CI secrets
How do you reuse authentication state with storage state?
Logging in through the UI is slow and flaky, so you perform the login once in a setup project, save the browser's cookies and local storage, and configure the rest of the tests to start already authenticated.
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = '.auth/user.json';
setup('Authenticate once', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Username').fill(process.env.TEST_USER!);
await page.getByLabel('Password').fill(process.env.TEST_PASS!);
await page.getByRole('button', { name: 'Sign In' }).click();
await expect(page).toHaveURL(/\/dashboard/);
// Save signed-in state
await page.context().storageState({ path: authFile });
});
// playwright.config.ts - reuse the auth state
export default defineConfig({
projects: [
// Setup: runs first, logs in, saves state
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
// All other tests start already authenticated
{
name: 'chromium',
dependencies: ['setup'],
use: {
storageState: '.auth/user.json',
},
},
],
});
// tests/transfer.spec.ts - starts already logged in
test('Transfer between accounts', async ({ page }) => {
// No login needed - storageState provides the session
await page.goto('/transfer');
await page.getByLabel('To Account').fill('ACC-002');
await page.getByLabel('Amount').fill('5000');
await page.getByRole('button', { name: 'Transfer' }).click();
await expect(page.getByText('Transfer Successful')).toBeVisible();
});
Why should you use auto-waiting instead of hard sleeps?
Playwright's actions and web-first assertions automatically wait and retry until the condition is met or a timeout elapses. Hard sleeps are both slow and flaky.
// WRONG: hard sleep - slow and flaky
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForTimeout(5000); // Always waits 5 seconds
const heading = await page.textContent('h1');
expect(heading).toBe('Welcome');
// CORRECT: auto-waiting - fast and reliable
await page.getByRole('button', { name: 'Sign In' }).click();
await expect(
page.getByRole('heading', { name: 'Welcome' })
).toBeVisible(); // Waits only until visible, retries automatically
Hard sleep vs auto-waiting:
Hard sleep: Click -> Wait 5000ms -> Check (may already be ready)
Auto-waiting: Click -> Check (retry every 100ms until ready or timeout)
Auto-waiting is faster (stops as soon as ready)
and more reliable (retries if not ready yet).
What negative login assertions should you include?
A robust login spec asserts not just that valid credentials succeed but that invalid credentials fail correctly.
// tests/login/negative.spec.ts
test('Invalid credentials show error and stay on login', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Username').fill('rajesh@nationalbank.in');
await page.getByLabel('Password').fill('wrong-password');
await page.getByRole('button', { name: 'Sign In' }).click();
// Error message appears
await expect(page.getByText('Invalid credentials')).toBeVisible();
// User stays on login page
await expect(page).toHaveURL(/\/login/);
// No authenticated content is reachable
await expect(
page.getByRole('heading', { name: 'Welcome' })
).not.toBeVisible();
await expect(page.getByText('Account Balance')).not.toBeVisible();
});
test('Empty credentials show validation message', async ({ page }) => {
await page.goto('/login');
await page.getByRole('button', { name: 'Sign In' }).click();
await expect(page.getByText('Username is required')).toBeVisible();
await expect(page.getByText('Password is required')).toBeVisible();
});
What locator strategies make tests stable?
Playwright's recommended locators are role-based and label-based because they are resilient to DOM structure changes and reflect how users interact with the page.
// FRAGILE: CSS selectors tied to implementation
await page.locator('#login-form > div:nth-child(1) > input').fill(user);
await page.locator('.btn-primary.login-submit').click();
// STABLE: role-based and label-based locators
await page.getByLabel('Username').fill(user);
await page.getByLabel('Password').fill(pass);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.getByRole('heading', { name: 'Welcome' });
| Locator Strategy | Stability | Example |
|---|---|---|
| getByRole | Best | getByRole('button', { name: 'Sign In' }) |
| getByLabel | Best | getByLabel('Username') |
| getByText | Good | getByText('Invalid credentials') |
| getByTestId | Good | getByTestId('login-submit') |
| CSS selector | Fragile | locator('.btn-primary') |
| XPath | Fragile | locator('//div[@class="form"]/input') |
What are common interview follow-up questions?
- "Why not log in through the API instead of the UI for the setup?" API login is even faster and more reliable. You can use
request.post('/api/login')to get the auth token and inject it as a cookie, skipping the browser entirely for setup. - "What happens if the storage state expires?" The setup project runs fresh on each pipeline execution, so the state is always current. For long-running local sessions, set a shorter expiry or re-run setup.
- "How do you handle multiple user roles?" Create multiple setup files, one per role, and configure Playwright projects to use the appropriate storage state for each role.
Visual Overview
Storage State Pattern
─────────────────────
┌──────────────────────┐
│ Setup Project │ Runs ONCE
│ auth.setup.ts │
│ │
│ 1. Navigate /login │
│ 2. Fill credentials │
│ 3. Click Sign In │
│ 4. Save storageState│──> .auth/user.json
└──────────┬───────────┘ (cookies + localStorage)
│
│ depends on
▼
┌──────────────────────┐
│ All Other Tests │ Start ALREADY authenticated
│ │
│ transfer.spec.ts │ use: { storageState: '.auth/user.json' }
│ balance.spec.ts │
│ statement.spec.ts │ No login UI in any of these
│ loan.spec.ts │
└──────────────────────┘
Five Robustness Techniques ────────────────────────── 1. Environment base URL ──> Config, not hard-coded 2. Externalized secrets ──> CI vault, not source control 3. Reused storage state ──> Login once, skip in 100 tests 4. Auto-waiting ──> No hard sleeps, retries 5. Negative assertions ──> Wrong pass -> error + no dashboard
Key Takeaway
My Playwright login test opens the application, navigates to the login page, fills the credentials using stable role-based locators, submits, and asserts a reliable success signal: the dashboard URL and a known post-login element like the welcome heading and the account balance. But the interviewer is really asking how I make it robust, and I have five answers.
First, the base URL is environment-specific: I set it in the Playwright config from an environment variable so the same test runs against dev, QA, staging, or production by changing one setting. Second, credentials are externalized: I read them from environment variables injected by the CI secret store, never committing them to the repository. Third, I reuse authentication state: I log in once in a setup project, save cookies and local storage with storageState, and configure the rest of the suite to start already authenticated. Fourth, I use auto-waiting: Playwright's web-first assertions wait and retry automatically, so I never use hard sleeps. Fifth, I include negative assertions: a wrong password must show the correct error, keep the user on the login page, and reveal no authenticated content.
At the National Bank of India I refactored a suite where every test logged in through the UI, making it slow and intermittently flaky. I introduced the setup-project storage-state pattern so login happened once, moved all secrets to the CI vault, parameterized the base URL, replaced every fixed sleep with auto-waiting assertions, and added the failed-login negative test. The suite got dramatically faster and stopped flaking.
Remember one sentence: a robust Playwright login test uses an environment base URL, externalized secrets, reused storage state, auto-waiting instead of sleeps, and explicit negative assertions.
---