Test suites
A test suite is a reusable function that registers a group of it() blocks on the current
test runner context. Lion writes its own component tests as suites so that a Subclasser (a team
building a design system on top of Lion) can run the exact same behavioural guarantees against
its own elements, instead of copying our test/ folders and re-asserting them by hand.
Contract
Every exported runner follows the same shape:
// call it at describe level, never inside an it(); it returns nothing
describe('my-input', () => {
// `host` is the element under test; the other keys are the roles the suite renders
runInputSuite({ classes: { host: MyInput, field: MyField } });
});
-
A suite holds the whole test file it came from. Moving a test file into a suite means moving all of its element tests, not only the ones that happened to be extracted the first time. A test that stays behind in
test/is a guarantee the Subclasser silently does not get. Unit tests of plain functions (utils/,validate/,formatters,parsers,validators) stay intest/on purpose: they describe no element. -
A runner registers tests, it does not run them. It must be called while the runner is collecting tests (at module level or inside
describe()), never inside anit(). The return value is alwaysundefined. -
The caller hands the suite the elements it should run against. Every runner takes one option object and destructures it, with Lion’s own element declared as the default on the role itself:
// inside every runner: the default for a role is Lion's own element classes: { host = LionAccordion, invoker = 'h2', content = 'div' } = {},That is what keeps a call site short - you pass the roles you replaced and leave the rest alone:
runAccordionSuite({ classes: { host: MyAccordion } }); // the slots are still `h2` and `div` runCollapsibleSuite({ classes: { host: MyCollapsible, invoker: 'button' } });hostis the element under test, the same key in every runner; the other keys are the roles the suite renders - the component’s public slots plus the elements it assembles - named after Lion’s tag names without thelion-prefix (invoker,content,option,tabs,fieldset, …). A role takes a class or, for a native element, its tag. For a class, the registered tag is used when it has one (that is what keeps a scoped child working); a subclass element is defined on the fly when it does not. A suite is never host-only: every element its fixtures render has a role. Suites import constructors, notdefineentry points, and resolve their fixture tags from those constructors. They do not callcustomElements.defineor register fixed global fixture names; useresolveSuiteTag,defineCEor constructor-based scoped fixtures instead. -
Every element a suite renders has a role. A component’s public slots are roles (
runAccordionSuiteandrunCollapsibleSuite:invoker,content), a composite lists what it renders (runFormSuite:checkbox-group,radio-group,fieldset,checkbox,listbox,option,radio), and a suite that assembles its own subject names that subjecthosttoo and names what it assembles (parent,child,portal). The type isSuiteConfig<Roles, Options>from@lion/ui/types/test-suites.js- one type for every runner, narrowed to the roles and options that runner has. Everything the caller leaves out keeps Lion’s own element for that role, which is the default inside the runner, not something a call site passes. -
The caller supplies the test framework. Suites use the ambient Mocha globals (
describe/it/beforeEach) and@open-wc/testingfixtures; your test file needs the same module graph as Lion’s owntest/files (seeweb-test-runner.config.mjs, including the scoped-custom-element-registry polyfill for the scoped registry setup). -
classesis the surface, and every suite is prefilled with it. A runner starts from Lion’s own elements for the roles it resolves, so calling it without arguments runs the guarantees against Lion; an extension layer hands its own elements for the roles it replaces. -
A suite asserts through its page object, not through Lion’s markup. Page-object constructors are passed through
pageObjects, keyed by role just likeclasses.runDatepickerSuitedefaultspageObjects.hosttoDatepickerInputObjectandrunCalendarSuitedefaults it toCalendarObject(both from the*-test-helpers.jsentry points), and every node reference and user action goes through them — the suite itself never queries.calendar-overlay…,.calendar__previous-buttonor#close-button. An extension layer whose markup differs passes its own subclass overriding the node getters (overlayRootEl,overlayHeaderEl,overlayHeadingEl,overlayCloseButtonEl,overlayEl;previousYearButtonEland friends for the calendar) instead of the suite learning about that markup. The datepicker’s inner calendar comes from the host page object’scalendarEltoo — that getter reads the element’s own_calendarNode, so no tag is needed there either. The combobox suite likewise reads the options container fromgetComboboxMembers(host)._listboxNode, so a renamed scoped options element needs no extra selector configuration.runPaginationSuitereads its previous, next, page and ellipsis nodes through the exportedPaginationObject; a subclass with different navigation markup can passpageObjects: { host: MyPaginationObject }overriding those getters. Click assertions wait for the requested page and its render to complete, so deferred navigation handlers are supported without changing the expected pages or event counts.runPaginationSuite({ classes: { host: MyPagination }, pageObjects: { host: MyPaginationObject }, }); runCalendarSuite({ pageObjects: { host: MyCalendarObject } }); runDatepickerSuite({ pageObjects: { host: MyDatepickerInputObject } }); -
Accessibility is Lion’s; styling and markup are the extension layer’s. So the accessible name is asserted, but not the attribute it lives on: the suite reads it from
title,aria-labelor alabelattribute, and the copy stays Lion’s (localized messages). AssertinggetAttribute('title') === 'Close'forces one mechanism on every design system and is what made 3 of my-ui’s accessibility tests fail although its invoker is perfectly accessible. -
Fixtures set what they assert. Assertions about a week layout (weekday headers, arrow-key row navigation, previous/next-month day counts) put
.firstDayOfWeek=${0}on the fixture instead of relying on the element’s default — a design system that starts the week on Monday is not wrong, andfirstDayOfWeekis property-only (attribute: false), so the attribute form is ignored. Where the grid only exists after an overlay opened, pin it on the rendered element (openedCalendarEl.firstDayOfWeek = 0) — before opening, the property lands on an element that gets re-rendered. The same rule covers the invoker’s accessible name: that fixture carrieslabel="date", because the assertion is that the invoker names the field. The collapsible fixture likewise sets its asserted 32px content height explicitly, rather than depending on the host’s width to wrap text into two lines. Switch event assertions await the host update before the inner button update, allowing deferred host-to-button synchronization. The combobox runner acceptsconfigureOption(option, label)for option classes that need additional properties. It runs during every option’s template render, beforechoiceValueis assigned and before connection, including dynamic additions and updates.labelcontains the fixture’s display label (or its explicit.labelin label-override tests). The suite retains its original light-DOM content, checked state and other bindings; the hook must not change behavior.runComboboxSuite({ classes: { host: MyCombobox, option: MyOption }, configureOption(option, label) { if (option instanceof MyOption) { Object.assign(option, { label }); } }, });
Public entry points
Only @lion/ui/exports/* is importable, so suites are reached through the suite entry points.
A runner belongs in an entry point only if its subject is itself public: exposing
runXSuite for a mixin or class that cannot be imported from @lion/ui/exports/* promises a
contract the Subclasser cannot use, because running a mixin suite means extending that same mixin.
A non-exported mixin keeps its runner inside the package next to its own tests.
| entry point | runners |
|---|---|
@lion/ui/test-suites/accordion.js | runAccordionSuite |
@lion/ui/test-suites/button.js | runButtonSuite, runButtonResetSuite, runButtonSubmitSuite |
@lion/ui/test-suites/calendar.js | runCalendarSuite |
@lion/ui/test-suites/checkbox-group.js | runCheckboxIndeterminateSuite |
@lion/ui/test-suites/collapsible.js | runCollapsibleSuite |
@lion/ui/test-suites/combobox.js | runComboboxSuite |
@lion/ui/test-suites/dialog.js | runDialogSuite |
@lion/ui/test-suites/drawer.js | runDrawerSuite |
@lion/ui/test-suites/form-core.js | runFormatMixinSuite, runInteractionStateMixinSuite, runNativeTextFieldMixinSuite, runValidateMixinSuite, runValidateMixinFeedbackPart, runRegistrationSuite, runChoiceGroupMixinSuite, runChoiceInputMixinSuite, runFormGroupMixinSuite, runFormGroupMixinInputSuite |
@lion/ui/test-suites/form.js | runFormSuite |
@lion/ui/test-suites/icon.js | runIconSuite |
@lion/ui/test-suites/input-amount-dropdown.js | runInputAmountDropdownSuite |
@lion/ui/test-suites/input-amount.js | runInputAmountSuite |
@lion/ui/test-suites/input-date.js | runInputDateSuite |
@lion/ui/test-suites/input-datepicker.js | runDatepickerSuite |
@lion/ui/test-suites/input-email.js | runInputEmailSuite |
@lion/ui/test-suites/input-file.js | runInputFileSuite |
@lion/ui/test-suites/input-iban.js | runInputIbanSuite |
@lion/ui/test-suites/input-range.js | runInputRangeSuite |
@lion/ui/test-suites/input-stepper.js | runInputStepperSuites |
@lion/ui/test-suites/input-tel-dropdown.js | runInputTelDropdownSuite, runInputTelDropdownParenthesesSuite |
@lion/ui/test-suites/input-tel.js | runInputTelSuite |
@lion/ui/test-suites/input.js | runInputSuite |
@lion/ui/test-suites/listbox.js | runListboxMixinSuite |
@lion/ui/test-suites/menu.js | runLionMenuSuite, runLionMenuInteractionsSuite |
@lion/ui/test-suites/overlays.js | runOverlayMixinSuite, runWithHoverInteractionSuite |
@lion/ui/test-suites/pagination.js | runPaginationSuite |
@lion/ui/test-suites/progress-indicator.js | runProgressIndicatorSuite |
@lion/ui/test-suites/radio-group.js | runRadioSuite, runRadioGroupSuite |
@lion/ui/test-suites/select-rich.js | runSelectRichSuite, runSelectRichInteractionSuite, runSelectInvokerSuite |
@lion/ui/test-suites/select.js | runSelectSuite |
@lion/ui/test-suites/steps.js | runStepSuite, runStepsSuite |
@lion/ui/test-suites/switch.js | runSwitchSuite |
@lion/ui/test-suites/tabs.js | runTabsSuite |
@lion/ui/test-suites/textarea.js | runTextareaSuite |
@lion/ui/test-suites/tooltip.js | runTooltipSuite |
The longer-standing *-test-suites.js / *-suites.js entry points remain as deprecated aliases.
New code should import from @lion/ui/test-suites/<component>.js.
The fixtures a suite needs from the caller are the exported *-test-helpers.js entry points
(@lion/ui/form-core-test-helpers.js, @lion/ui/listbox-test-helpers.js,
@lion/ui/input-test-helpers.js, @lion/ui/pagination-test-helpers.js, …). Helpers that live in a component’s internal
test-helpers/ folder are private to Lion and are not part of this contract.
Not yet covered
runValidateMixinFeedbackPart,runRegistrationSuiteand theform-grouprunners build their element from Lion mixins; the map can only replace the outermost element.runMoreButtonMenuMixinSuiteremains internal because its mixin is not part of@lion/ui/menu.js.runInteractiveListMixinSuiteandrunMultiLevelListMixinSuiteare exported from@lion/ui/test-suites/menu.jswhileInteractiveListMixinandMultiLevelListMixinare not re-exported from@lion/ui/menu.js, so a Subclasser cannot extend the mixin those suites describe. Either the mixins become public or these two runners move back into the package.runMoreButtonMenuMixinSuiteandrunCustomChoiceGroupMixinSuiteare already internal, which is the right shape for a mixin that is not exported.- Replacing a child element (
classes: { option: MyOption }) only works when your own element registers that child in its own scope — its markup and lookups have to know the tag. A plain Lion subclass does not, which is why the in-repo probe passes the host only and the extension layer that motivated the feature exercises the child role in its own repository. runOverlayMixinSuiteandrunWithHoverInteractionSuiteare legacy mixin suites and still take a prebuilt element as required input.fieldsethas no suite of its own because it has no behaviour of its own: its whole test file runs the two form-group runners against it.- Unit tests of plain functions are deliberately not suites:
IconManager, the file validators ofinput-file, and theformatters/parsers/validatorsfiles ofinput-amountandinput-ibantest exported functions, not elements. input-amount’s “override the currency label slot” test registers a fixedmy-input-amounttag of its own; a suite runner forinput-amounttherefore cannot use that tag name for its own binding.