Best Practices

Practical guidance for writing Donobu tests that are reliable, maintainable, and cost-efficient.

Use Donobu's interpolation syntax instead of JS template literals

Injecting dynamic values with JavaScript string interpolation bakes the runtime value into the cache key. This creates a separate cache entry for every unique value — log in as alice@example.com, log in as bob@example.com, and so on — making the cache grow unboundedly and eliminating the performance benefit of caching.

// ❌ Cache explosion — separate entry per user
await page.ai(
  `Log in as ${process.env.USERNAME} with password ${process.env.PASSWORD}`,
);

// ✅ Stable cache — one entry for all users
await page.ai('Log in as {{$.env.USERNAME}} with password {{$.env.PASSWORD}}', {
  envVars: ['USERNAME', 'PASSWORD'],
});

See Environment Variable Interpolation for the full syntax.


Write goal-oriented instructions, not step-by-step scripts

The AI is more robust when given an outcome to achieve rather than a sequence of steps to follow. Step-by-step instructions are fragile — if any single step changes (a button moves, a field is renamed), the instruction may fail. Goal-oriented instructions allow the AI to adapt.

// ❌ Brittle — breaks if any step changes
await page.ai(
  'Click the blue button, then type "Pro" in the plan name field, then click Save',
);

// ✅ Robust — the AI figures out the current path to the goal
await page.ai('Change the subscription plan to Pro');

Use page.ai.extract for reading, page.ai for acting

page.ai.extract is purpose-built for reading structured data from the current page. It is faster, cheaper (a single LLM call rather than a multi-step flow), and does not write to the cache. Use page.ai() only when the AI needs to navigate or interact with the page to reach the data.

// ✅ Reading data that's already visible — use extract
const plan = await page.ai.extract(z.object({ currentPlan: z.string() }));

// ✅ Navigating to reach data — use page.ai with a schema
const invoice = await page.ai('Open the most recent invoice', {
  schema: z.object({ amount: z.string(), date: z.string() }),
});

Narrow allowedTools for critical flows

Restricting the tool set to what a flow actually needs reduces the chance of the AI taking an unexpected side path and makes cached replays more predictable. It also serves as documentation: the allowedTools list communicates exactly what kind of interactions the test involves.

// Login flow: only these interactions are needed
await page.ai('Log in with the test account', {
  allowedTools: ['click', 'inputText', 'pressKey'],
});

// Form submission: only form-related tools
await page.ai('Fill in and submit the registration form', {
  allowedTools: [
    'click',
    'inputText',
    'chooseSelectOption',
    'pressKey',
    'scrollPage',
  ],
});

Use page.ai.assert for lightweight checks

page.ai.assert sits between page.ai and Playwright's expect: it evaluates natural-language conditions intelligently without running a full autonomous flow. Use it when the check requires understanding — not just an exact string match — but doesn't require navigating or interacting with the page.

// ✅ Full flow (page.ai) — navigates and acts
await page.ai('Add the cheapest item to the cart');

// ✅ Lightweight assertion (page.ai.assert) — evaluates intelligently, no navigation
await page.ai.assert('The cart icon shows at least one item');

// ✅ Exact check (expect) — deterministic, no AI needed
await expect(page.locator('.cart-count')).toHaveText('1');

page.ai.assert is especially suitable for conditions that require a judgement call, such as visual or layout checks: 'The page has a clean, uncluttered layout' or 'The error message is prominently visible'.


Use storageState for authenticated tests

Do not spend AI tokens logging in at the start of every test. Log in once in a global setup step, save the browser state, and reuse it across the suite.

// playwright.config.ts
export default defineConfig({
  projects: [
    {
      name: 'authenticated',
      use: { storageState: 'playwright/.auth/user.json' },
      testMatch: '**/*.auth.test.ts',
    },
  ],
});

See Playwright's authentication guide for how to generate the state file.


Wrap unstable selectors with page.find failovers

For elements whose primary selector may drift between deployments (e.g. auto-generated class names, changing data-testid values), list fallback selectors up front. This provides an immediate graceful degradation rather than a hard failure that requires a self-healing cycle.

// ✅ Multiple selectors — works even if the primary one changes
const submitButton = page.find('[data-testid="submit-btn"]', {
  failover: ['button:has-text("Submit")', 'button[type=submit]'],
});
await submitButton.click();

Commit the cache directory

The .cache-lock/ cache files are plain JavaScript modules. Committing them to version control ensures:

  • Every developer gets fast, deterministic test runs on git clone without calling the AI
  • CI runs are consistent and cheap
  • Cache updates are visible in pull requests, making it easy to review which flows changed and why

One goal per page.ai call

Chaining multiple unrelated goals into a single instruction makes the flow harder to cache reliably and harder to debug when it fails. Each page.ai call should accomplish one coherent user action.

// ❌ Two unrelated goals in one call — harder to cache, harder to debug
await page.ai(
  'Log in to the app and then navigate to the billing settings and cancel the subscription',
);

// ✅ Split into focused calls
await page.ai('Log in with the test account');
await page.ai('Navigate to the billing settings');
await page.ai('Cancel the current subscription');

Test at the right level of abstraction

Not every test needs page.ai. Use the right tool for each scenario:

ScenarioRecommended approach
Complex multi-step user journeypage.ai(instruction)
Reading structured data from a pagepage.ai.extract(schema)
Checking a layout or visual conditionpage.ai.assert(condition)
Checking an exact text or valueexpect(locator).toHaveText(...)
Clicking a stable, well-identified elementpage.locator(selector).click()
Clicking an element that may change selectorspage.find(selector, { failover }).click()