Search

Find components, guides, and documentation

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 } });
});
  1. 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 in test/ on purpose: they describe no element.

  2. 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 an it(). The return value is always undefined.

  3. 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' } });

    host is 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 the lion- 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, not define entry points, and resolve their fixture tags from those constructors. They do not call customElements.define or register fixed global fixture names; use resolveSuiteTag, defineCE or constructor-based scoped fixtures instead.

  4. Every element a suite renders has a role. A component’s public slots are roles (runAccordionSuite and runCollapsibleSuite: 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 subject host too and names what it assembles (parent, child, portal). The type is SuiteConfig<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.

  5. The caller supplies the test framework. Suites use the ambient Mocha globals (describe/it/beforeEach) and @open-wc/testing fixtures; your test file needs the same module graph as Lion’s own test/ files (see web-test-runner.config.mjs, including the scoped-custom-element-registry polyfill for the scoped registry setup).

  6. classes is 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.

  7. A suite asserts through its page object, not through Lion’s markup. Page-object constructors are passed through pageObjects, keyed by role just like classes. runDatepickerSuite defaults pageObjects.host to DatepickerInputObject and runCalendarSuite defaults it to CalendarObject (both from the *-test-helpers.js entry points), and every node reference and user action goes through them — the suite itself never queries .calendar-overlay…, .calendar__previous-button or #close-button. An extension layer whose markup differs passes its own subclass overriding the node getters (overlayRootEl, overlayHeaderEl, overlayHeadingEl, overlayCloseButtonEl, overlayEl; previousYearButtonEl and friends for the calendar) instead of the suite learning about that markup. The datepicker’s inner calendar comes from the host page object’s calendarEl too — that getter reads the element’s own _calendarNode, so no tag is needed there either. The combobox suite likewise reads the options container from getComboboxMembers(host)._listboxNode, so a renamed scoped options element needs no extra selector configuration. runPaginationSuite reads its previous, next, page and ellipsis nodes through the exported PaginationObject; a subclass with different navigation markup can pass pageObjects: { 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 } });
  8. 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-label or a label attribute, and the copy stays Lion’s (localized messages). Asserting getAttribute('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.

  9. 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, and firstDayOfWeek is 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 carries label="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 accepts configureOption(option, label) for option classes that need additional properties. It runs during every option’s template render, before choiceValue is assigned and before connection, including dynamic additions and updates. label contains the fixture’s display label (or its explicit .label in 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 pointrunners
@lion/ui/test-suites/accordion.jsrunAccordionSuite
@lion/ui/test-suites/button.jsrunButtonSuite, runButtonResetSuite, runButtonSubmitSuite
@lion/ui/test-suites/calendar.jsrunCalendarSuite
@lion/ui/test-suites/checkbox-group.jsrunCheckboxIndeterminateSuite
@lion/ui/test-suites/collapsible.jsrunCollapsibleSuite
@lion/ui/test-suites/combobox.jsrunComboboxSuite
@lion/ui/test-suites/dialog.jsrunDialogSuite
@lion/ui/test-suites/drawer.jsrunDrawerSuite
@lion/ui/test-suites/form-core.jsrunFormatMixinSuite, runInteractionStateMixinSuite, runNativeTextFieldMixinSuite, runValidateMixinSuite, runValidateMixinFeedbackPart, runRegistrationSuite, runChoiceGroupMixinSuite, runChoiceInputMixinSuite, runFormGroupMixinSuite, runFormGroupMixinInputSuite
@lion/ui/test-suites/form.jsrunFormSuite
@lion/ui/test-suites/icon.jsrunIconSuite
@lion/ui/test-suites/input-amount-dropdown.jsrunInputAmountDropdownSuite
@lion/ui/test-suites/input-amount.jsrunInputAmountSuite
@lion/ui/test-suites/input-date.jsrunInputDateSuite
@lion/ui/test-suites/input-datepicker.jsrunDatepickerSuite
@lion/ui/test-suites/input-email.jsrunInputEmailSuite
@lion/ui/test-suites/input-file.jsrunInputFileSuite
@lion/ui/test-suites/input-iban.jsrunInputIbanSuite
@lion/ui/test-suites/input-range.jsrunInputRangeSuite
@lion/ui/test-suites/input-stepper.jsrunInputStepperSuites
@lion/ui/test-suites/input-tel-dropdown.jsrunInputTelDropdownSuite, runInputTelDropdownParenthesesSuite
@lion/ui/test-suites/input-tel.jsrunInputTelSuite
@lion/ui/test-suites/input.jsrunInputSuite
@lion/ui/test-suites/listbox.jsrunListboxMixinSuite
@lion/ui/test-suites/menu.jsrunLionMenuSuite, runLionMenuInteractionsSuite
@lion/ui/test-suites/overlays.jsrunOverlayMixinSuite, runWithHoverInteractionSuite
@lion/ui/test-suites/pagination.jsrunPaginationSuite
@lion/ui/test-suites/progress-indicator.jsrunProgressIndicatorSuite
@lion/ui/test-suites/radio-group.jsrunRadioSuite, runRadioGroupSuite
@lion/ui/test-suites/select-rich.jsrunSelectRichSuite, runSelectRichInteractionSuite, runSelectInvokerSuite
@lion/ui/test-suites/select.jsrunSelectSuite
@lion/ui/test-suites/steps.jsrunStepSuite, runStepsSuite
@lion/ui/test-suites/switch.jsrunSwitchSuite
@lion/ui/test-suites/tabs.jsrunTabsSuite
@lion/ui/test-suites/textarea.jsrunTextareaSuite
@lion/ui/test-suites/tooltip.jsrunTooltipSuite

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, runRegistrationSuite and the form-group runners build their element from Lion mixins; the map can only replace the outermost element.
  • runMoreButtonMenuMixinSuite remains internal because its mixin is not part of @lion/ui/menu.js.
  • runInteractiveListMixinSuite and runMultiLevelListMixinSuite are exported from @lion/ui/test-suites/menu.js while InteractiveListMixin and MultiLevelListMixin are 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. runMoreButtonMenuMixinSuite and runCustomChoiceGroupMixinSuite are 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.
  • runOverlayMixinSuite and runWithHoverInteractionSuite are legacy mixin suites and still take a prebuilt element as required input.
  • fieldset has 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 of input-file, and the formatters/parsers/validators files of input-amount and input-iban test exported functions, not elements.
  • input-amount’s “override the currency label slot” test registers a fixed my-input-amount tag of its own; a suite runner for input-amount therefore cannot use that tag name for its own binding.