💭

video要素のテストをしよう

2024/03/10に公開

前提

前提となるライブラリは次です。

  • react
  • @testing-library/react
  • jest

これらの説明はしません。ご了承ください。

はじめに

video要素のテストのついてまとめました。

調べた感じ、個々のテスト手法はStack Overflowだったり、githubのissueに載ってたりしましたが、まとまったものはなかったのでまとめておきます。

video要素の取得方法

video要素はgetByRoleじゃ取得できません。

おそらく、それはaria roleとしてvideoが定義されていないためです。(参考)

なので、aria-labelをつけるなりして、取得するための目印をつけましょう。

import { Video } from "./Video"
import { render, screen } from "@testing-library/react"

describe("Video",()=>{
	it("video要素が取得できる", () => {
		render(
			// biome-ignore lint/a11y/useMediaCaption:
			<video
				src={staticPath.test_video.video_mp4}
				controls
				aria-label="The Video"
			/>,
		);
		expect(screen.getByLabelText("The Video")).toBeInTheDocument();
	});
})

動画の再生・一時停止

controls属性を設定していた場合、ブラウザ上では再生ボタンが描画されますが、これはテスト環境では取得できません。

ではどうするかというと、video要素を取得してplay関数を実行するのですが、jestのjsdomにはplay関数が実装されていません。

つまり結論としてどうするかというと次のことをします。

  • play関数をモックする
  • playイベントを発行する

これによって動画の再生を模倣することができます。

import { Video } from "./Video"
import { render, screen,act, fireEvent } from "@testing-library/react"

describe("Video",()=>{
	it("動画が再生できる", async () => {
		jest.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(function (
			this: HTMLMediaElement,
		) {
			fireEvent(this, new Event("play"));
			return Promise.resolve();
		});
		const handlePlay = jest.fn();
		render(
			// biome-ignore lint/a11y/useMediaCaption:
			<video
				src={staticPath.test_video.video_mp4}
				controls
				aria-label="The Video"
				onPlay={handlePlay}
			/>,
		);
		const video = screen.getByLabelText<HTMLVideoElement>("The Video");
		await video.play();
		expect(handlePlay).toHaveBeenCalled();
	});
})

一時停止も同様の方法でテストできます。

シークバーを移動させる

動画の再生と同様にシークバーもモックする必要があります。

シークバーの移動はvideo要素のcurrentTimeを変更することで実現されるので、curerntTimeをモックして挙動を模倣します。

import { staticPath } from "@/lib/$path";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { Video } from "./Video";

describe("Video", () => {
	it("動画をシークできる", async () => {
		jest
			.spyOn(HTMLMediaElement.prototype, "currentTime", "set")
			.mockImplementation(function (
				this: HTMLMediaElement & { _currentTime: number },
				time: number,
			) {
				this._currentTime = time;
				fireEvent(this, new Event("seeking"));
				fireEvent(this, new Event("seeked"));
			});
		jest
			.spyOn(HTMLMediaElement.prototype, "currentTime", "get")
			.mockImplementation(function (
				this: HTMLMediaElement & { _currentTime: number },
			) {
				return this._currentTime ?? 0;
			});

		const handleSeeked = jest.fn();
		const handleSeeking = jest.fn();
		render(
			// biome-ignore lint/a11y/useMediaCaption:
			<video
				src={staticPath.test_video.video_mp4}
				controls
				aria-label="The Video"
				onSeeked={handleSeeked}
				onSeeking={handleSeeking}
			/>,
		);
		const video = screen.getByLabelText<HTMLVideoElement>("The Video");
		expect(video.currentTime).toBe(0);
		video.currentTime = 10;
		expect(handleSeeked).toHaveBeenCalled();
		expect(handleSeeking).toHaveBeenCalled();
		expect(video.currentTime).toBe(10);
	});
});

動画の再生が終了する

これはシンプルにendedイベントを発行するだけです。

describe("Video", () => {
    it("動画の再生が終わる", async () => {
        const handleEnded = jest.fn();
        render(
            // biome-ignore lint/a11y/useMediaCaption:
            <video
                src={staticPath.test_video.video_mp4}
                controls
                aria-label="The Video"
                onEnded={handleEnded}
            />,
        );
        const video = screen.getByLabelText<HTMLVideoElement>("The Video");
        fireEvent(video, new Event("ended"));
        expect(handleEnded).toHaveBeenCalled();
	});
});

すべてのテストでvideo要素のモックを適用する

これまでモックしてきたものをすべてまとめてモックする場合は次のようになります。

新たにdurationを設定していること、play関数内でsetIntervalでcurrentTimeを進めていることに注意してください。

import { fireEvent } from "@testing-library/dom";

Object.defineProperties(HTMLMediaElement.prototype, {
	play: {
		value() {
			if(Number.isNaN(this.duration)) {
				this.duration = 10;
			}
			this._timer = setInterval(() => {
				if(this._currentTime === undefined) this._currentTime = 0;
				this._currentTime += 0.01
				if (this.currentTime >= this.duration) {
					fireEvent(this, new Event("ended"));
					clearInterval(this._timer);
				}
			}, 10);
			fireEvent(this, new Event("play"));
			return Promise.resolve();
		},
	},
	pause: {
		value() {
			clearInterval(this._timer);
			fireEvent(this, new Event("pause"));
		},
	},
	currentTime: {
		get() {
			return this._currentTime ?? 0;
		},
		set(time) {
			this._currentTime = time;
			fireEvent(this, new Event("seeking"));
			fireEvent(this, new Event("seeked"));
		},
	},
	duration:{
		get() {
			return this._duration ?? NaN;
		},
		set(time) {
			this._duration = time;
		}
	}
});

上記をsetupのファイルの中に置くと各テストファイルでモックする必要がなくなります。

これが動いてるかどうかをテストする関数は次です。

it("すべてのテストでvideo要素のモックを適用する", async () => {
    jest.useFakeTimers({
        advanceTimers:true
    })
    const handleEnded = jest.fn();
    render(
        // biome-ignore lint/a11y/useMediaCaption:
        <video
            src={staticPath.test_video.video_mp4}
            controls
            aria-label="The Video"
            onEnded={handleEnded}
        />,
    );
    const video = screen.getByLabelText<HTMLVideoElement>("The Video");
    video.play();
    await waitFor(() => {
        return new Promise((resolve) => {
            setTimeout(() => {
                resolve(true);
            }, 11 * 1000);
        })
    },{
        timeout: 12*1000,
    });
    expect(handleEnded).toHaveBeenCalled();
});

まとめ

これでvideo要素をいろいろカスタムしても通常のDOMと同様の挙動が模倣されるのでテストが書けます。

次回はcanvasのテストを書きたいと思います。

まだ調査中なのでできるかわかりませんが...

Discussion