Login (Basic)
The simplest CDAT example. One feature (login), four files, all the principles. Read this first.
Live code preview
Below is the actual implementation as it lives in the cdat-pattern repository. Switch tabs to see each layer; the structure is identical for every feature in your project.
/**
* Login Feature - Components Layer
*
* This file contains ONLY locators and selectors.
* No business logic, no assertions, no waits.
*
* @layer Components (C in CDAT)
*/
import type { Page, Locator } from '@playwright/test';
/**
* Login page component locators
*
* @example
* ```typescript
* const components = new LoginComponents(page);
* await components.usernameInput.fill('user');
* ```
*/
export class LoginComponents {
// ─────────────────────────────────────────────────────────────────
// FORM ELEMENTS
// ─────────────────────────────────────────────────────────────────
/** Username/email input field */
readonly usernameInput: Locator;
/** Password input field */
readonly passwordInput: Locator;
/** Submit/login button */
readonly submitButton: Locator;
// ─────────────────────────────────────────────────────────────────
// FEEDBACK ELEMENTS
// ─────────────────────────────────────────────────────────────────
/** Error message container */
readonly errorMessage: Locator;
/** Success message or redirect indicator */
readonly successIndicator: Locator;
// ─────────────────────────────────────────────────────────────────
// NAVIGATION ELEMENTS
// ─────────────────────────────────────────────────────────────────
/** Forgot password link */
readonly forgotPasswordLink: Locator;
/** Register/Sign up link */
readonly registerLink: Locator;
/** Remember me checkbox */
readonly rememberMeCheckbox: Locator;
constructor(private readonly page: Page) {
// Form elements
this.usernameInput = page.getByLabel('Username');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
// Feedback elements
this.errorMessage = page.locator('[data-testid="error-message"]');
this.successIndicator = page.locator('[data-testid="success-indicator"]');
// Navigation elements
this.forgotPasswordLink = page.getByRole('link', { name: /forgot password/i });
this.registerLink = page.getByRole('link', { name: /sign up|register/i });
this.rememberMeCheckbox = page.getByLabel(/remember me/i);
}
// ─────────────────────────────────────────────────────────────────
// COMPOSED SELECTORS (Selector Composition Pattern)
// ─────────────────────────────────────────────────────────────────
/**
* Get a specific field error by field name
*
* @param fieldName - Name of the field (username, password)
* @returns Locator for the field-specific error message
*/
getFieldError(fieldName: string): Locator {
return this.page.locator(`[data-testid="${fieldName}-error"]`);
}
}
/**
* Login Feature - Data Layer
*
* This file contains ONLY test data, types, and constants.
* No business logic, no locators.
*
* @layer Data (D in CDAT)
*/
// ─────────────────────────────────────────────────────────────────
// INTERFACES
// ─────────────────────────────────────────────────────────────────
/**
* User credentials for login
*/
export interface LoginCredentials {
username: string;
password: string;
rememberMe?: boolean;
}
/**
* Login response data
*/
export interface LoginResult {
success: boolean;
errorMessage?: string;
redirectUrl?: string;
}
// ─────────────────────────────────────────────────────────────────
// ENUMS
// ─────────────────────────────────────────────────────────────────
/**
* Types of login errors
*/
export enum LoginErrorType {
InvalidCredentials = 'Invalid username or password',
AccountLocked = 'Account is locked',
AccountDisabled = 'Account is disabled',
NetworkError = 'Network error occurred',
RequiredField = 'This field is required',
}
// ─────────────────────────────────────────────────────────────────
// TEST DATA - Valid Credentials
// ─────────────────────────────────────────────────────────────────
/**
* Valid test user credentials
* Works with demo.playwright.dev/todoapp
*/
export const VALID_USER: LoginCredentials = {
username: 'testuser',
password: 'Password123!',
rememberMe: false,
};
/**
* Admin user credentials
*/
export const ADMIN_USER: LoginCredentials = {
username: 'admin',
password: 'AdminPass123!',
rememberMe: true,
};
// ─────────────────────────────────────────────────────────────────
// TEST DATA - Invalid Credentials
// ─────────────────────────────────────────────────────────────────
/**
* Invalid credentials for negative testing
*/
export const INVALID_CREDENTIALS: LoginCredentials = {
username: 'wronguser',
password: 'wrongpassword',
};
/**
* Empty credentials for validation testing
*/
export const EMPTY_CREDENTIALS: LoginCredentials = {
username: '',
password: '',
};
/**
* Credentials with SQL injection attempt (for security testing)
*/
export const SQL_INJECTION_ATTEMPT: LoginCredentials = {
username: "' OR '1'='1",
password: "' OR '1'='1",
};
// ─────────────────────────────────────────────────────────────────
// URLS
// ─────────────────────────────────────────────────────────────────
/**
* URL paths for login feature
*/
export const LOGIN_URLS = {
loginPage: '/login',
dashboardPage: '/dashboard',
forgotPasswordPage: '/forgot-password',
registerPage: '/register',
} as const;
// ─────────────────────────────────────────────────────────────────
// TIMEOUTS
// ─────────────────────────────────────────────────────────────────
/**
* Feature-specific timeout overrides
*/
export const LOGIN_TIMEOUTS = {
/** Time to wait for login API response */
loginApiResponse: 10000,
/** Time to wait for redirect after successful login */
redirectAfterLogin: 5000,
} as const;
/**
* Login Feature - Actions Layer
*
* This file contains business logic and user interactions.
* NO expect() assertions here - those belong in tests.
*
* @layer Actions (A in CDAT)
*/
import type { Page } from '@playwright/test';
import { Cdat, LocatorState } from '../../utils/Cdat';
import { LoginComponents } from './components';
import type { LoginCredentials, LoginResult } from './data';
/**
* Login feature actions
*
* Implements user interactions for login functionality.
* All methods use smart waits (Zero waitForTimeout principle).
*
* @example
* ```typescript
* const actions = new LoginActions(page);
* await actions.login(VALID_USER);
* ```
*/
export class LoginActions {
private readonly components: LoginComponents;
constructor(private readonly page: Page) {
this.components = new LoginComponents(page);
}
// ─────────────────────────────────────────────────────────────────
// ATOMIC ACTIONS (Single Responsibility)
// ─────────────────────────────────────────────────────────────────
/**
* Fill username field
*
* @param username - Username to enter
*/
async fillUsername(username: string): Promise<void> {
await Cdat.waitAndFill(this.components.usernameInput, username);
}
/**
* Fill password field
*
* @param password - Password to enter
*/
async fillPassword(password: string): Promise<void> {
await Cdat.waitAndFill(this.components.passwordInput, password);
}
/**
* Click submit button
*/
async clickSubmit(): Promise<void> {
await Cdat.waitAndClick(this.components.submitButton);
}
/**
* Toggle remember me checkbox
*
* @param checked - Whether to check the checkbox
*/
async setRememberMe(checked: boolean): Promise<void> {
if (checked) {
await Cdat.waitAndCheck(this.components.rememberMeCheckbox);
return;
}
await Cdat.waitAndUncheck(this.components.rememberMeCheckbox);
}
// ─────────────────────────────────────────────────────────────────
// COMPOSED ACTIONS (Method Composition Pattern)
// ─────────────────────────────────────────────────────────────────
/**
* Perform complete login flow
*
* @param credentials - User credentials
*
* @example
* ```typescript
* await actions.login({ username: 'user', password: 'pass' });
* ```
*/
async login(credentials: LoginCredentials): Promise<void> {
await this.fillUsername(credentials.username);
await this.fillPassword(credentials.password);
if (credentials.rememberMe !== undefined) {
await this.setRememberMe(credentials.rememberMe);
}
await this.clickSubmit();
}
/**
* Login and wait for successful redirect
*
* @param credentials - User credentials
* @param expectedUrl - Expected URL after successful login
*/
async loginAndWaitForRedirect(
credentials: LoginCredentials,
expectedUrl: string
): Promise<void> {
await this.login(credentials);
await Cdat.waitForUrlContains(this.page, expectedUrl);
}
// ─────────────────────────────────────────────────────────────────
// STATE GETTERS (No assertions - return data for tests)
// ─────────────────────────────────────────────────────────────────
/**
* Get current error message text
*
* @returns Error message text or empty string if not visible
*/
async getErrorMessage(): Promise<string> {
const isVisible = await Cdat.checkState(
this.components.errorMessage,
LocatorState.Visible
);
if (!isVisible) {
return '';
}
return Cdat.waitForText(this.components.errorMessage);
}
/**
* Check if login was successful (success indicator visible)
*
* @returns true if success indicator is visible
*/
async isLoginSuccessful(): Promise<boolean> {
return Cdat.checkState(
this.components.successIndicator,
LocatorState.Visible
);
}
/**
* Check if error message is displayed
*
* @returns true if error message is visible
*/
async isErrorDisplayed(): Promise<boolean> {
return Cdat.checkState(
this.components.errorMessage,
LocatorState.Visible
);
}
// ─────────────────────────────────────────────────────────────────
// NAVIGATION ACTIONS
// ─────────────────────────────────────────────────────────────────
/**
* Navigate to login page
*
* @param baseUrl - Base URL of the application
*/
async navigateToLoginPage(baseUrl: string): Promise<void> {
await this.page.goto(`${baseUrl}/login`);
}
/**
* Click forgot password link
*/
async clickForgotPassword(): Promise<void> {
await Cdat.waitAndClick(this.components.forgotPasswordLink);
}
/**
* Click register link
*/
async clickRegister(): Promise<void> {
await Cdat.waitAndClick(this.components.registerLink);
}
// ─────────────────────────────────────────────────────────────────
// FORM HELPERS
// ─────────────────────────────────────────────────────────────────
/**
* Clear all form fields
*/
async clearForm(): Promise<void> {
await Cdat.waitAndClear(this.components.usernameInput);
await Cdat.waitAndClear(this.components.passwordInput);
}
/**
* Get current form values
*
* @returns Current values in the form
*/
async getFormValues(): Promise<{ username: string; password: string }> {
const username = await Cdat.waitForInputValue(this.components.usernameInput);
const password = await Cdat.waitForInputValue(this.components.passwordInput);
return { username, password };
}
}
/**
* Login Feature - Tests Layer
*
* This file contains test scenarios with assertions.
* All expect() calls belong HERE, not in Actions.
*
* @layer Tests (T in CDAT)
*/
import { test, expect } from '@playwright/test';
import { LoginActions } from './actions';
import { LoginComponents } from './components';
import {
VALID_USER,
INVALID_CREDENTIALS,
EMPTY_CREDENTIALS,
LOGIN_URLS,
LoginErrorType,
} from './data';
// ─────────────────────────────────────────────────────────────────
// TEST SETUP
// ─────────────────────────────────────────────────────────────────
test.describe('Login Feature', () => {
let actions: LoginActions;
let components: LoginComponents;
test.beforeEach(async ({ page, baseURL }) => {
actions = new LoginActions(page);
components = new LoginComponents(page);
// Navigate to login page before each test
await page.goto(`${baseURL}${LOGIN_URLS.loginPage}`);
});
// ─────────────────────────────────────────────────────────────────
// POSITIVE SCENARIOS
// ─────────────────────────────────────────────────────────────────
test.describe('Positive Scenarios', () => {
test('TC_LOGIN_001: Given valid credentials, When user logs in, Then dashboard is displayed', async ({
page,
}) => {
// Arrange
const credentials = VALID_USER;
// Act
await actions.login(credentials);
// Assert
await expect(page).toHaveURL(new RegExp(LOGIN_URLS.dashboardPage));
});
test('TC_LOGIN_002: Given valid credentials with remember me, When user logs in, Then session persists', async ({
page,
}) => {
// Arrange
const credentials = { ...VALID_USER, rememberMe: true };
// Act
await actions.login(credentials);
// Assert
await expect(page).toHaveURL(new RegExp(LOGIN_URLS.dashboardPage));
// Additional assertion: Check for persistent session cookie
const cookies = await page.context().cookies();
const sessionCookie = cookies.find((c) => c.name === 'session');
expect(sessionCookie?.expires).toBeGreaterThan(Date.now() / 1000);
});
});
// ─────────────────────────────────────────────────────────────────
// NEGATIVE SCENARIOS
// ─────────────────────────────────────────────────────────────────
test.describe('Negative Scenarios', () => {
test('TC_LOGIN_003: Given invalid credentials, When user logs in, Then error is displayed', async () => {
// Arrange
const credentials = INVALID_CREDENTIALS;
// Act
await actions.login(credentials);
// Assert
const errorMessage = await actions.getErrorMessage();
expect(errorMessage).toContain(LoginErrorType.InvalidCredentials);
});
test('TC_LOGIN_004: Given empty username, When user submits form, Then validation error is shown', async () => {
// Arrange
const credentials = { ...EMPTY_CREDENTIALS, password: 'somepassword' };
// Act
await actions.login(credentials);
// Assert
const fieldError = components.getFieldError('username');
await expect(fieldError).toBeVisible();
await expect(fieldError).toContainText(LoginErrorType.RequiredField);
});
test('TC_LOGIN_005: Given empty password, When user submits form, Then validation error is shown', async () => {
// Arrange
const credentials = { ...EMPTY_CREDENTIALS, username: 'someuser' };
// Act
await actions.login(credentials);
// Assert
const fieldError = components.getFieldError('password');
await expect(fieldError).toBeVisible();
await expect(fieldError).toContainText(LoginErrorType.RequiredField);
});
});
// ─────────────────────────────────────────────────────────────────
// NAVIGATION SCENARIOS
// ─────────────────────────────────────────────────────────────────
test.describe('Navigation', () => {
test('TC_LOGIN_006: Given login page, When forgot password clicked, Then forgot password page opens', async ({
page,
}) => {
// Act
await actions.clickForgotPassword();
// Assert
await expect(page).toHaveURL(new RegExp(LOGIN_URLS.forgotPasswordPage));
});
test('TC_LOGIN_007: Given login page, When register clicked, Then register page opens', async ({
page,
}) => {
// Act
await actions.clickRegister();
// Assert
await expect(page).toHaveURL(new RegExp(LOGIN_URLS.registerPage));
});
});
// ─────────────────────────────────────────────────────────────────
// ACCESSIBILITY SCENARIOS
// ─────────────────────────────────────────────────────────────────
test.describe('Accessibility', () => {
test('TC_LOGIN_008: Given login form, Then all inputs have accessible labels', async () => {
// Assert
await expect(components.usernameInput).toBeVisible();
await expect(components.passwordInput).toBeVisible();
// Verify labels are properly associated
await expect(components.usernameInput).toHaveAccessibleName(/username/i);
await expect(components.passwordInput).toHaveAccessibleName(/password/i);
});
test('TC_LOGIN_009: Given login form, Then form is keyboard navigable', async ({ page }) => {
// Act - Navigate with Tab key
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
// Assert - Submit button should be focused
await expect(components.submitButton).toBeFocused();
});
});
});
Run it locally
git clone https://github.com/darco81/cdat-pattern.git
cd cdat-pattern/examples/basic
pnpm install
pnpm exec playwright install chromium
pnpm testYou should see one test pass: “Given valid credentials, When login, Then dashboard opens”.
Why this structure for one feature?
You’re testing one form. Why four files?
components.ts- when the login form’s HTML changes (and it will), you change one file. The selector strategy stays in one place.data.ts- when QA needs a new test user, they add a fixture. No git diff intest.tsfor data churn.actions.ts-login(creds)is reusable. The next feature that needs an authed session importsLoginActionsand callslogin(VALID_USER). No copy-paste.test.ts- assertions are clearly demarcated. If a test fails, you know exactly which scenario broke; the assertion is right there.
Next steps
- /docs/architecture - full dependency rules
- /examples/e-commerce - multi-feature composition
- /docs/zero-rules - the three principles in this code