| name | ui-test |
| description | Create a Playwright UI test for browser-based testing. Use when testing HTMX interactions, form submissions, page navigation, or JavaScript behavior in the browser. |
Create a Playwright-based UI test for the Epistola Suite.
Input: What user interaction or behavior to test.
Decision Points
Ask the user (if not already specified):
- What page or feature is being tested?
- What user interactions should be verified?
- What HTMX behavior should be tested?
- Are there any prerequisite data setups needed?
Test Structure
Create in apps/epistola/src/test/kotlin/app/epistola/suite/ui/<FeatureName>UiTest.kt.
Canonical example: VariantCardUiTest.kt — apps/epistola/src/test/kotlin/app/epistola/suite/ui/VariantCardUiTest.kt
Inheritance Chain
BasePlaywrightTest → BaseIntegrationTest
BaseIntegrationTest provides: Spring Boot @SpringBootTest(RANDOM_PORT), Testcontainers DB, fixture {}, withMediator {}, scenario {}, createTenant()
BasePlaywrightTest adds: page (Playwright Page), baseUrl(), headless Chromium lifecycle
Tag: @Tag("ui") is inherited from BasePlaywrightTest — do NOT add it manually.
Basic Test Pattern
class FeatureUiTest : BasePlaywrightTest() {
@Test
fun `user can create a variant via HTMX form`() {
val (tenant, template) = withMediator {
val tenant = createTenant("UI Test Tenant")
val template = CreateDocumentTemplate(
id = TestIdHelpers.nextTemplateId(),
tenantId = tenant.id,
name = "Test Template",
).execute()
tenant to template
}
page.navigate("${baseUrl()}/tenants/${tenant.id}/templates/${template.id}")
page.locator("button:has-text('New Variant')").click()
page.locator("#slug").fill("english")
page.locator("form button[type='submit']:has-text('Create Variant')").click()
page.waitForSelector(".variant-card[data-variant-id='english']")
assertThat(page.locator(".variant-card")).hasCount(2)
}
}
Data Setup with withMediator { }
Important: Helper methods must be called inside withMediator { } at the call site, not defined as separate methods that call .execute() independently.
private fun createTestData(): Pair<Tenant, DocumentTemplate> = withMediator {
val tenant = createTenant("Test")
val template = CreateDocumentTemplate(
id = TestIdHelpers.nextTemplateId(),
tenantId = tenant.id,
name = "Test",
).execute()
tenant to template
}
TestIdHelpers
Generate unique IDs for test data:
import app.epistola.suite.common.TestIdHelpers
TestIdHelpers.nextTemplateId()
TestIdHelpers.nextVariantId()
TestIdHelpers.nextEnvironmentId()
TestIdHelpers.nextAttributeId()
Playwright Patterns
Locators (prefer user-visible selectors)
page.locator("button:has-text('New Variant')")
page.locator("#slug")
page.locator(".variant-card")
page.locator("[data-variant-id='some-id']")
page.locator("select[data-filter-key='language']")
page.locator(".variant-card:not(.variant-card-default)")
page.locator("form button[type='submit']")
Waiting for HTMX
page.waitForSelector(".variant-card[data-variant-id='new-id']")
page.waitForFunction("document.querySelectorAll('.variant-card').length === 2")
Assertions (Playwright built-in)
import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat
assertThat(page.locator(".element")).hasCount(2)
assertThat(page.locator(".element")).hasText("Expected")
assertThat(page.locator(".element")).isVisible()
assertThat(page.locator(".element")).hasClass(Pattern.compile(".*some-class.*"))
Form Interaction
page.locator("#field-id").fill("value")
page.locator("select#dropdown").selectOption("option-value")
page.locator("input[type='checkbox']").check()
page.locator("form button[type='submit']").click()
Confirm Dialogs
The codebase uses openConfirmDialog() (custom HTML dialog), not browser confirm():
page.locator("button[title='Delete variant']").first().click()
page.waitForSelector("#confirm-dialog[open]")
page.locator("#confirm-dialog button.ep-btn-destructive").click()
page.waitForSelector("#confirm-dialog:not([open])")
For the template danger zone delete (which uses native confirm()):
page.onDialog { dialog -> dialog.accept() }
page.locator("button.ep-btn-destructive:has-text('Delete Template')").click()
Run Commands
./gradlew uiTest
./gradlew uiTest --tests 'FeatureUiTest'
Checklist
Gotchas
- UI tests require Docker (Testcontainers for the database)
- Tests use headless Chromium (
setHeadless(true))
- Each test gets a fresh
page instance and clean DB state
- Use
TestIdHelpers for unique IDs — avoids collisions between tests
- Prefer asserting visible UI state over checking data in the database
- Keep tests focused on user interactions, not implementation details
withMediator { } binds the mediator context — all .execute() / .query() calls must be inside it
- The
pnpm build must have been run before UI tests (frontend assets need to exist)