Testing - prove the application boots
A service whose injection nobody registered fails when something first resolves it, which in the browser is the first render. assertArdorApplicationBoots finds it in a unit test instead. Available in 0.4.4; it needs @venizia/ignis-inversion 0.4.6-1 or newer.
import { assertArdorApplicationBoots } from '@venizia/ardor/testing';
await assertArdorApplicationBoots(() => new Application());Application is your application class; the call goes in a bun:test test, after import 'reflect-metadata'. An app made from the preset has it as src/boot.test.ts.
What it does
In order, and it stops at the first failure with the error the app would raise:
- Calls
start(): the artifact index, the stereotype bindings, the features,bindContext(). - Resolves every binding of the namespaces
services,repositories,datasources,componentsandconfigurations, collects every failure and throws once. The message names each binding that failed and the key or class it needed. - Resolves what
ArdorApplicationresolves on mount: the data provider, the auth provider (optional) and the i18n provider, through the same function the component calls. - Reads and mounts the features, and loads the messages of the first locale, so a key two features define fails here.
fetch is replaced for the run by a function that rejects, so nothing leaves the process, and put back afterwards. The helper returns the application it started.
Options
| Option | Type | Default | Meaning |
|---|---|---|---|
skipBindings | Array<TClass | string> | none | Bindings left unresolved: an exact class, a class name or a binding key, among the five namespaces of step 2. A skip that matches no binding rejects, so a typo does not skip silently. It does not reach step 3: see below. |
locale | string | the i18n provider's | The locale whose messages must load. |
allowEmpty | boolean | false | An application with nothing to check and no feature rejects: the artifact index is usually empty. |
skipBindings applies to step 2 only. Step 3 builds the data, auth and i18n providers the application bound, so a bound auth provider that cannot be built fails the check even when you name its key, and the key, outside the five namespaces, rejects as a skip that matched nothing. An application that never signs in, such as a guest app that builds its own react-admin tree, leaves CoreBindings.DEFAULT_AUTH_PROVIDER unbound (see Authentication (No Sign-In)).
The first argument is an instance or a function that returns one. Pass a function: a failed start leaves a half-started instance, and the same instance cannot be passed twice.
What it cannot catch
- Anything that fails only in a real browser: CSS, layout, the router, a page's own rendering.
- A request a service makes with its own transport, or a socket it opens when it is built. Name such a binding in
skipBindings. - A wrong API url, a server that is down, an expired token: nothing here reaches the network.
Fake the browser APIs your code reads
happy-dom and some other test hosts have a ResizeObserver or IntersectionObserver whose observe() does nothing, no matchMedia, a localStorage that never fails and a navigator.clipboard that does not exist. ARDOR's hooks and helpers read all of them, so the same entry holds a fake of each. Each install returns a function that puts back what was there; call it in afterEach.
import {
FakeClipboard,
FakeIntersectionObserver,
FakeMatchMedia,
FakeResizeObserver,
FakeStorage,
} from '@venizia/ardor/testing';
declare const element: Element; // an element the component under test observes
const restore = [
FakeResizeObserver.install(),
FakeIntersectionObserver.install(),
FakeMatchMedia.install(),
new FakeStorage().install(),
new FakeClipboard().install(),
];
// render the component, then:
FakeResizeObserver.flush({ element, width: 320, height: 200 });
FakeIntersectionObserver.flush({ element, isIntersecting: true });
FakeMatchMedia.set({ query: '(min-width: 600px)', matches: true });
restore.forEach((undo) => undo());The same classes are in @venizia/ardor-kernel/testing, for a project that uses the kernel without the umbrella. They need no DOM of their own.
FakeResizeObserver.install()andFakeIntersectionObserver.install()put the fake onglobalThisand forget the observers of an earlier install, so install once per test. Everyinstallalso puts the fake onwindowwhere that is another object (happy-dom), because code readswindow.matchMediaandwindow.localStorage; the restore function puts both back.FakeResizeObserver.allandFakeIntersectionObserver.alllist the observers the code created; each has itscallback,targets(the elements observed now),isDisconnected, andoptions(theFakeResizeObserverone has an entry perobservecall, and anunobservedlist).flushcalls every observer ofelementat once:FakeResizeObserver.flush({ element, width, height })with one entry whose content box and border box have that size,FakeIntersectionObserver.flush({ element, isIntersecting, ratio })with one entry (ratiois 1 or 0 byisIntersectingunless you give it). An element nobody observes, an element that was unobserved and a disconnected observer get nothing. For an entry of your own, callFakeResizeObserver.all[0].callback.FakeIntersectionObserver.settle({ isIntersecting?, ratio? })is the first report of a browser, which an observer sends for every newly observed element: each live observer is called once per element it has not reported on (not intersecting unless you say so).flushcounts as a report, and an element observed again is reported again.FakeIntersectionObserver.live()lists the observers that are not disconnected andFakeIntersectionObserver.createdcounts the ones made since the lastinstall.- Animation frames are yours.
useElementSizeanduseResizeEffectapply a size on the next animation frame (useResizeEffectwithbeforePaint: truecalls inside the observer callback, with no frame), so the test also runs its frames, with a fakerequestAnimationFrameor its runner's timers, insideact. FakeMatchMedia.install()replacesmatchMediawith a function that answers one list per query,falseuntilFakeMatchMedia.set({ query, matches })sets it and tells the listeners with achangeevent.FakeMatchMedia.queriesholds the queries the code asked about, sosize === 0shows that it never asked, andFakeMatchMedia.of(query).listenerCountcounts thechangelisteners of a query, so a test can assert a subscribe and an unsubscribe.new FakeStorage()is aStoragethat keeps its keys as own properties, as the real one does.storage.install()makes itlocalStorage;storage.install('session')makes itsessionStorage. Each way to fail has its own switch:failReads = truemakesgetItemthrowSecurityError,failListing = truemakeskeyandlengththrow it, andfailWrites = truemakessetItemthrowQuotaExceededErrorandremoveItemthrowSecurityError.writeslists the values it accepted. Flip them at any time to see the code recover. A key named like a member of the fake (getItem,length,writes,failWritesand the rest) would shadow it, sosetItemthrows aTypeErrorfor it, where the realStorageaccepts it.new FakeClipboard()replacesnavigator.clipboardwith one that keeps the text:writtenlists everywriteText,textis whatreadTextanswers, andisDenied = truemakes both reject withNotAllowedError. To test a missing clipboard, or one that throws before it returns a promise, replacenavigator.clipboardyourself.