Four Files, One Job Each
CDAT is structurally boring. Four files per feature, each with one job, and a dependency graph that only points one way. The boring is the whole point.
The name is the layout. Components, Data, Actions, Tests.
The four layers
Components. Locators and nothing else. No waits, no logic, no assertions. If you find a click() in here, you have already started a god object in disguise.
export class LoginComponents {
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(private readonly page: Page) {
this.usernameInput = page.getByLabel('Username');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
this.errorMessage = page.locator('[data-testid="error-message"]');
}
}
Data. Types, fixtures, enums, URLs. No locators, no DOM. This is also the first file an AI assistant reads when it tries to understand the feature, which is not an accident.
export interface LoginCredentials {
username: string;
password: string;
rememberMe?: boolean;
}
export enum LoginErrorType {
InvalidCredentials = 'Invalid username or password',
RequiredField = 'This field is required',
}
export const VALID_USER: LoginCredentials = { username: 'testuser', password: 'Password123!' };
export const INVALID_CREDENTIALS: LoginCredentials = { username: 'wronguser', password: 'wrongpassword' };
Actions. Business logic built from smart waits. It executes. It never asserts. State getters return data for the test to judge.
export class LoginActions {
private readonly components: LoginComponents;
constructor(private readonly page: Page) {
this.components = new LoginComponents(page);
}
async login(credentials: LoginCredentials): Promise<void> {
await Cdat.waitAndFill(this.components.usernameInput, credentials.username);
await Cdat.waitAndFill(this.components.passwordInput, credentials.password);
await Cdat.waitAndClick(this.components.submitButton);
}
// returns data, never asserts
async getErrorMessage(): Promise<string> {
const visible = await Cdat.checkState(this.components.errorMessage, LocatorState.Visible);
if (!visible) return '';
return Cdat.waitForText(this.components.errorMessage);
}
}
Tests. Scenarios, and every expect() in the codebase. The only place where business intent and assertion live together.
test('TC_LOGIN_003: invalid credentials, error shown', async () => {
await actions.login(INVALID_CREDENTIALS);
const error = await actions.getErrorMessage();
expect(error).toContain(LoginErrorType.InvalidCredentials);
});
The dependency rule that buys the reuse
Lower layers never import higher ones. Components and Data know nothing about Actions. Actions know nothing about Tests. That single rule is why one login() action is reused across thirty tests without anyone exposing internals or copy-pasting a method.
The seam, in one example
Here is a login as one all-in-one method. Any structure collapses to this under delivery pressure. It is a universal shape, not a property of one pattern.
class LoginPage {
async login(user: string, pass: string) {
await this.usernameInput.fill(user);
await this.passwordInput.fill(pass);
await this.submitButton.click();
await expect(this.dashboard).toBeVisible(); // the assertion lives inside the method
}
}
That last line is the tell. The method now has an opinion about what success means. A test that wants to assert something else has to wrap the method or copy it.
Here is the same behaviour as CDAT. The action executes, the test judges.
// actions.ts - executes, does not assert
async login(creds: LoginCredentials): Promise<void> {
await Cdat.waitAndFill(this.components.username, creds.username);
await Cdat.waitAndClick(this.components.submit);
}
// test.ts - asserts
test('login succeeds', async ({ page }) => {
const actions = new LoginActions(page);
await actions.login(VALID_USER);
await expect(page).toHaveURL(/dashboard/);
});
Now thirty tests can call login(VALID_USER) and each assert whatever it needs. Nothing duplicated. Nothing exposed. That is the entire trade, made once, at the layer boundary.
One test, all four layers
Trace TC_LOGIN_003 and you cross every layer exactly once.
The test asks for INVALID_CREDENTIALS and the LoginErrorType.InvalidCredentials enum, both from data. It runs login() and getErrorMessage() from actions. Those reach the usernameInput, submitButton and errorMessage locators in components. The waits underneath come from the Cdat smart-wait helpers, so no hardcoded delay touches the test. The single expect() stays in the test.
Four files, one path, each layer doing exactly one thing.
Six rules that keep it honest
Locators live in components.ts. Never build a locator inside a test or an action.
Actions execute, tests assert. No expect() outside test.ts.
Fixtures live in data.ts. No hardcoded credentials inside a test.
No waitForTimeout. Wait for the condition, not the clock.
No any. One any in data.ts is contagious; every consumer downstream loses its types.
Compose, do not inherit. If checkout needs cart, inject cart, do not subclass it.
These are guidelines, not a religion. Honest data across nine systems: the else count runs 9 to 45 per repo, and three of five teams keep the no-any rule as a warning rather than a build break. Treat the rules as a code-review smell, not a CI gate, and you keep both your team and most of the benefit.
The proof you are not building another monolith
Across the example suite the layers weigh in close. Data 1636 lines. Components 1343. Actions 1574. Tests 1234.
No single file is eating the feature. That balance is the structural opposite of a god object, and it is the thing you are actually buying. A monolith has one file at four thousand lines and three at fifty. CDAT spreads the weight on purpose.
Where the agent earns its keep
This is the context-before-LLM argument made concrete.
Point an agent at a CDAT feature and the job is well-posed. “Add a negative test for a locked account.” It knows the locator goes in components.ts, the fixture in data.ts, the flow in actions.ts, the assertion in test.ts. Four small single-purpose files are four small well-scoped prompts.
Point the same agent at a 1200-line all-in-one class and the job becomes “add a test somewhere in here and try not to break the other thirty”. One is a contract. The other is a hope. The model is the same model. The architecture is what changed the odds.
The cost, stated plainly
Four files for a feature that one method used to hold. Even a one-test feature gets all four, on purpose, so a newcomer can predict the layout instead of learning each exception. If your team will not enforce the boundaries, CDAT saves you nothing. You will write a 1500-line actions.ts instead of a 1500-line page object and learn only a new folder name.
It is also opinionated towards TypeScript. The JavaScript version runs, but you lose the no-any rule and roughly a third of the value.
Part 3 is the part your Head of QA cares about. What this costs to adopt, what it returns in maintenance, and why the architecture decision is now an AI decision.
From the Field is what I actually build, what breaks, and what I learn. Real projects, real numbers, real bugs. No tutorials.