CLOVER🍀

That was when it all began.

WHATWG DOMやHTMLなどのJavaScript実装である、jsdomを試す

これは、なにをしたくて書いたもの?

jsdomを触っておこうかなということで。

jsdom

jsdomのGitHubリポジトリーはこちら。

GitHub - jsdom/jsdom: A JavaScript implementation of various web standards, for use with Node.js

jsdomは、Node.js上で使用するWHATWG DOMやHTMLなど多くのWeb標準のピュアJavaScript実装です。Webアプリケーションの
テストやスクレイピングなどに役立てる程度に、Webブラウザのサブセットをエミュレーションすることを目標にした
プロジェクトです。

今回はこちらを触ってみます。

Vitestを使ったテストコードで試してみようと思うのですが、environmentjsdomにするとjsdomを使用したブラウザ環境の
エミュレーションを行えるようなのでこちらも試してみます。

Test Environment | Guide | Vitest

つまり、environmentnodejsdomの2つにします。

環境

今回の環境はこちら。

$ node --version
v22.14.0


$ npm --version
10.9.2

Vitestのnode environmentでjsdomを試す

まずはVitestのnode environmentでjsdomを試してみます。Vitestを挙げていますが、要するにNode.js環境で単純にjsdomを
使うだけの話です。

Node.jsプロジェクトを作成して、必要な依存関係などをインストール。

$ npm init -y
$ npm i -D typescript
$ npm i -D @types/node@v22
$ npm i -D prettier
$ npm i -D vitest
$ mkdir test

jsdomと型定義もインストールしておきます。

$ npm i jsdom
$ npm i -D @types/jsdom

プロジェクトはECMAScript Modulesとして、scriptsや依存関係はこのように設定しました。

  "type": "module",

  ...

  "scripts": {
    "build": "tsc --project .",
    "build:watch": "tsc --project . --watch",
    "typecheck": "tsc --project ./tsconfig.typecheck.json",
    "typecheck:watch": "tsc --project ./tsconfig.typecheck.json --watch",
    "test": "vitest run",
    "test:watch": "vitest watch",
    "format": "prettier --write test"
  },

  ...

  "devDependencies": {
    "@types/jsdom": "^21.1.7",
    "@types/node": "^22.14.1",
    "prettier": "^3.5.3",
    "typescript": "^5.8.3",
    "vitest": "^3.1.1"
  },
  "dependencies": {
    "jsdom": "^26.1.0"
  }

各種設定ファイル。

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "lib": ["esnext"],
    "baseUrl": "./src",
    "outDir": "dist",
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "noPropertyAccessFromIndexSignature": true,
    "skipLibCheck": true,
    "esModuleInterop": true
  },
  "include": [
    "src/**/*"
  ]
}

tsconfig.typecheck.json

{
  "extends": "./tsconfig",
  "compilerOptions": {
    "baseUrl": "./",
    "noEmit": true
  },
  "include": [
    "test/**/*"
  ]
}

vite.config.ts

/// <reference types="vitest" />
import { defineConfig } from 'vite';

export default defineConfig({
  test: {
    environment: 'node',
  },
});

.prettierrc.json

{
  "singleQuote": true,
  "printWidth": 100
}

では、jsdomを使っていってみます。

jsdom / Basic usage

テストコードの雛形はこちら。

test/app.test.ts

import { JSDOM, VirtualConsole } from 'jsdom';
import { expect, test } from 'vitest';

// ここに、テストを書く!

まずは基本的なところから。JSDOMコンストラクターにHTMLを渡すようです。

test('first jsdom', () => {
  const dom = new JSDOM(`<!DOCTYPE html><p>Hello world</p>`);
  expect(dom.window.document.querySelector('p')?.textContent).toStrictEqual('Hello world');
});

JSDOMインスタンスを作った後は、windowオブジェクトを操作できるようですね。

JSDOMコンストラクターに渡せるのは、stringBufferBinaryDataのようです。

オプションの設定。

test('set options', () => {
  const domNoOptions = new JSDOM(`<!DOCTYPE html><p>Hello world</p>`);

  expect(domNoOptions.window.location.protocol).toStrictEqual('about:');
  expect(domNoOptions.window.location.host).toStrictEqual('');
  expect(domNoOptions.window.location.pathname).toStrictEqual('blank');
  expect(domNoOptions.window.location.search).toStrictEqual('');

  const domWithOptions = new JSDOM(`<!DOCTYPE html><p>Hello world</p>`, {
    url: 'http://example.com/path?message=hello',
  });

  expect(domWithOptions.window.location.protocol).toStrictEqual('http:');
  expect(domWithOptions.window.location.host).toStrictEqual('example.com');
  expect(domWithOptions.window.location.pathname).toStrictEqual('/path');
  expect(domWithOptions.window.location.search).toStrictEqual('?message=hello');
});

jsdom / Customizing jsdom / Simple options

今回は、HTMLをhttp://example.com/path?message=helloからロードしたことにしてみました。window.locationに反映されて
いることが確認できます。

指定できるオプションはこのあたりのようですね。

https://github.com/jsdom/jsdom/blob/26.1.0/lib/api.js#L202-L221

JavaScriptの実行もできるようです。

test('execute script', () => {
  const dom = new JSDOM(
    `<body>
  <div id="content"></div>
  <script>document.getElementById("content").append(document.createElement("hr"));</script>
</body>`,
    {
      runScripts: 'dangerously',
    },
  );

  expect(dom.window.document.querySelector('hr')).not.toBeNull();
});

jsdom / Customizing jsdom / Executing scripts

HTML内のscriptにあるJavaScriptの実行はセキュリティ上デフォルトで無効になっていて、runScriptsオプションを
dangerouslyに指定しなければ実行できません。

window.evalを使ってHTML外からJavaScriptを実行する場合は、runScriptsオプションにoutside-onlyを指定すると
実行できます。

Consoleオブジェクトも扱うことができ、window.consoleVirtual consoleとして割り当てることもできます。

test('virtual console', () => {
  const virtualConsole = new VirtualConsole();
  virtualConsole.sendTo(console);

  const dom = new JSDOM('<body><script>console.log("Print Console");</script></body>', {
    virtualConsole,
    runScripts: 'dangerously',
  });
});

jsdom / Customizing jsdom / Virtual consoles

今回は、window.consoleの内容をNode.jsのconsoleに転送しています。

このテストを実行すると

$ npm test

標準出力にwindow.console.logの結果が出力されます。

Print Console

最後は外部URLから取得したHTMLを使って、JSDOMインスタンスを構築してみます。

test('load from url', async () => {
  const dom = await JSDOM.fromURL('https://kazuhira-r.hatenablog.com/entry/2024/08/18/175254');
  expect(dom.window.document.querySelector('.entry-title-link')?.textContent).toStrictEqual(
    'TypeScript × Vitest × SuperTestでExpressのテストを書いて動かしてみる',
  );
});

jsdom / Convenience APIs

今回はJSDOM#fromURLを使って指定のURLからHTMLを取得しましたが、JSDOM#fromFileでファイルを指定することも
できます。

Vitestのjsdom environmentでjsdomを試す。

次は、Vitestのjsdom environmentでjsdomを試してみます。

Test Environment | Guide | Vitest

これ、どういうことなんだろう?と思ったのですが、テストコード内でグローバルオブジェクトとしてjsdomのwindow
使えるようになるみたいですね

// @vitest-environment jsdom

import { expect, test } from 'vitest'

test('test', () => {
  expect(typeof window).not.toBe('undefined')
})

Node.jsプロジェクトを作成して、必要な依存関係をインストール。

$ npm init -y
$ npm i -D typescript
$ npm i -D prettier
$ npm i -D vitest
$ npm i -D jsdom @types/jsdom
$ mkdir test

package.jsonやPrettierの設定はほとんど同じ(jsdomがdevDependenciesに移ったくらい)なので省略します。

tsconfig.jsonlibDOMDOM.Iterableとしました。

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "lib": ["esnext", "DOM", "DOM.Iterable"],
    "baseUrl": "./src",
    "outDir": "dist",
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "noPropertyAccessFromIndexSignature": true,
    "skipLibCheck": true,
    "esModuleInterop": true
  },
  "include": [
    "src/**/*"
  ]
}

vite.config.tsに関しては、environmentjsdomになります。

vite.config.ts

/// <reference types="vitest" />
import { defineConfig } from 'vite';

export default defineConfig({
  test: {
    environment: 'jsdom',
  },
});

これで、テストコード内でwindowが使えるようになります。

test/app.test.ts

import { expect, test } from 'vitest';

test('jsdom environment', () => {
  expect(window).not.toBeUndefined();
  expect(document).not.toBeUndefined();
});

test('create element', () => {
  const element = document.createElement('div');
  expect(element).not.toBeNull();
});


test('document innerHtml', () => {
  document.body.innerHTML = '<!DOCTYPE html><p>Hello world</p>';

  expect(document.querySelector('p')?.textContent).toStrictEqual('Hello world');
});

ちなみに、jsdomがインストールされていない場合は、Vitestでテストを実行した時にjdsomを依存関係に追加してよいか
聞かれることになります。

 MISSING DEPENDENCY  Cannot find dependency 'jsdom'

✖ Do you want to install jsdom? … 

ちなみにvite.config.tsenvironmentが他の値であっても

export default defineConfig({
  test: {
    environment: 'node',
  },
});

テストコードのファイルの最初に@vitest-environment jsdomを含むコメントを以下のように記述することで、そのテストコードを
jsdom環境で動作させることもできます。

/**
 * @vitest-environment jsdom
 */
import { expect, test } from 'vitest';

test('jsdom environment', () => {
  expect(window).not.toBeUndefined();
  expect(document).not.toBeUndefined();
});

test('create element', () => {
  const element = document.createElement('div');
  expect(element).not.toBeNull();
});


test('document innerHtml', () => {
  document.body.innerHTML = '<!DOCTYPE html><p>Hello world</p>';

  expect(document.querySelector('p')?.textContent).toStrictEqual('Hello world');
});

基本的な使い方はこんなところでしょうか。

おわりに

jsdomを試してみました。

非ブラウザ環境でのJavaScriptでのHTML操作に関するライブラリーを扱ってこなかったので、こういう感じなんだなーと雰囲気を
押さえられたような気がします。

Vitestでのサポートも見てみましたし、初歩的な内容は掴めたのではないでしょうか。