Best practices for Scout UI tests
Best practices specific to Scout UI tests.
For guidance that applies to both UI and API tests, see the general Scout best practices. Scout is built on Playwright, so the official Playwright Best Practices also apply.
Default to parallel UI suites when possible. Parallel workers share the same Kibana/ES deployment, but run in isolated Spaces.
| Mode | When to use |
|---|---|
| Parallel | UI tests (most suites), suites that share pre-ingested data (often using the global setup hook) |
| Sequential | API tests, suites that require a “clean” Elasticsearch state |
UI tests should answer “does this feature work for the user?” Verify that components render, respond to interaction, and navigate correctly. Leave exact data validation (computed values, aggregation results, edge cases) to API or unit tests, which are faster and less brittle.
| What you’re testing | Recommended layer |
|---|---|
| User flows, navigation, rendering | Scout UI test |
| Data correctness, API contracts, edge cases | Scout API test |
| Isolated component logic (loading/error states, tooltips, field validation) | RTL/Jest unit test |
Examples
❌ Don’t: verify computed values that belong in an API test:
await expect(page.testSubj.locator('row-0-col-count')).toHaveText('1,024');
await expect(page.testSubj.locator('row-0-col-avg')).toHaveText('42.7');
✔️ Do: verify that the UI renders and responds to interaction:
await expect(page.testSubj.locator('datasetQualityTable-loaded')).toBeVisible();
await page.testSubj.click('tableSortByLastActivity');
await expect(page.testSubj.locator('row-0-col-dataset')).not.toHaveText('');
When a test asserts user flow (not just “land on a page”), prefer navigation the way a user would: follow links and buttons, and use browser history (page.goBack()) instead of direct URL jumps where it matters for the scenario. Reserve page.goto / deep links for cheap setup when the test is not about navigation.
Setup/teardown using UI is slow and brittle. Prefer Kibana APIs and fixtures.
Examples
❌ Don’t: create test data through the UI:
test.beforeEach(async ({ page, browserAuth }) => {
await browserAuth.loginAsAdmin();
await page.testSubj.click('createDataViewButton');
await page.testSubj.fill('indexPatternInput', 'logs-*');
await page.testSubj.click('saveDataViewButton');
});
✔️ Do: use API fixtures:
test.beforeEach(async ({ uiSettings, kbnClient }) => {
await uiSettings.setDefaultTime({ from: startTime, to: endTime });
await kbnClient.importExport.load(DATA_VIEW_ARCHIVE_PATH);
});
Playwright actions and web-first assertions already wait/retry. Don’t add redundant waits, and never use page.waitForTimeout() as it’s a hard sleep with no readiness signal and a common source of flakiness.
Examples
❌ Don’t: add unnecessary waits before actions or assertions:
await page.testSubj.waitForSelector('myButton', { state: 'visible' });
await page.testSubj.click('myButton');
await page.testSubj.locator('successToast').waitFor();
await expect(page.testSubj.locator('successToast')).toBeVisible();
✔️ Do: let Playwright handle waiting automatically:
await page.testSubj.click('myButton');
await expect(page.testSubj.locator('successToast')).toBeVisible();
When an action triggers async UI work (navigation, saving, loading data), wait for the resulting state before your next step. This ensures the UI is ready and prevents flaky interactions with elements that haven’t rendered yet.
Example
await page.gotoApp('sample/page/here');
await page.testSubj.waitForSelector('mainContent', { state: 'visible' });
If an action fails, don't wrap it in a retry loop. Playwright already waits for actionability; repeated failures usually point to an app issue (unstable DOM, non-unique selectors, re-render bugs). Fix the component or make your waiting/locators explicit and stable.
Examples
❌ Don't: retry actions in a loop:
for (let i = 0; i < 3; i++) {
try {
await page.testSubj.click('submitButton');
break;
} catch {
await page.waitForTimeout(1000);
}
}
✔️ Do: fix the root cause (for example, wait for a readiness signal):
await expect(page.testSubj.locator('formReady')).toBeVisible();
await page.testSubj.click('submitButton');
Prefer stable data-test-subj attributes accessed using page.testSubj. If data-test-subj is missing, prefer adding one to source code. If that’s not possible, use getByRole inside a scoped container.
Examples
❌ Don’t: use raw CSS selectors or unscoped text matchers (searching the entire page for text is unreliable when duplicates exist):
await page.click('[data-test-subj="myButton"]');
await page.getByText('Delete').click();
❌ Don’t: select elements by index (flagged by Playwright’s recommended ESLint rules), as they break on non-clean environments where tests run without server restart and extra data may exist:
await page.testSubj.locator('tableRow').nth(0).click();
✔️ Do: use page.testSubj or scoped getByRole:
await page.testSubj.click('myButton');
await page.testSubj.locator('confirmDeleteModal').getByRole('button', { name: 'Delete' }).click();
Scout configures Playwright timeouts (source). Prefer defaults.
- Don’t override suite-level timeouts/retries with
test.describe.configure()unless you have a strong reason. - If you increase a timeout for one operation, keep it well below the test timeout and add a short code comment explaining why (slow first load, CI variance, known heavy view, etc.).
- After raising timeouts for flakiness, re-run the flaky test runner (or many local repeats) to confirm the new value is necessary.
- Keep in mind that an assertion timeout that exceeds the test timeout is ignored.
- Time spent in hooks (
beforeEach,afterEach) counts toward the test timeout. If setup is slow, the test itself may time out even though its assertions are fast.
Example
await expect(editor).toBeVisible();
// justified: report generation can be slow
await expect(downloadBtn).toBeEnabled({ timeout: 30_000 });
- will use the default timeout
Tables/maps/visualizations can appear before data is rendered. Prefer waiting on a component-specific “loaded” signal rather than global indicators like the Kibana chrome spinner (our data shows they are unreliable for confirming that a particular component has finished rendering).
Do not rely on helpers that only wait for a global “loading” indicator to disappear. Each view should have an explicit readiness wait (or removal of such helpers in favor of those waits).
Example
In source code, use a dynamic data-test-subj:
<EuiBasicTable
data-test-subj={`myTable-${isLoading ? 'loading' : 'loaded'}`}
loading={isLoading}
items={items}
columns={columns}
/>
In tests, wait for the loaded state:
await expect(page.testSubj.locator('myTable-loaded')).toBeVisible();
For Kibana Maps, data-render-complete="true" is often the right “ready” signal.
Prefer existing page objects (and their methods) over rebuilding EUI interactions in test files.
- Prefer
readonlylocator fields assigned in the constructor for stable selectors, and methods for parameterized locators, multi-step actions, or flows. Thin getter-only methods for every field add noise; match the patterns used by built-in page objects (for exampleDashboardApp).
Example
await pageObjects.datePicker.setAbsoluteRange({
from: 'Sep 19, 2015 @ 06:31:44.000',
to: 'Sep 23, 2015 @ 18:31:44.000',
});
Page objects should focus on real UI interaction. Put HTTP mocks, interceptors, and similar setup in dedicated fixtures (for example fixtures/mocks.ts) so tests and reviewers can find them in one place.
Create methods for repeated flows (and make them wait for readiness).
Example
async openNewDashboard() {
await this.page.testSubj.click('newItemButton');
await this.page.testSubj.waitForSelector('emptyDashboardWidget', { state: 'visible' });
}
Playwright creates a fresh browser context for each test, so there is no cached state to work around. Both page object methods and test code should be explicit about the action they perform, not defensive about the current state. Conditional flows (like "if modal is open, close it first") hide bugs, waste time, and make failures harder to understand.
Examples
❌ Don’t: add conditional logic to handle unknown state:
async switchToEditMode() {
const isViewMode = await this.page.testSubj.locator('dashboardViewMode').isVisible();
if (isViewMode) {
await this.page.testSubj.click('dashboardEditMode');
}
}
✔️ Do: make the action explicit, since the caller knows the expected state:
async openEditMode() {
await this.page.testSubj.click('dashboardEditMode');
await this.page.testSubj.waitForSelector('dashboardIsEditing', { state: 'visible' });
}
Prefer explicit expect() in the test file so reviewers can see intent and failure modes. Also prefer expect() over manual boolean checks, as Playwright’s error output includes the locator, call log, and a clear message, which if/throw patterns lose.
In page objects, avoid expect(). Use waitForSelector / visibility waits to synchronize after navigation or actions (for example wait for a header to be visible). Assertions belong in specs.
Examples
❌ Don’t: hide assertions inside page objects:
// inside page object
async createIndexAndVerify(name: string) {
await this.page.testSubj.click('saveButton');
await expect(this.page.testSubj.locator('indicesTable')).toContainText(name);
}
✔️ Do: keep assertions in the test file:
await pageObjects.indexManagement.clickCreateIndexSaveButton();
await expect(page.testSubj.locator('indicesTable')).toContainText(testIndexName);
If you must interact with EUI internals, use wrappers from Scout to keep that complexity out of tests.
Example
import { EuiComboBoxWrapper, ScoutPage } from '@kbn/scout';
export class StreamsAppPage {
public readonly fieldComboBox: EuiComboBoxWrapper;
constructor(private readonly page: ScoutPage) {
this.fieldComboBox = new EuiComboBoxWrapper(this.page, 'fieldSelectorComboBox');
}
async selectField(value: string) {
await this.fieldComboBox.selectSingleOption(value);
}
}
Scout supports automated accessibility (a11y) scanning via page.checkA11y. Add checks at high-value points in your UI tests (landing pages, modals, flyouts, wizard steps) rather than on every interaction.
Example
const { violations } = await page.checkA11y({ include: ['[data-test-subj="myPanel"]'] });
expect(violations).toHaveLength(0);
For the full guide (scoping, exclusions, handling pre-existing violations), see Accessibility testing.
If a page has onboarding/getting-started state, set localStorage before navigation.
Example
test.beforeEach(async ({ page, browserAuth, pageObjects }) => {
await browserAuth.loginAsViewer();
await page.addInitScript(() => {
window.localStorage.setItem('gettingStartedVisited', 'true');
});
await pageObjects.homepage.goto();
});
If the same custom role appears in many specs, extract it into a browserAuth fixture extension instead of repeating the role descriptor everywhere. Tests then read like intent.
Example
// in your plugin's fixtures/index.ts
await use({
...browserAuth,
loginAsPlatformEngineer: () =>
browserAuth.loginWithCustomRole('platform_engineer', roleDescriptor),
});
// in specs
await browserAuth.loginAsPlatformEngineer();
For setup details, see Reuse role helpers.
- General best practices — apply to both UI and API tests
- Write UI tests
- Browser authentication
- Page objects
- Accessibility (a11y) checks
- Parallelism