# The 14 rules of CANDO-TEST

Architecture rarely collapses because a team makes one obviously disastrous decision. More often, it erodes through small exceptions that each seem reasonable: a selector placed directly in a test, a Page Object that constructs another Page Object, a convenient data object copied into a second scenario.

The test suite still runs. The structure still looks familiar. But the boundaries no longer carry much weight.

The rules of CANDO-TEST are intended to make that erosion visible. They turn the framework’s central idea—each layer owns one concern, and dependencies move downwards—into decisions that can be applied during implementation and review.

This is the practical companion to *CANDO-TEST: A clearer architecture for UI test automation*. That article explains the layers and the thinking behind them. Here, I’ll focus on the constraints that keep the architecture intact as a suite grows.

## 1. Keep one domain in one folder

A domain is a cohesive, user-facing feature whose tests and supporting code tend to change together. All of its Assessments, Components, Fixtures, Network Mocks, Data Variant Creators, and Page Objects should live together.

```text
src/
  auth/
    assessments/
    components/
    dataVariants/
    fixtures/
    networkMocks/
    objects/
```

The boundary should follow the feature, not necessarily a route. One domain may span several pages, while a dashboard route may contain several independent domains.

**Why it matters:** Organising by technical layer scatters the knowledge required to understand a feature. Domain locality makes that knowledge easier to find, change, and remove.

**Do:** Put the code for an authentication flow under `src/auth/`.

**Don’t:** Distribute it across top-level `assessments/`, `pages/`, and `components/` directories.

## 2. Use Fixtures as the composition root

Fixtures should construct and provide the collaborators an Assessment needs: Page Objects, Network Mocks, and Data Variant Creators. The domain fixture is the single place where these dependencies are assembled.

This is an application of Inversion of Control. Assessments receive ready-to-use collaborators instead of knowing how to build them.

**Why it matters:** Manual construction spreads lifecycle and infrastructure decisions across test files. Fixtures keep those decisions in one inspectable place and allow the test runner to handle setup and cleanup correctly.

**Do:** Receive `todoPage` and `todoApiNetworkMock` from the domain fixture.

**Don’t:** Construct them in an Assessment or use `beforeEach` as a dependency-injection mechanism.

## 3. Import the test entry point from the domain fixture

An Assessment should import `test` and any re-exported assertion support from its domain fixture, not directly from the underlying automation framework.

```typescript
import { test } from "../fixtures/todo.fixtures";
```

This small import rule makes the composition root difficult to bypass accidentally.

**Why it matters:** A direct framework import gives an Assessment access to low-level runtime primitives and avoids the domain fixture. The test may still pass, but the architecture is no longer protecting it.

**Do:** Import from the fixture belonging to the Assessment’s domain.

**Don’t:** Import the base `test` or `expect` directly from Playwright or an equivalent framework.

## 4. Put cross-cutting setup in a shared session fixture

Authentication, base URLs, common headers, cache resets, and similar concerns usually apply across several domains. They belong in a shared session fixture that each domain fixture extends.

```text
base test
    ↓
shared session fixture
    ↓
domain fixture
```

Domain fixtures should add only what is specific to their feature.

**Why it matters:** Reimplementing session concerns in every domain creates drift. A change to authentication or cache handling then requires several coordinated edits.

**Do:** Define authentication once and inherit it through the fixture chain.

**Don’t:** Repeat the same session setup inside every domain.

## 5. Compose Components from shallow shared bases

A domain Component declares what its locators are and how its particular fields map to data. A shared base Component can own a reusable interaction protocol, such as submitting a form or asserting a field error.

Shared bases should remain shallow. If behaviour becomes richer, composition is usually easier to follow than another level of inheritance. Domain Components may also compose stable subregions of the same interface, provided they remain focused on element-level behaviour.

New shared bases should be extracted when two or more domains need the same protocol. They shouldn’t be created in anticipation of reuse that may never appear.

**Why it matters:** Shared protocols prevent duplication, but deep or speculative inheritance hides behaviour across several classes. The aim is reuse without recreating the coupling problems the layers were meant to solve.

**Do:** Let `CaseFormComponent` extend a proven `BaseFormComponent`.

**Don’t:** Reimplement the same submit-and-validate protocol in every domain, or build a deep hierarchy of hypothetical variants.

## 6. Let Assessments drive behaviour, not structure

An Assessment describes what should happen and under which conditions. It interacts with domain-level collaborators exposed by the fixture: Page Objects, Network Mocks, and Data Variant Creators.

It shouldn’t know about Components, locators, browser handles, request contexts, or the raw assertion API. Its configuration phase declares data and network conditions; its execution phase expresses a user action and the observable result.

```typescript
test("shows a success confirmation", async ({
  todoPage,
  todoApiNetworkMock,
  todoDataVariantCreator,
}) => {
  const data = todoDataVariantCreator.createValidData();
  await todoApiNetworkMock.withSuccessFetchResponse().mock();

  await todoPage.open();
  await todoPage.createTodo(data);
  await todoPage.assertCreatedSuccessfully();
});
```

**Why it matters:** An Assessment that reaches into page structure becomes coupled to implementation details. When the interface changes, the scenario changes even if the behaviour does not.

**Do:** Call `todoPage.createTodo(data)`.

**Don’t:** Locate the form fields or destructure the underlying browser page inside the Assessment.

## 7. Make Page Objects speak user language

Page Objects own page-level orchestration: navigation, URL state, modal and toast coordination, cross-Component sequencing, and full-page transitions. Their methods should describe what a user is trying to do.

Components own element-level interactions. A Page Object can coordinate several Components, but it shouldn’t contain their locators or reimplement their behaviour.

Page Objects also shouldn’t compose other Page Objects. A method may trigger navigation, but it shouldn’t return or own the destination Page Object. Each page can be constructed independently by the fixture.

**Why it matters:** User-language methods keep scenarios readable and insulate them from structural changes. Preventing nested Page Object graphs also avoids coupling the suite to its navigation structure.

**Do:** Expose `createTodo(data)` or `navigateToSettings()`.

**Don’t:** Expose `clickSubmitButton()` or return a `SettingsPage` from a dashboard method.

## 8. Reuse before rewriting

Before adding another helper, Component, data factory, or Network Mock, look for an existing abstraction that owns the same responsibility. Extend or configure it when doing so preserves a clear boundary.

Reuse isn’t the same as forcing unrelated concepts together. The existing abstraction should genuinely represent the same protocol or domain idea.

**Why it matters:** Meaningful reuse narrows the blast radius of change. A selector, data shape, or network response can be updated in one place rather than several near-duplicates.

**Do:** Add a named scenario to an existing Data Variant Creator.

**Don’t:** Copy an existing factory into a second Assessment because changing it locally feels quicker.

## 9. Don’t overbuild UI Components

Not every visible element deserves a class. A Component should exist when a region has interaction logic worth encapsulating, is reused, or forms a natural domain seam.

A multi-step checkout form is a reasonable Component. A wrapper around one static heading is probably not.

**Why it matters:** Excessively granular Components add names, files, constructors, and navigation without isolating meaningful change. The abstraction costs more than it saves.

**Do:** Group related controls into a Component that represents a coherent part of the interface.

**Don’t:** Create a class for every button, label, or link to make the structure appear more modular.

## 10. Generate test data through named variants

All scenario data should originate in a Data Variant Creator. Each method names an intent:

```typescript
const valid = todoDataVariantCreator.createValidData();
const overdue = todoDataVariantCreator.createWithPastDueDate();
const urgent = todoDataVariantCreator.createValidData({
  priority: "high",
});
```

A scoped override can pin one dimension of a recognised scenario. It shouldn’t replace the scenario with an arbitrary object built inside the Assessment.

Data Variant Creators are pure factories. They don’t call APIs, mutate application state, or use test-framework assertions. They also define their own types inside the test repository instead of importing types or schemas from the application under test.

**Why it matters:** Named variants explain why the data exists. Repository-owned types also keep the tests independent: they assert the application’s observable contract rather than its consistency with its own internal definitions.

**Do:** Use `createValidData({ priority: "high" })`.

**Don’t:** Construct the entire data object inline or import the application’s DTO.

## 11. Add Component interfaces only when substitution exists

An interface is useful when two or more Component implementations can fill the same role—for example, standard and accessible versions of a form.

When only one implementation exists, the Page Object should accept the concrete Component. An interface can be introduced when a second implementation creates a real need for substitution.

**Why it matters:** Interfaces make variation easier when variation exists. Before that point, they add ceremony without reducing coupling.

**Do:** Introduce an interface for two interchangeable payment Components.

**Don’t:** Require an interface for every class in the name of consistency.

## 12. Use Builders only for structural complexity

A Page Object Builder is justified when a page has at least two optional sub-Components, several structural configuration choices, or conditional construction logic.

Builders compose structure. Scenario data belongs in Page Object method arguments at execution time:

```typescript
const checkoutPage = checkoutPageBuilder
  .withPaymentComponent(stripePayment)
  .withShippingComponent(domesticShipping)
  .withGuestCheckout()
  .build();

await checkoutPage.placeOrder(orderData);
```

For a fixed page with one Component, the fixture should construct the Page Object directly.

**Why it matters:** A Builder around a simple constructor is `new` with extra steps. Passing scenario data through it also mixes two concerns: what the page is made of and what the user is doing.

**Do:** Use a Builder for genuinely optional or conditional page structure.

**Don’t:** Add `withFormData(data)` to a Builder or use one for every Page Object.

## 13. Opt out when the Page Object metaphor breaks

Some interaction models resist clean user-intent methods. Graph editors, canvases, and complex drag-and-drop surfaces can require coordinate-based or continuous interactions that become misleading when forced behind conventional Page Object names.

An Assessment may opt out of the standard Object layer when this happens. It should remain in the domain, use its Fixtures, Network Mocks, and Data Variant Creators, and document why bespoke interaction helpers are necessary.

This is an exception for a mismatched interaction model, not a shortcut for difficult work. If most Assessments in a domain opt out, that domain is no longer meaningfully using CANDO-TEST.

**Why it matters:** A documented exception is more honest than an abstraction whose method names conceal what the interaction actually requires. Keeping the exception narrow prevents it from becoming a route around the architecture.

**Do:** Explain the opt-out at the top of the Assessment file.

**Don’t:** Use the rule because the page is unfamiliar or the standard structure takes longer to implement.

## 14. Enforce the folder convention in configuration

The test runner should discover tests only inside `assessments/` directories. The structure should be enforced by configuration, not depend entirely on contributors remembering it.

**Why it matters:** If any test-shaped file can run from anywhere in the repository, code can bypass the domain fixture and the Assessment conventions while still appearing in the test results.

**Do:** Restrict the discovery pattern to domain `assessments/` folders.

**Don’t:** Rely on a broad default pattern and a written convention alone.

## The supporting ownership rules

The 14 rules define the main structure, but several ownership decisions make them work in practice.

### Synchronisation belongs below the Assessment

Components wait for element-level state, such as an enabled button or a visible alert. Page Objects wait for page-level transitions, such as navigation or a modal closing. Assessments don’t add explicit delays; they invoke an assertion whose owning layer handles the required synchronisation.

### Assertions belong to the observed surface

Components assert element state. Page Objects assert page-level outcomes. Network Mocks assert HTTP contracts when those contracts matter. Data Variant Creators never assert. Assessments declare the outcome by calling a named assertion on the layer that can observe it.

### Tests remain independent

No Assessment should rely on state created by another. Data Variant Creators remain pure, Network Mocks have per-test state, and cross-test setup belongs in Fixtures.

Fixtures may use practical mechanisms such as API calls, cookies, or storage seeding to arrange state. Test repository autonomy governs what the suite *asserts*, not every mechanism it may use to prepare a scenario.

## Rules should protect against a known failure

A framework can accumulate rules in the same way an application accumulates abstractions: one reasonable addition at a time, until nobody remembers why half of them exist.

Each CANDO-TEST rule should be traceable to a failure it prevents. The rule against locators in Page Objects protects element-level encapsulation. The rule against inline data protects scenario clarity. The limits on interfaces and Builders protect against abstraction inflation.

That gives reviewers a useful test: if we can’t explain the failure a rule prevents in this situation, it may not be worth enforcing there.

The goal isn’t architectural purity. It is a test suite in which intent remains readable, changes remain local, and exceptions remain visible. The rules give that structure enough strength to survive the small shortcuts that otherwise accumulate unnoticed.
