redis/e2e-testing
>- 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.
npx skills add https://github.com/redis/RedisInsight --skill e2e-testing
All E2E tests are in tests/e2e-playwright/. This is a standalone package - no imports from redisinsight/ui/ or redisinsight/api/.
Always refer to tests/e2e-playwright/TEST_PLAN.md for:
After implementing tests, update TEST_PLAN.md to mark tests as ✅.
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
The folder a test lives in determines its execution mode. Each browser platform has a parallel project and a serial project:
| Project | Folder | Parallelism | Use Case |
|---------------------|-------------------|-------------|----------|
| chromium-parallel | tests/parallel/ | Parallel (4 workers) | Standard chromium tests |
| chromium-serial | tests/serial/ | Serial (1 worker) | Sequential chromium tests |
| electron-parallel | tests/parallel/ | Serial (1 worker)* | Standard electron tests |
| electron-serial | tests/serial/ | Serial (1 worker) | Sequential electron tests |
\* Electron uses one worker today because there is a single app instance.
For each platform, the serial project depends on the parallel project via dependencies: '<platform>-parallel'] in [playwright.config.ts, so the order is always:
<platform>-parallel runs first (with up to N workers)<platform>-serial runs after, one worker at a timeSerial tests perform destructive operations on the shared RTE Redis (FLUSHDB, broad deleteAllIndexes, dangerous commands) so they can't safely run alongside parallel tests on the same RTE. Electron also can't run its two projects concurrently because the desktop app binds its embedded API on a fixed port (5530).
If serial grows large enough to become a CI bottleneck, the next step is to give serial tests a dedicated Redis instance (or split into a separate CI job) — not to revert the ordering.
# Full platform run (parallel + serial)
npx playwright test --project=chromium-parallel --project=chromium-serial
npx playwright test --project=electron-parallel --project=electron-serial
# Just parallel
npx playwright test --project=chromium-parallel
# Just serial — needs --no-deps, otherwise parallel runs first (it's a
# project dependency). --no-deps also skips browser-setup.
npx playwright test --project=chromium-serial --no-deps
npx playwright test # All projects
tests/serial/Put a test in tests/serial/ when it:
beforeAlltests/ (e.g., tests/my-feature/)playwright.config.ts:{
name: 'my-feature',
testDir: './tests/my-feature',
fullyParallel: false, // or true
workers: 1,
timeout: 120000,
// Optional: different setup
// globalSetup: './my-feature-setup.ts',
}
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)
Page objects are stateless - they don't store database objects. Pass databaseId to navigation methods.
// For database instance pages - extend InstancePage
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');
}
// InstancePage provides: instanceHeader, navigationTabs, bottomPanel
// Plus navigation methods: navigateToBrowser(), openCli(), etc.
async goto(databaseId: string): Promise<void> {
await this.gotoDatabase(databaseId);
await this.navigationTabs.gotoWorkbench();
await this.waitForLoad();
}
}
Break large pages into components:
// pages/feature/FeaturePage.ts
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);
}
}
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
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;
// Setup: Create database once for all tests
test.beforeAll(async ({ apiHelper }) => {
database = await apiHelper.createDatabase({
name: 'test-feature-db',
host: standaloneConfig.host,
port: standaloneConfig.port,
});
});
// Teardown: Clean up database after all tests
test.afterAll(async ({ apiHelper }) => {
await apiHelper.deleteDatabase(database.id);
});
test.describe('Sub-feature', () => {
// Navigation: Pass databaseId to goto() - page is a fixture
test.beforeEach(async ({ featurePage }) => {
await featurePage.goto(database.id);
});
// Tests receive page fixtures they need
test('should do something', async ({ featurePage }) => {
await featurePage.doAction();
await expect(featurePage.result).toBeVisible();
});
// Tests that need both page and apiHelper
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');
});
});
});
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)// ❌ BAD: Storing database in page object
const browserPage = createBrowserPage(database); // OLD pattern - don't use
// ✅ GOOD: Pass databaseId to goto()
await browserPage.goto(database.id);
// ❌ BAD: Using page fixture without declaring it in test signature
test('should work', async () => {
await browserPage.doSomething(); // browserPage is undefined!
});
// ✅ GOOD: Declare fixtures in test signature
test('should work', async ({ browserPage }) => {
await browserPage.doSomething();
});
// ❌ BAD: Navigation inside each test
test('should work', async ({ browserPage }) => {
await browserPage.goto(database.id); // Should be in beforeEach
// ...
});
// ❌ BAD: Using test.describe.serial when not needed
test.describe.serial('Feature', () => { // Use regular describe unless tests truly depend on each other
// ...
});
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,
}));
// Usage in tests
const config = ConfigFactory.build();
const config = ConfigFactory.build({ name: 'custom-name' });
Always prefix test data with test- for easy cleanup:
// In apiHelper
async deleteTestData(): Promise<number> {
return this.deleteByPattern(new RegExp(`^${TEST_PREFIX}`));
}
// fixtures/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();
},
});
Before writing tests, ALWAYS use Playwright MCP to explore the UI:
data-testid attributes used in the applicationgetByRole()browser_navigate_Playwright to target URLbrowser_snapshot_Playwright to see element treebrowser_click_Playwright to trigger dialogs, dropdowns, etc.browser_wait_for_Playwright for dynamic contentTEST_PLAN.md under the feature sectiondata-testid attributes → use with page.getByTestId()page.getByRole(){ name: 'text' } optionpage.getByPlaceholder()page.getByText()After exploring, use discovered patterns directly in Page Object locators:
// Use data-testid when available
this.addButton = page.getByTestId('btn-add-key');
// Use role + name for accessible elements
this.submitButton = page.getByRole('button', { name: 'Submit' });
// Use placeholder for form fields
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.
browserPage.goto(), workbenchPage.goto())data-testid attributes for stable selectorsgetByRole, getByLabel for accessible elementswaitFor({ state: 'visible' })afterEachpage.goto() directly - tests must work in both browser and ElectronwaitForTimeout)redisinsight/ui/ or redisinsight/api/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.
BasePage provides only fundamental navigation:
await this.gotoHome(); // Click Redis logo → databases list
await this.gotoDatabase(dbId); // Click database → Browser page (default)
Each page owns its navigation via its goto() method:
await settingsPage.goto(); // Settings page
await browserPage.goto(dbId); // Browser page for database
await workbenchPage.goto(dbId); // Workbench page for database
await analyticsPage.goto(dbId); // Analytics page for database
await pubSubPage.goto(dbId); // Pub/Sub page for database
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();
// Use Page Object's goto() method in beforeEach
test.beforeEach(async ({ browserPage }) => {
await browserPage.goto(database.id); // Navigates and waits for page load
});
// Switch tabs when already connected
await browserPage.navigationTabs.gotoWorkbench();
// NEVER do this - fails in Electron
await page.goto(`/${database.id}/browser`);
await page.goto('/settings');
await page.goto('/');
Run these commands from the E2E package directory:
cd tests/e2e-playwright
npx playwright test # All Playwright projects
npx playwright test --project=chromium-parallel # Chromium parallel tests
npx playwright test --project=chromium-serial --no-deps # Chromium serial only (skips parallel + setup)
npx playwright test --project=electron-parallel # Electron parallel tests
npx playwright test --project=electron-serial --no-deps # Electron serial only (skips parallel + setup)
ENV=ci npx playwright test # CI environment
ENV=staging npx playwright test # Staging environment
Always run linter and type checker after making changes:
npm run lint # ESLint check
npm run type-check # TypeScript type check
Both must pass before committing. Common issues:
any types (avoid when possible)Promise<string | null>)Tests should be isolated and not depend on execution order:
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);
});
// Tests can run in parallel - they share the database but don't modify shared state
});
// Only use .serial when tests modify state that subsequent tests depend on
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', ...);
});
test('should create unique item', async ({ apiHelper }) => {
const uniqueName = `test-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
// Use uniqueName for this test's data
});
Follow this naming convention for test and page object paths:
| Feature | Test Path | Page Object Path |
|---------|-----------|------------------|
| Database List | tests/parallel/databases/list/ | pages/databases/ |
| Add Database | tests/parallel/databases/add/ | pages/databases/ |
| Import Database | tests/parallel/databases/import/ | pages/databases/ |
| Browser - Key List | tests/parallel/browser/key-list/ | pages/browser/ |
| Browser - Add Key | tests/parallel/browser/add-key/ | pages/browser/ |
| Browser - Key Details | tests/parallel/browser/key-details/ | pages/browser/ |
| Workbench | tests/parallel/workbench/ | pages/workbench/ |
| CLI | tests/parallel/cli/ | pages/cli/ |
| Pub/Sub | tests/parallel/pubsub/ | pages/pubsub/ |
| Slow Log | tests/parallel/analytics/slow-log/ | pages/analytics/ |
| DB Analysis | tests/parallel/analytics/analysis/ | pages/analytics/ |
| Settings | tests/parallel/settings/ | pages/settings/ |
| Navigation | tests/parallel/navigation/ | pages/navigation/ |
| Auto-Update | tests/auto-update/ | pages/ (shared) |
| Deep Links | tests/electron/deep-links/ | pages/ (shared) |
Note: Most tests go in tests/parallel/. Only use other project folders for tests with special requirements (serial execution, different setup, etc.).
Take redis/e2e-testing from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.
The instructions reference npx.
Without those the skill loads but fails at the first command.