| name | e2e-testing |
| description | Playwright end-to-end testing standards for RedisInsight: page object models, test structure, fixtures, navigation patterns, and flaky-test prevention. Use when editing files under tests/e2e-playwright/**, writing Playwright tests, adding page objects, or when the user mentions Playwright, E2E tests, page objects, or end-to-end testing. |
E2E Testing Standards (Playwright)
Location
All E2E tests are in tests/e2e-playwright/. This is a standalone package - no imports from redisinsight/ui/ or redisinsight/api/.
Test Plan
Always refer to tests/e2e-playwright/TEST_PLAN.md for:
- Test coverage status (✅ implemented, 🔲 not implemented)
- Feature implementation order
- Test data requirements
After implementing tests, update TEST_PLAN.md to mark tests as ✅.
Project Structure
tests/e2e-playwright/
├── TEST_PLAN.md # Master test plan with coverage status
├── config/ # Configuration (env, databases)
│ └── databases/ # Database configs by type
├── fixtures/ # Playwright fixtures
├── helpers/ # API helpers for setup/teardown
├── pages/ # Page Object Models
│ ├── BasePage.ts # Base class for all pages
│ ├── InstancePage.ts # Base class for database instance pages
│ ├── components/ # Shared components (InstanceHeader, NavigationTabs, BottomPanel)
│ └── {feature}/ # Feature-specific pages (browser/, cli/, etc.)
├── test-data/ # Test data factories
├── tests/ # Test specs organized by project
│ ├── main/ # Default parallel tests
│ │ └── {feature}/
│ │ └── {action}/
│ ├── auto-update/ # Serial tests with special setup
│ └── electron/ # Electron-specific tests
└── types/ # TypeScript types
Playwright Projects
Tests are organized into projects based on execution requirements. Each project can have different parallelism, timeouts, and setup.
| Project | Folder | Parallelism | Use Case |
|---|
main | tests/main/ | Parallel | Standard tests that can run concurrently |
auto-update | tests/auto-update/ | Serial | Tests requiring special setup or causing flakiness |
electron | tests/electron/ | Serial | Electron-specific features (deep links, etc.) |
Running Projects
npx playwright test --project=main
npx playwright test --project=auto-update
npx playwright test
When to Create a New Project
Create a new project folder when tests:
- Require different parallelism settings (serial vs parallel)
- Need different global setup/teardown
- Would cause flakiness when run with other tests
- Require special environment configuration
Adding a New Project
- Create folder under
tests/ (e.g., tests/my-feature/)
- Add project configuration in
playwright.config.ts:
{
name: 'my-feature',
testDir: './tests/my-feature',
fullyParallel: false,
workers: 1,
timeout: 120000,
}
Page Objects
Page Object Hierarchy
BasePage (abstract)
├── DatabasesPage # Databases list page
├── SettingsPage # Settings page
└── InstancePage (abstract) # Base for all database instance pages
├── instanceHeader # Database name, stats, breadcrumb
├── navigationTabs # Browse, Workbench, Analyze, Pub/Sub
├── bottomPanel # CLI, Command Helper, Profiler
└── BrowserPage # Browser-specific (extends InstancePage)
└── WorkbenchPage (future)
└── AnalyzePage (future)
└── PubSubPage (future)
Extend the Appropriate Base Class
- BasePage - For standalone pages (DatabasesPage, SettingsPage)
- InstancePage - For pages within a connected database (BrowserPage, WorkbenchPage, etc.)
Page objects are stateless - they don't store database objects. Pass databaseId to navigation methods.
import { Page, Locator } from '@playwright/test';
import { InstancePage } from '../InstancePage';
export class WorkbenchPage extends InstancePage {
readonly editor: Locator;
constructor(page: Page) {
super(page);
this.editor = page.getByTestId('workbench-editor');
}
async goto(databaseId: string): Promise<void> {
await this.gotoDatabase(databaseId);
await this.navigationTabs.gotoWorkbench();
await this.waitForLoad();
}
}
Component-Based Structure
Break large pages into components:
export class FeaturePage extends InstancePage {
readonly dialog: FeatureDialog;
readonly list: FeatureList;
constructor(page: Page) {
super(page);
this.dialog = new FeatureDialog(page);
this.list = new FeatureList(page);
}
}
Test Structure
File Organization
tests/
├── main/ # Default parallel tests (most tests go here)
│ └── {feature}/ # e.g., databases, browser, workbench
│ └── {action}/ # e.g., add, edit, delete
│ ├── standalone.spec.ts
│ └── cluster.spec.ts
├── auto-update/ # Serial tests with special setup
└── electron/ # Electron-specific tests
Test Setup Pattern
Use simple, explicit setup with clear separation of concerns. Page objects are fixtures - they don't store database state. Pass databaseId to goto() methods.
import { test, expect } from '../../../fixtures/base';
import { standaloneConfig } from '../../../config/databases/standalone';
import { DatabaseInstance } from '../../../types';
test.describe('Feature > Action', () => {
let database: DatabaseInstance;
test.beforeAll(async ({ apiHelper }) => {
database = await apiHelper.createDatabase({
name: 'test-feature-db',
host: standaloneConfig.host,
port: standaloneConfig.port,
});
});
test.afterAll(async ({ apiHelper }) => {
await apiHelper.deleteDatabase(database.id);
});
test.describe('Sub-feature', () => {
test.beforeEach(async ({ featurePage }) => {
await featurePage.goto(database.id);
});
test('should do something', async ({ featurePage }) => {
await featurePage.doAction();
await expect(featurePage.result).toBeVisible();
});
test('should create and verify', async ({ featurePage, apiHelper }) => {
await apiHelper.createKey(database.id, 'test-key', 'value');
await featurePage.refresh();
await expect(featurePage.keyList).toContainText('test-key');
});
});
});
Key Principles
beforeAll - Create database/test data via API (runs once)
afterAll - Clean up database/test data via API (runs once)
beforeEach - Navigate to page via UI using goto(databaseId) (runs before each test)
- Individual tests - Receive page fixtures they need in the signature
- Page objects are stateless - Don't store database objects in pages, pass IDs to methods
Avoid These Anti-Patterns
const browserPage = createBrowserPage(database);
await browserPage.goto(database.id);
test('should work', async () => {
await browserPage.doSomething();
});
test('should work', async ({ browserPage }) => {
await browserPage.doSomething();
});
test('should work', async ({ browserPage }) => {
await browserPage.goto(database.id);
});
test.describe.serial('Feature', () => {
});
Test Data
Use Fishery Factories with Faker
Use the fishery library for test data factories:
import { Factory } from 'fishery';
import { faker } from '@faker-js/faker';
export const TEST_PREFIX = 'test-';
export const ConfigFactory = Factory.define<Config>(() => ({
name: `${TEST_PREFIX}${faker.string.alphanumeric(8)}`,
host: '127.0.0.1',
port: 6379,
}));
const config = ConfigFactory.build();
const config = ConfigFactory.build({ name: 'custom-name' });
Cleanup Pattern
Always prefix test data with test- for easy cleanup:
async deleteTestData(): Promise<number> {
return this.deleteByPattern(new RegExp(`^${TEST_PREFIX}`));
}
Fixtures
Add New Fixtures to base.ts
type Fixtures = {
myPage: MyPage;
apiHelper: ApiHelper;
};
export const test = base.extend<Fixtures>({
myPage: async ({ page }, use) => {
await use(new MyPage(page));
},
apiHelper: async ({}, use) => {
const helper = new ApiHelper();
await use(helper);
await helper.dispose();
},
});
UI Exploration with Playwright MCP
Before writing tests, ALWAYS use Playwright MCP to explore the UI:
Why Explore First?
- Discover actual
data-testid attributes used in the application
- Understand element roles and accessible names for
getByRole()
- See page structure and component hierarchy
- Avoid trial-and-error test writing
Exploration Workflow
- Navigate to the page:
browser_navigate_Playwright to target URL
- Take snapshot:
browser_snapshot_Playwright to see element tree
- Interact with elements:
browser_click_Playwright to trigger dialogs, dropdowns, etc.
- Wait for async content:
browser_wait_for_Playwright for dynamic content
- Document findings: Add discovered UI patterns to
TEST_PLAN.md under the feature section
What to Look For
data-testid attributes → use with page.getByTestId()
- Element roles (button, combobox, grid, treeitem) → use with
page.getByRole()
- Accessible names → use with
{ name: 'text' } option
- Form field placeholders → use with
page.getByPlaceholder()
- Text content patterns → use with
page.getByText()
Use Discovered Patterns in Page Objects
After exploring, use discovered patterns directly in Page Object locators:
this.addButton = page.getByTestId('btn-add-key');
this.submitButton = page.getByRole('button', { name: 'Submit' });
this.searchInput = page.getByPlaceholder('Search...');
Note: Keep TEST_PLAN.md as a simple visual list of test cases. Document UI patterns in Page Object comments if needed.
Best Practices
✅ DO
- Explore UI with Playwright MCP before writing tests
- Use Page Object navigation methods (e.g.,
browserPage.goto(), workbenchPage.goto())
- Use
data-testid attributes for stable selectors
- Use
getByRole, getByLabel for accessible elements
- Wait for elements with
waitFor({ state: 'visible' })
- Clean up test data in
afterEach
- Use API for setup, UI for assertions
- Handle both List view and Tree view in key assertions
❌ DON'T
- NEVER use
page.goto() directly - tests must work in both browser and Electron
- Write tests without exploring the actual UI first
- Use fixed timeouts (
waitForTimeout)
- Use CSS selectors for dynamic content
- Leave test data after tests
- Import from
redisinsight/ui/ or redisinsight/api/
- Hardcode test data (use faker)
- Assume element structure without verification
Navigation (IMPORTANT)
All navigation must use UI-based methods, NOT URL navigation.
Tests must work in both browser mode (http://localhost:8080) and Electron mode (no baseURL). Direct page.goto() calls fail in Electron because there's no baseURL.
Navigation Architecture
BasePage provides only fundamental navigation:
await this.gotoHome();
await this.gotoDatabase(dbId);
Each page owns its navigation via its goto() method:
await settingsPage.goto();
await browserPage.goto(dbId);
await workbenchPage.goto(dbId);
await analyticsPage.goto(dbId);
await pubSubPage.goto(dbId);
NavigationTabs component handles tab switching within a connected database:
await browserPage.navigationTabs.gotoBrowser();
await browserPage.navigationTabs.gotoWorkbench();
await browserPage.navigationTabs.gotoAnalyze();
await browserPage.navigationTabs.gotoPubSub();
✅ Correct Navigation Pattern
test.beforeEach(async ({ browserPage }) => {
await browserPage.goto(database.id);
});
await browserPage.navigationTabs.gotoWorkbench();
❌ Incorrect Navigation Pattern
await page.goto(`/${database.id}/browser`);
await page.goto('/settings');
await page.goto('/');
Running Tests
Run these commands from the E2E package directory:
cd tests/e2e-playwright
npx playwright test
npx playwright test --project=chromium
npx playwright test --project=electron
npx playwright test --project=main
npx playwright test --project=auto-update
ENV=ci npx playwright test
ENV=staging npx playwright test
Code Quality (IMPORTANT)
Always run linter and type checker after making changes:
npm run lint
npm run type-check
Both must pass before committing. Common issues:
- Unused variables/imports
- Missing return types
any types (avoid when possible)
- Null/undefined handling (use proper types like
Promise<string | null>)
Test Isolation (IMPORTANT)
Tests should be isolated and not depend on execution order:
1. Shared Database with beforeAll/afterAll
test.describe('Feature Name', () => {
let database: DatabaseInstance;
test.beforeAll(async ({ apiHelper }) => {
database = await apiHelper.createDatabase({ name: 'test-feature-db', ... });
});
test.afterAll(async ({ apiHelper }) => {
await apiHelper.deleteDatabase(database.id);
});
});
2. Use Serial Only When Tests Truly Depend on Each Other
test.describe.serial('Workflow that modifies state', () => {
test('step 1: create item', ...);
test('step 2: modify item created in step 1', ...);
test('step 3: delete item', ...);
});
3. Unique Test Data Per Test (when needed)
test('should create unique item', async ({ apiHelper }) => {
const uniqueName = `test-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
});
Feature-to-Path Mapping
Follow this naming convention for test and page object paths:
| Feature | Test Path | Page Object Path |
|---|
| Database List | tests/main/databases/list/ | pages/databases/ |
| Add Database | tests/main/databases/add/ | pages/databases/ |
| Import Database | tests/main/databases/import/ | pages/databases/ |
| Browser - Key List | tests/main/browser/key-list/ | pages/browser/ |
| Browser - Add Key | tests/main/browser/add-key/ | pages/browser/ |
| Browser - Key Details | tests/main/browser/key-details/ | pages/browser/ |
| Workbench | tests/main/workbench/ | pages/workbench/ |
| CLI | tests/main/cli/ | pages/cli/ |
| Pub/Sub | tests/main/pubsub/ | pages/pubsub/ |
| Slow Log | tests/main/analytics/slow-log/ | pages/analytics/ |
| DB Analysis | tests/main/analytics/analysis/ | pages/analytics/ |
| Settings | tests/main/settings/ | pages/settings/ |
| Navigation | tests/main/navigation/ | pages/navigation/ |
| Auto-Update | tests/auto-update/ | pages/ (shared) |
| Deep Links | tests/electron/deep-links/ | pages/ (shared) |
Note: Most tests go in tests/main/. Only use other project folders for tests with special requirements (serial execution, different setup, etc.).