返回

JavaScript 测试环境设置完整 Document 内容 (Jest/JSDOM)

javascript

如何设置 JavaScript 测试环境下的整个 Document 内容?

在用 Jest 写单元测试时,遇到个问题:我想把一些网页的 HTML 内容保存到文件里,作为测试数据。我可以用下面的代码把保存下来的 HTML 文件中的 <body> 内容加载进来:

const fs = require('node:fs');
html = fs.readFileSync('blah-blah-blah.body.html', 'utf8');
document.body.innerHTML = html;

挺好用的,但就是有点麻烦。因为直接用 curl url -o blah-blah-blah.html 保存整个文档很容易,但要从中单独提取出 <body> 元素就得再费点事。我尝试过直接设置 document.documentElement

html = fs.readFileSync('blah-blah-blah.document.html', 'utf8');
document.documentElement = html;

可惜 document.documentElement 是只读的,没戏。那该怎么办呢?

问题分析

根本原因在于,document.documentElement 属性(它代表整个文档的根元素,通常是 <html> 元素)在浏览器和多数模拟环境(例如 jsdom, Jest使用的环境)下是被设计为只读的。 这是为了防止对文档结构进行潜在的破坏性修改。 如果允许随意替换documentElement, 可能会导致文档状态的不一致或其他难以预料的问题。

解决方案

有几个方法可以解决这个问题,下面一一介绍:

1. 使用 innerHTML 设置 document.documentElement

尽管不能直接赋值 document.documentElement,但可以修改它的 innerHTML。这招最直接:

  • 原理: innerHTML 属性允许我们设置或获取元素的 HTML 内容。通过设置 document.documentElement.innerHTML,我们可以替换整个文档的内容(除了 <html> 标签本身)。

  • 代码示例:

const fs = require('node:fs');
const html = fs.readFileSync('blah-blah-blah.html', 'utf8');
document.documentElement.innerHTML = html;
  • 注意事项: 这样会移除并重新创建所有的子节点。原来的DOM上绑定的事件处理都会失效。

2. 使用 JSDOMinnerHTML

如果你使用 JSDOM 这个库,能用另一种方式来设置整个文档。 (Jest 内部用了 JSDOM)

  • 原理 : JSDOM库, 在模拟的时候能完全替换内容。

  • 代码示例:

const fs = require('node:fs');
const { JSDOM } = require('jsdom');

const html = fs.readFileSync('my-page.html', 'utf8');
const dom = new JSDOM(html);

// 获取 JSDOM 实例的 document 对象
const document = dom.window.document;

//  document 就具备完整的 DOM API 可以操作了
console.log(document.querySelector('title').textContent);
  • 进阶 : 使用这种方法来完全控制DOM,可以实现更加复杂的功能.
    • 可以方便模拟不同的 userAgent.
    • 通过 window 上的 API 可以模拟全局的 localStorage, sessionStorage.

3. 清空并重建 body 内容

一个简单粗暴的备选方案是,先清空整个 body,再把新的 HTML 内容塞进去。虽然不能替换 <html>,但通常测试用例更关注 <body> 内部。

  • 原理: 就像一开始的例子,直接操作 document.body.innerHTML。但这回我们先把它清空。

  • 代码示例:

const fs = require('node:fs');
const html = fs.readFileSync('blah-blah-blah.html', 'utf8');

// 提取 <body> 部分 (可以使用正则表达式或 HTML 解析器库)
const bodyContent = extractBodyContent(html); // 自己实现 extractBodyContent 函数

document.body.innerHTML = ''; // 清空
document.body.innerHTML = bodyContent; // 设置新的内容
你要自己实现从完整 HTML 提取body content的部分. 正则表达式最简单,但是复杂文档容易出bug.
使用类似`cheerio` 之类的 HTML解析库可以稳健处理.
  • Cheerio 方案

    如果你使用 cheerio 这个库, 可以这样加载:

const fs = require('node:fs');
const cheerio = require('cheerio');

const html = fs.readFileSync('blah-blah-blah.html', 'utf8');
const $ = cheerio.load(html);

document.body.innerHTML = $('body').html();

4. 使用 Object.defineProperty (慎用)

理论上 可以通过修改属性的符把 documentElement 变成可写的, 但是绝对不推荐 , 这么干风险极高, 而且 Jest 和 jsdom 可能在未来版本禁止这种操作。

  • 原理: JavaScript 允许通过 Object.defineProperty 来修改属性的特性(如可写性、可枚举性等)。
  • 代码:
    (只展示思路,不要在生产使用!)
  Object.defineProperty(document, 'documentElement', {
    writable: true,
  });
  • 风险 :
    1. 破坏了jsdom 模拟的环境的稳定性, 可能引入未知bug.
    2. 和测试框架行为冲突.

如何选择?

  • 如果简单替换内容能满足需求,首选方法 1(document.documentElement.innerHTML = html)。
  • 需要用到更强大的 jsdom 提供的功能, 使用方法2 (JSDOM实例).
  • 如果仅仅关心 body 内的内容, 且想尽可能减少对原来 <html> 的修改,使用方法3 (处理body内容).
  • 绝对不要试图通过Object.defineProperty 更改只读属性。