Testing
Wardic runs in a component test as it does on a page. Render under a provider with DomLive in jsdom or happy-dom, and fake the timers to move time yourself. These tests run with Vitest in jsdom, on both bindings, as part of Wardic's own test suite:
// @vitest-environment jsdom
import { type JSX, render } from "@solidjs/web"
import { DomLive } from "@wardic/dom"
import { domAnimation, w, WardicConfig, WardicProvider } from "@wardic/solid/unstable/v2"
import { createEffect } from "solid-js"
import { afterEach, beforeEach, expect, it, onTestFinished, vi } from "vitest"
/** What's under test: a notice that fades in, and a button that grows under the pointer and undoes when it's tapped. */
function Notice(props: { readonly onUndo?: () => void }) {
// The button's ward hears its taps; its motion props stay on the element.
const undo = w.ward()
createEffect(undo.when("tap"), () => props.onUndo?.())
return (
<>
<w.p role="status" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.2, ease: "linear" }}>
Saved
</w.p>
<w.button wardRef={undo} whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
Undo
</w.button>
</>
)
}
/** Renders `ui` under the provider, as the app has it, and takes it away after the test. */
const show = (ui: () => JSX.Element) => {
const host = document.body.appendChild(document.createElement("div"))
const dispose = render(
() => (
<WardicProvider env={DomLive} features={[domAnimation]}>
{ui()}
</WardicProvider>
),
host,
)
onTestFinished(() => {
dispose()
host.remove()
})
return host
}
// Wardic draws on animation frames, which fake timers fake too: the test moves time itself.
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it("fades the notice in", async () => {
const notice = show(() => <Notice />).querySelector<HTMLElement>("[role=status]")!
expect(notice.style.opacity).toBe("0")
await vi.advanceTimersByTimeAsync(100)
expect(Number(notice.style.opacity)).toBeCloseTo(0.5, 1)
await vi.advanceTimersByTimeAsync(150)
expect(notice.style.opacity).toBe("1")
})
it("grows the button under the pointer", async () => {
const button = show(() => <Notice />).querySelector("button")!
button.dispatchEvent(new PointerEvent("pointerenter", { pointerType: "mouse" }))
await vi.advanceTimersByTimeAsync(1000)
expect(button.style.transform).toBe("scale(1.1)")
})
it("undoes when the button is tapped", async () => {
const onUndo = vi.fn()
const button = show(() => <Notice onUndo={onUndo} />).querySelector("button")!
button.dispatchEvent(new PointerEvent("pointerdown", { pointerType: "mouse", isPrimary: true, button: 0, bubbles: true }))
await vi.advanceTimersByTimeAsync(1000)
expect(button.style.transform).toBe("scale(0.95)")
button.dispatchEvent(new PointerEvent("pointerup", { pointerType: "mouse", isPrimary: true, button: 0, bubbles: true }))
await vi.advanceTimersByTimeAsync(20)
expect(onUndo).toHaveBeenCalledOnce()
})
it("shows the notice at once with the animations skipped", async () => {
const host = show(() => (
<WardicConfig skipAnimations>
<Notice />
</WardicConfig>
))
await vi.advanceTimersByTimeAsync(20)
expect(host.querySelector<HTMLElement>("[role=status]")!.style.opacity).toBe("1")
})// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"
import { DomLive } from "@wardic/dom"
import { domAnimation, w, WardicConfig, WardicProvider } from "@wardic/solid"
import type { JSX } from "solid-js"
import { afterEach, beforeEach, expect, it, vi } from "vitest"
/** What's under test: a notice that fades in, and a button that grows under the pointer and undoes when it's tapped. */
function Notice(props: { readonly onUndo?: () => void }) {
return (
<>
<w.p role="status" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.2, ease: "linear" }}>
Saved
</w.p>
<w.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }} onTap={() => props.onUndo?.()}>
Undo
</w.button>
</>
)
}
/** The provider, as the app has it. */
const withWardic = (ui: () => JSX.Element) => () => (
<WardicProvider env={DomLive} features={[domAnimation]}>
{ui()}
</WardicProvider>
)
// Wardic draws on animation frames, which fake timers fake too: the test moves time itself.
beforeEach(() => vi.useFakeTimers())
afterEach(() => {
cleanup()
vi.useRealTimers()
})
it("fades the notice in", async () => {
render(withWardic(() => <Notice />))
const notice = screen.getByRole("status")
expect(notice.style.opacity).toBe("0")
await vi.advanceTimersByTimeAsync(100)
expect(Number(notice.style.opacity)).toBeCloseTo(0.5, 1)
await vi.advanceTimersByTimeAsync(150)
expect(notice.style.opacity).toBe("1")
})
it("grows the button under the pointer", async () => {
render(withWardic(() => <Notice />))
const button = screen.getByRole("button", { name: "Undo" })
fireEvent.pointerEnter(button, { pointerType: "mouse" })
await vi.advanceTimersByTimeAsync(1000)
expect(button.style.transform).toBe("scale(1.1)")
})
it("undoes when the button is tapped", async () => {
const onUndo = vi.fn()
render(withWardic(() => <Notice onUndo={onUndo} />))
const button = screen.getByRole("button", { name: "Undo" })
fireEvent.pointerDown(button, { pointerType: "mouse", isPrimary: true, button: 0 })
await vi.advanceTimersByTimeAsync(1000)
expect(button.style.transform).toBe("scale(0.95)")
fireEvent.pointerUp(button, { pointerType: "mouse", isPrimary: true, button: 0 })
expect(onUndo).toHaveBeenCalledOnce()
})
it("shows the notice at once with the animations skipped", async () => {
render(withWardic(() => (
<WardicConfig skipAnimations>
<Notice />
</WardicConfig>
)))
await vi.advanceTimersByTimeAsync(20)
expect(screen.getByRole("status").style.opacity).toBe("1")
})On Solid 2, render with @solidjs/web's render, since @solidjs/testing-library is Solid 1's.
Moving time
Wardic draws on animation frames, and fake timers fake them with the rest. After vi.useFakeTimers(), nothing moves until the test says so:
await vi.advanceTimersByTimeAsync(100)moves time on 100 ms, frame by frame, and each element'sstyleis then what the page would draw. Await it: there's work between frames that isn't timed, andvi.advanceTimersByTimealone moves nothing.- An animation starts on the frame after it's asked for, so a 0.2 s one lands a frame after 200 ms: move past the end to see it land.
- The values are the same on every run.
Skipping the animations
To test what a component shows rather than how it moves, wrap it in <WardicConfig skipAnimations>. Every animation lands on its next frame, exits and layout animations included, so after one frame each element is at its end. Put it in the test's provider once, and every test gets it.
Gestures
Fire the pointer events, as the tests above do: pointerenter plays whileHover, and pointerdown then pointerup play whileTap and make a tap.
- On Solid 1, a tap calls
onTap. - On Solid 2, a tap is the ward's
tapevent:await card.next("tap"), or an effect oncard.when("tap"), as the notice's button has. See Ward events.
What needs a browser
jsdom and happy-dom don't lay anything out: every box measures 0. jsdom has no IntersectionObserver or ResizeObserver either. So layout animations, layoutId, dragging within constraints and in-view animations belong in a real browser, with Vitest's browser mode or Playwright.
Reduced motion
Give the test's provider reducedMotion="always". jsdom has no matchMedia, so there "user" never reduces. In a browser test, emulate the system setting instead, as Playwright's page.emulateMedia({ reducedMotion: "reduce" }) does.
@wardic/test
@wardic/test is a different tool, for testing Wardic's core and a new Environment with no page at all: a clock that replays the frames it's given, a renderer that records what it's asked to draw, and input and a page that a test scripts. Its TestLive draws nothing on a page, so for a component's test, use DomLive as above.