Media element stub
stubMediaElement() makes <video> and <audio> elements work in a jsdom test: play() resolves, duration has a value, and changing the state fires the events your component listens for. Use it for player, advertising or subtitle components.
import { stubMediaElement } from 'vitest-auto-spy/dom-stubs';
let media: ReturnType<typeof stubMediaElement>;
beforeEach(() => {
media = stubMediaElement({ duration: 120 });
});
it('marks the video finished', () => {
const fixture = TestBed.createComponent(PlayerComponent);
fixture.detectChanges();
const video = fixture.nativeElement.querySelector('video');
media.set(video, { ended: true }); // fires `pause`, then `ended`
expect(media.play).toHaveBeenCalledTimes(1);
expect(fixture.componentInstance.finished()).toBe(true);
});Without it, jsdom gives you an empty shell:
| What the code under test does | What jsdom does |
|---|---|
await video.play() | throws Not implemented: HTMLMediaElement.play() |
video.duration | NaN, and assigning it throws |
video.canPlayType('video/mp4') | '' for every type, so feature detection says no |
video.readyState | 0, forever |
video.error | not there at all |
video.load() | nothing |
| Option | Type | Default | Meaning |
|---|---|---|---|
duration | number | 0 | Duration every element reports until you set one |
canPlayType | (type: string) => CanPlayTypeResult | () => 'probably' | What canPlayType(type) answers |
Common mistake: installing it once in a describe body or beforeAll. It is removed after the first test. Install it in beforeEach, as above, or use installPerTest.
Driving one
media.set(element, state) changes an element's state and fires the events the browser would fire. Use it instead of assigning fields: your component's durationchange, timeupdate or ended handlers only run when the event fires.
media.set(video, { readyState: 1 }); // fires `loadedmetadata`
media.set(video, { currentTime: 119 }); // fires `timeupdate`
media.set(video, { ended: true }); // fires `pause`, `ended`Field passed to set | Event dispatched |
|---|---|
duration | durationchange |
readyState ≥ 1 | loadedmetadata |
currentTime | timeupdate |
ended: true | pause, ended |
a non-null error | error |
Several fields in one call fire several events, in the order of the table.
ended: falseanderror: nullfire nothing: they clear a state, and the browser has no event for that.ended: truealso setspausedtotrueand firespausebeforeended, as the browser does when media plays to the end. If you passpausedin the same call, your value is kept.media.state(element)returns the current state:duration,currentTime,paused,ended,readyStateanderror.
Seeking the way the component does
A player that restarts assigns the field directly, and that works too:
component.restart(); // video.currentTime = 0
expect(media.state(video).currentTime).toBe(0);
expect(component.progress()).toBe(0); // its own `timeupdate` handler ranAssigning currentTime fires timeupdate, exactly as media.set(video, { currentTime: 0 }) does.
State is per element
Each element has its own state, so an ad and the main video can report different durations:
media.set(advert, { duration: 15 });
expect(advert.duration).toBe(15);
expect(content.duration).toBe(120); // the option's defaultduration starts at the duration option, or 0. It is never NaN, so a component that waits for a known length has one from the first read.
The state belongs to the current stubMediaElement() call. An element that outlives its test (kept in a module variable, or left in <body> under isolate: false) starts again from the defaults of the stub installed now.
play, pause, load, canPlayType
They are mocks of your test runner (vi.fn() on Vitest), shared by every media element, so every matcher works:
expect(media.pause).toHaveBeenCalledTimes(1);play()returns a resolved promise, notundefined. Code often calls.catch()on it to ignore an autoplay error, and that line would throw onundefined. It also firesplayandplaying.play.mock.instances[0]tells you which element played.canPlayTypeanswers'probably'by default. To make one codec unsupported, pass your own:
stubMediaElement({ canPlayType: (type) => (type.includes('vp9') ? '' : 'probably') });Installation and undo
The stub is installed on HTMLMediaElement.prototype. So it also covers an element your code creates with document.createElement('video'), which a per-element stub could not reach.
It is installed through mockValueProp / mockReadonlyPropGetter, so restoreMockedProps() puts the real prototype back after each test. setupAutoSpy() already runs that. Without this, a hand-written Object.defineProperty on the prototype leaks into the next file.