Files
Xen-Orchestra-xen-orchestra…/@xen-orchestra/web/docs/tests/testing-components.md
2026-11-09 09:57:02 +01:00

17 KiB

Testing components

Components run in the same unit project as everything else: @vue/test-utils mount on happy-dom, no browser needed. Mount with createGlobalTestConfig() so the real vue-i18n instance, the app form validation messages (RegleVuePlugin with formValidationConfig, as in main.ts) and a fresh Pinia are installed, and give the repeated mount a helper (see Setup):

import VmSystemGraphics from '@/modules/vm/components/system/VmSystemGraphics.vue'
import { createVm } from '@/test/create-vm.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'

function mountGraphics(vm = createVm()) {
  return mount(VmSystemGraphics, {
    props: { vm },
    global: createGlobalTestConfig(),
  })
}

it('shows VGA as enabled when the VM uses the "std" adapter', () => {
  const wrapper = mountGraphics(createVm({ vga: 'std' }))

  expect(wrapper.text()).toContain(t('enabled'))
})

The test file sits next to the component, named <ComponentName>.unit.test.ts (no .vue). Group with describe only when the component has several distinct behaviours to separate; a small component reads better as flat its.

Keep components thin

A component should not carry much complex logic. Non-trivial computation (chart series, aggregation, formatting rules) belongs in a *.util.ts or a composable, tested directly — that is cheaper and covers more branches than mounting. A component that is awkward to test is telling you to extract, not to write a heavier component test, and never to skip the test.

What is left to assert at the component level is what it renders: labels, formatted values, conditional rows, empty states.

Extracting from a tested component

The extraction is a refactor, so the tests come first and are what proves it changed nothing:

  1. Write the tests against the component as it stands, and run them green.
  2. Extract.
  3. Re-run those tests unchanged.
  4. Move the ones the extraction made redundant down next to the new util/composable, and delete them from the component test.

Step 3 is the gate. A test that has to be edited to pass is reporting a behaviour change — say so, do not absorb it into the diff. Tests written only after an extraction describe the new code and cannot catch what the extraction broke, which is the whole risk being taken.

Step 4 is selective. A test that only exercised the logic now living in the util moves; a test asserting something the component alone does — rendering, conditional rows, empty states, feeding the util's output into the template — stays.

Assert what the user reads, not the markup

wrapper.text() concatenates the whole subtree with no separators, so '8 B' and 'Disabled' run together. It is fine for toContain on a single component, but it cannot express "this label has this value". Query instead:

// every card title, in order
expect(wrapper.findAll('.ui-title').map(title => title.text())).toEqual([t('graphics-display')])

// a key/value row: the markup is semantic, so label and value are addressable
const rows = wrapper.findAll('.vts-tabular-key-value-row')
const labelledValues = Object.fromEntries(rows.map(row => [row.get('dt.label').text(), row.get('dd.value').text()]))

expect(labelledValues).toEqual({ [t('vga')]: t('disabled'), [t('video-ram')]: '8 B' })

Prefer this shape: one assertion covering every row a user sees, and it fails loudly when a value lands under the wrong label. Assert against class names and semantic elements, never a positional chain of child indexes.

The class must exist for the component's own CSS: never add a class only for a test, and remove one whose CSS is gone. Without a styled class, query the component (getComponent), a styled class of a child component, or the text the user reads.

src/test/find-rendered-values.ts does exactly that reduction, so a card test does not re-roll it:

Helper Reads
findLabelledValues every VtsTabularKeyValueRow / VtsKeyValueRow of a card
findCardLabelledValues every VtsCardRowKeyValue of a side-panel card
findCopiedValues what each copyable key/value row copies, keyed by label
findCardCopiedValues what each copyable VtsCardRowKeyValue copies, in order
expect(findLabelledValues(wrapper)).toEqual({ [t('vga')]: t('disabled'), [t('video-ram')]: '8 B' })

A row offers a copy button through its copy prop (copies its value) or its copy-value prop (copies that string). findCopiedValues leaves rows without one out, so a toEqual pins both which rows are copyable and what they copy — including a row that must offer no copy because its value is empty:

expect(findCopiedValues(wrapper)).toEqual({ [t('ip-address')]: '10.0.0.1', [t('uuid')]: 'vm-42' })

Both collapse rows that share a label into one entry — a list repeating the same label (an address list labelling only its first row) is queried directly instead:

expect(wrapper.findAll('.vts-card-row-key-value').map(row => row.get('.value').text())).toEqual([
  '10.0.0.1',
  '10.0.0.2',
])

When a card mixes deterministic values with environment-derived ones (a relative start time, a formatted date), pin the deterministic rows with toMatchObject and cover the composition separately by asserting the list of labels, in order — the formatted values themselves belong in the test of the composable that derives them.

Two more readers live in the same file, for output that is not a label/value pair:

Helper Reads
findTableRows the tbody of a table, as one array of cell texts per row
findTags every UiTag, as its text, in the order the component lists them
expect(findTableRows(wrapper)).toEqual([
  ['XSAPATCH-1', '1.0'],
  ['XSAPATCH-2', '2.0'],
])

findTableRows reads cells rather than each row's whole text for the same reason the helpers above split label from value: row.text() concatenates the columns, so a patch named XSAPATCH-1 at version 1.0 and one named XSAPATCH-11 at version .0 read identically.

Never spell out a translation

A rendered label or a translated value is wording the component does not own, so asserting behaviour means naming the key, not the English string. src/test/i18n.ts exposes the t of the very instance createGlobalTestConfig() installs:

// ✗ rewording the translation breaks the test although the component still picks the same key
expect(findLabelledValues(wrapper)).toEqual({ 'Suspend storage repository': 'None' })

// ✓ asserts the decision: the suspend-SR row, falling back to the "none" wording
expect(findLabelledValues(wrapper)).toEqual({ [t('suspend-storage-repository')]: t('none') })

A mistyped key does not pass quietly — t returns a ⟨key⟩ marker for a key the locale has no entry for, which matches no rendered text.

Where the line falls:

Value Assert as
a label, or a value that is itself a translation t('key') — including a plural, t('n-vcpus', 2)
a formatted date formatDateTime(timestamp) from @core/utils/time.util.ts, as the component does
a string the component composes from several translations a template literal of those t(…) calls
data reaching the component (a name, an id, an IP, a tag) the literal — it is the test's own input
a number the component formats ('8 B', '4 GiB', '2') the literal — deriving it would copy the template

Tables

A table is read by header label, never by column position: src/test/find-table-cells.ts looks the label up in thead th, so a column added, moved or hidden does not shift the assertions.

Helper Reads
findTableHeaders(wrapper) the header labels, in order ('' for a checkbox column)
findTableColumn(wrapper, label) the text of each cell of that column, one per body row
findTableColumnCells(wrapper, label) the cells of that column, to query a link, a tag or a checkbox inside
findTableRows(wrapper), from find-rendered-values.ts the cell texts of each body row, for a table read row by row
expect(findTableHeaders(wrapper)).toEqual(['', t('vm'), t('size')])
expect(findTableColumn(wrapper, t('size'))).toEqual(['80GiB', '120GiB'])
expect(findTableColumnCells(wrapper, t('vm'))[0].getComponent(UiLink).attributes('href')).toBe('/vm/vm-42/dashboard')

A label missing from the header throws, naming the label, rather than reading an empty column.

Chart cards

A dashboard chart card loads its VtsLinearChart asynchronously, and the real chart cannot mount here: ECharts needs a canvas and a ResizeObserver. src/test/linear-chart-stub.ts stands in for it, with the props declared so they stay readable. mountChartCard from src/test/mount-chart-card.ts wires that stub in and defaults loading to false, so a card test only names its component and its stats type:

function mountChart(props: ChartCardProps<XapiHostStats>) {
  return mountChartCard(HostDashboardLoadAverageChart, props)
}

it('plots the load average of the host', () => {
  const wrapper = mountChart({ data: createHostStats({ stats: { load: [1.234, 2.567] } }) })

  expect(findLinearChart(wrapper).props('data')).toEqual([
    {
      label: t('load-average'),
      data: [
        { timestamp: 990_000, value: 1.23 },
        { timestamp: 1_000_000, value: 2.57 },
      ],
    },
  ])
})

Stubbing with true instead would push the props into attributes, where they arrive stringified — which is why mountChartCard owns that wiring rather than each test repeating it.

Assert the plotted series, not the absence of a state hero. Every card guards on the series it built, so an empty series does reach the "no data" hero — but a hero-only test still passes over a series plotting the wrong samples. That is how the pool CPU card came to size its series off stats.memory unnoticed.

The maxValue is worth its own assertion: it is where the card's own axis choice lives ({ step: 5, fallback: 10 } for the host load average, step: 50 for host network throughput, headroom: 1.2 for the stacked pool cpu usage), and nothing else covers it.

So is the valueFormatter, which decides whether the axis and the tooltips read 1 KiB or 1024%. formatChartValue(wrapper, value) runs a value through the formatter the card handed over, and returns undefined when it handed none — so one assertion covers both the choice of formatter and the wiring:

it('formats the plotted values as bytes', () => {
  const wrapper = mountChart({ data: statsWithSamples })

  expect(formatChartValue(wrapper, 1024)).toBe('1 KiB')
})

Keep it to one representative value: how the formatter handles the whole range, null included, belongs to the formatter's own test — chart-stats.util.unit.test.ts for formatChartBytes, chart-percent-formatter.composable.unit.test.ts for useChartPercentFormatter.

Legend cards

A donut card and a progress-bar card both render their values through UiLegend, which keeps the label and the value addressable. src/test/find-rendered-values.ts reads them, so a card test does not re-roll the query:

Helper Reads
findLegends every UiLegend, as ordered [label, value] pairs
findProgressBarGroupLegends the same, grouped per VtsProgressBarGroup
findCardNumbers every UiCardNumbers a card lays out beside its chart
expect(findLegends(wrapper)).toEqual([
  [t('vm:status:running', 2), '2'],
  [t('vm:status:halted', 2), '1'],
])

Pairs rather than a record: a card may legend the same label twice, and the order it lists them in is part of what a user reads.

Three things shape those assertions:

  • VtsProgressBarGroup sorts descending by default, so the expected order is the busiest first, not the order the payload listed.
  • A card holding several legend groups — PoolDashboardStatus shows one for its hosts and one for its VMs — is queried per group: findProgressBarGroupLegends does that for progress-bar groups, while a donut card goes through wrapper.findAll('.vts-donut-chart-with-legend') and the legends inside each. A flat findLegends collapses the groups into one list, and an assertion on it no longer says which group a value landed in.
  • VtsStateHero renders its own wording ahead of the slot: an all-done hero reads 'All good!Patches up to date'. Assert the part the component owns with toContain.

Routing

createGlobalTestConfig() installs a router on every mount, built by src/test/create-test-router.ts over the real generated routes on an in-memory history. Two unrelated things need it:

  • A component rendering RouterLink. Without a router the link resolves to a plain element and its scoped slot (isActive, href) is never invoked, so the markup renders empty instead of failing — a tab bar silently becomes no tabs, and an assertion on it passes vacuously.
  • Anything reaching useUiStore (VtsStateHero, VtsKeyValueRow, TabItem…). That store calls useRouter()/useRoute() in its setup, so a router-less mount logs two injection "Symbol(router)" not found warnings, and reading uiStore.hasUi would throw on route.query.

Because the routes are the real ones, href assertions are real paths (/vm/vm-42/system) rather than a stub's echo of its own prop. Page components are swapped for an empty one: a test never renders a page, and installing a router runs an initial navigation that would otherwise import the whole page module graph.

A test that needs to navigate builds its own router and pushes before mounting:

const router = createTestRouter()
await router.push('/vm/vm-42/system')

const wrapper = mount(VmHeader, { props: { vm }, global: createGlobalTestConfig({ router }) })

expect(
  wrapper
    .findAll('.tab-item')
    .filter(tab => tab.classes('active'))
    .map(tab => tab.text())
).toEqual([t('system')])

Pushing before mounting is what makes the active tab assertable; without a push the router sits at its start location and nothing is active.

Selects

How FormSelect and VtsSelect render and open their options belongs to the web-core tests, never to an XO 6 one:

  • A component that only passes select bindings on is mounted with createGlobalTestConfig({ stubs: { FormSelect: true } }), given createSelectBindings, and read with findSelectStubLabels.
  • The options a form builds (their labels, their custom properties) are read by mounting the form with mountWithSelect, rendering a VtsSelect for its select, then openSelect and findSelectOptionLabels; findShownSelections reads the selection each select shows.

What happy-dom cannot do

There is no layout engine, and Vitest's css option is off, so component styles are never loaded. Out of reach:

  • toBeVisible() and anything depending on real visibility — assert presence/absence instead (expect(wrapper.find('.foo').exists()).toBe(false))
  • getComputedStyle assertions on design tokens (colour, padding, flex-direction)
  • real event dispatch — trigger() simulates events
  • canvas (ECharts), ResizeObserver, IntersectionObserver — for a chart card, stub the chart (see Chart cards)

happy-dom also lacks some browser APIs entirely. EventSource is one: mounting a component that reaches a remote-resource collection opens an SSE subscription and throws ReferenceError: EventSource is not defined. It surfaces as an unhandled rejection, so Vitest exits non-zero while reporting every test as passed — do not read a green test list as success. Mock the collection (Mocking dependencies) so the subscription is never opened.

Pages

Pages (src/pages/**) are the lowest testing priority, and their tests stay thin. A page is composition: assert which components it renders, and their order. Never assert a child's values from a page test — duplicating them there adds no coverage, breaks on every unrelated child edit, and does not say which child broke. Value and formatting assertions live in the test next to the component that renders them.