如何在没有 UI 框架的情况下对 HTML 和 Vanilla JavaScript 进行单元测试

2025-06-11

如何在没有 UI 框架的情况下对 HTML 和 Vanilla JavaScript 进行单元测试

最近我对一些事情很好奇:是否可以为不使用任何类型的 UI 框架或开发人员工具的前端代码编写单元测试?

换句话说,没有 React、Angular 或 Vue。没有 webpack 或 rollup。没有任何构建工具。只有一个普通的index.html文件和一些原生 JavaScript。

这样的设置可以测试吗?

本文及其附带的 GitHub repo就是该问题的结果。


先前经验

在我的职业生涯中,我做过不少测试。我主要是一名前端软件工程师,因此我的专业领域包括使用 Jest 作为测试框架编写单元测试,以及在使用 React 时使用EnzymeReact Testing Library作为测试库。我还使用CypressSelenium进行过端到端测试

我通常选择使用 React 构建用户界面。几年前,我开始使用 Enzyme 来测试这些界面,但后来我更倾向于使用 React 测试库,并秉持着“测试应用应该以用户使用应用的方式进行,而不是测试实现细节”的理念。

Kent C. Dodds 的 React 测试库建立在他的DOM 测试库之上,顾名思义,DOM 测试库是一个帮助你测试 DOM 的库。我觉得这可能是一个很好的起点。


初步研究

研究

照片由 Michael Longmire 在 Unsplash 上拍摄

在软件工程的世界里,很少有人能像你一样率先尝试。几乎所有事情都已经有人以各种形式做过了。因此,Google、Stack Overflow 和开发者论坛是你的好帮手。

我以为肯定有人尝试过,也写过相关文章。但研究了一下之后,发现好像有几个人之前也尝试过,但都无功而返。一位开发者在 2019 年 8 月寻求帮助,但没有得到任何回复。另一位开发者写了一篇很有用的文章,介绍了他们的想法,但不幸的是,他们最终只测试了实现细节,而这正是我想要避免的。

因此,利用从他们的尝试中获得的信息,我开始制作自己的演示项目。


演示应用程序

如上所述,您可以在此处找到我的演示应用程序的代码。您还可以在此处查看该应用程序的实际运行情况。它小巧而简单,毕竟这只是一个概念验证。

不过,演示应用也不必太无聊,所以我创建了一个双关语生成器供大家娱乐。它看起来如下:

演示应用程序

演示应用程序

查看源代码时,有两个重要文件需要注意:

  • src/index.html:这是整个应用程序。没有其他文件,只有一个带有脚本标签的 HTML 文件。
  • src/index.test.js:这是测试文件。我使用了 Jest 和 DOM 测试库。

这两个文件都很小,所以我将它们包含在下面:


源文件:index.html

<html>
<head>
<title>Pun Generator</title>
<link rel="stylesheet" type="text/css" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.4/css/bootstrap.min.css" />
<style>
.jumbotron {
background: #337ab7;
color: white;
}
.jumbotron p {
max-width: 75%;
margin: 1em auto 2em;
}
#pun-container {
margin-top: 32px;
}
#pun-container p {
font-size: 24px;
margin: 32px 0;
}
</style>
</head>
<body>
<div class="jumbotron text-center">
<div class="container">
<h1>Pun Generator</h1>
<p>Get ready for some good pun-ch lines.</p>
</div>
</div>
<main>
<div class="container text-center">
<div class="row">
<button class="btn btn-lg btn-primary">Click me for a terrible pun</button>
</div>
<div class="row">
<div id="pun-container"></div>
</div>
</div>
</main>
<script>
(function() {
const puns = [
"My ceiling isn't the best, but it's up there.",
'I got fired from the calendar factory, just for taking a day off.',
"Q: What's the best thing about Switzerland? A: Well, the flag is a big plus.",
'The past, present, and future walk into a bar. It was tense.',
'I tried to make a belt out of watches. It was a waist of time.',
"Why can't Harry Potter tell the difference between his potion pot and his best friend? They're both cauld ron.",
"I'm afraid of negative numbers. I'll stop at nothing to avoid them.",
"Two antennas got married. The ceremony wasn't much, but the reception was excellent.",
'RIP boiling water. You will be mist.',
'I met a criminal with a bounty on his head. That was a weird place to keep paper towels.',
'I did a theatrical performance about puns. It was a play on words.',
'Knowing how to pick locks has opened a lot of doors for me.',
'I should have been sad when my flashlight batteries died, but I was delighted.',
"Santa Claus's elves are subordinate clauses.",
"I'm designing a reversible jacket. I'm excited to see how it turns out.",
];
let counter = 0;
const appendNewPun = () => {
const p = document.createElement('p');
p.innerHTML = puns[counter++] || "I'm all out of puns!";
document.querySelector('#pun-container').prepend(p);
}
const button = document.querySelector('button');
button.addEventListener('click', appendNewPun);
})();
</script>
</body>
</html>
view raw index.html hosted with ❤ by GitHub
<html>
<head>
<title>Pun Generator</title>
<link rel="stylesheet" type="text/css" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.4/css/bootstrap.min.css" />
<style>
.jumbotron {
background: #337ab7;
color: white;
}
.jumbotron p {
max-width: 75%;
margin: 1em auto 2em;
}
#pun-container {
margin-top: 32px;
}
#pun-container p {
font-size: 24px;
margin: 32px 0;
}
</style>
</head>
<body>
<div class="jumbotron text-center">
<div class="container">
<h1>Pun Generator</h1>
<p>Get ready for some good pun-ch lines.</p>
</div>
</div>
<main>
<div class="container text-center">
<div class="row">
<button class="btn btn-lg btn-primary">Click me for a terrible pun</button>
</div>
<div class="row">
<div id="pun-container"></div>
</div>
</div>
</main>
<script>
(function() {
const puns = [
"My ceiling isn't the best, but it's up there.",
'I got fired from the calendar factory, just for taking a day off.',
"Q: What's the best thing about Switzerland? A: Well, the flag is a big plus.",
'The past, present, and future walk into a bar. It was tense.',
'I tried to make a belt out of watches. It was a waist of time.',
"Why can't Harry Potter tell the difference between his potion pot and his best friend? They're both cauld ron.",
"I'm afraid of negative numbers. I'll stop at nothing to avoid them.",
"Two antennas got married. The ceremony wasn't much, but the reception was excellent.",
'RIP boiling water. You will be mist.',
'I met a criminal with a bounty on his head. That was a weird place to keep paper towels.',
'I did a theatrical performance about puns. It was a play on words.',
'Knowing how to pick locks has opened a lot of doors for me.',
'I should have been sad when my flashlight batteries died, but I was delighted.',
"Santa Claus's elves are subordinate clauses.",
"I'm designing a reversible jacket. I'm excited to see how it turns out.",
];
let counter = 0;
const appendNewPun = () => {
const p = document.createElement('p');
p.innerHTML = puns[counter++] || "I'm all out of puns!";
document.querySelector('#pun-container').prepend(p);
}
const button = document.querySelector('button');
button.addEventListener('click', appendNewPun);
})();
</script>
</body>
</html>
view raw index.html hosted with ❤ by GitHub

测试文件:index.test.js

import { fireEvent, getByText } from '@testing-library/dom'
import '@testing-library/jest-dom/extend-expect'
import { JSDOM } from 'jsdom'
import fs from 'fs'
import path from 'path'
const html = fs.readFileSync(path.resolve(__dirname, './index.html'), 'utf8');
let dom
let container
describe('index.html', () => {
beforeEach(() => {
// Constructing a new JSDOM with this option is the key
// to getting the code in the script tag to execute.
// This is indeed dangerous and should only be done with trusted content.
// https://github.com/jsdom/jsdom#executing-scripts
dom = new JSDOM(html, { runScripts: 'dangerously' })
container = dom.window.document.body
})
it('renders a heading element', () => {
expect(container.querySelector('h1')).not.toBeNull()
expect(getByText(container, 'Pun Generator')).toBeInTheDocument()
})
it('renders a button element', () => {
expect(container.querySelector('button')).not.toBeNull()
expect(getByText(container, 'Click me for a terrible pun')).toBeInTheDocument()
})
it('renders a new paragraph via JavaScript when the button is clicked', async () => {
const button = getByText(container, 'Click me for a terrible pun')
fireEvent.click(button)
let generatedParagraphs = container.querySelectorAll('#pun-container p')
expect(generatedParagraphs.length).toBe(1)
fireEvent.click(button)
generatedParagraphs = container.querySelectorAll('#pun-container p')
expect(generatedParagraphs.length).toBe(2)
fireEvent.click(button)
generatedParagraphs = container.querySelectorAll('#pun-container p')
expect(generatedParagraphs.length).toBe(3)
})
})
view raw index.test.js hosted with ❤ by GitHub
import { fireEvent, getByText } from '@testing-library/dom'
import '@testing-library/jest-dom/extend-expect'
import { JSDOM } from 'jsdom'
import fs from 'fs'
import path from 'path'
const html = fs.readFileSync(path.resolve(__dirname, './index.html'), 'utf8');
let dom
let container
describe('index.html', () => {
beforeEach(() => {
// Constructing a new JSDOM with this option is the key
// to getting the code in the script tag to execute.
// This is indeed dangerous and should only be done with trusted content.
// https://github.com/jsdom/jsdom#executing-scripts
dom = new JSDOM(html, { runScripts: 'dangerously' })
container = dom.window.document.body
})
it('renders a heading element', () => {
expect(container.querySelector('h1')).not.toBeNull()
expect(getByText(container, 'Pun Generator')).toBeInTheDocument()
})
it('renders a button element', () => {
expect(container.querySelector('button')).not.toBeNull()
expect(getByText(container, 'Click me for a terrible pun')).toBeInTheDocument()
})
it('renders a new paragraph via JavaScript when the button is clicked', async () => {
const button = getByText(container, 'Click me for a terrible pun')
fireEvent.click(button)
let generatedParagraphs = container.querySelectorAll('#pun-container p')
expect(generatedParagraphs.length).toBe(1)
fireEvent.click(button)
generatedParagraphs = container.querySelectorAll('#pun-container p')
expect(generatedParagraphs.length).toBe(2)
fireEvent.click(button)
generatedParagraphs = container.querySelectorAll('#pun-container p')
expect(generatedParagraphs.length).toBe(3)
})
})
view raw index.test.js hosted with ❤ by GitHub

源文件概述

正如您在文件中看到的index.html,它没有什么特别之处。如果您是第一次学习如何创建一个简单的网页,那么您的结果很可能与此非常相似,其中包含一些基本的 HTML、CSS 和 JavaScript。为了简单起见,我将 CSS 和 JavaScript 内联到文件中,而不是链接到其他源文件。

JavaScript 创建一个双关语数组,为按钮添加一个点击事件监听器,然后在每次点击按钮时在屏幕上插入一个新的双关语。很简单,对吧?


深入测试文件

由于这是一篇关于测试的文章,所以测试文件是这里的关键。让我们一起来看看一些比较有趣的代码片段。


检索 HTML 文件

我遇到的第一个问题是如何将 HTML 文件导入到测试文件中。如果要测试 JavaScript 文件,通常会像这样导入要测试的文件中导出的方法:

import { methodA, methodB } from './my-source-file'
Enter fullscreen mode Exit fullscreen mode

但是,这种方法在我的情况下不适用于 HTML 文件。因此,我使用内置的fsNode 模块读取 HTML 文件并将其存储在变量中:

const html = fs.readFileSync(path.resolve(__dirname, './index.html'), 'utf8');
Enter fullscreen mode Exit fullscreen mode

创建 DOM

现在我有了一个包含文件 HTML 内容的字符串,我需要以某种方式渲染它。默认情况下,Jest 在运行测试时使用jsdom来模拟浏览器。如果需要配置 jsdom,也可以在测试文件中显式导入它,我就是这么做的:

import { JSDOM } from 'jsdom'
Enter fullscreen mode Exit fullscreen mode

然后,在我的beforeEach方法中,我使用 jsdom 来渲染我的 HTML,以便我可以对其进行测试:

let dom
let container

beforeEach(() => {
  dom = new JSDOM(html, { runScripts: 'dangerously' })
  container = dom.window.document.body
})
Enter fullscreen mode Exit fullscreen mode

在 jsdom 环境中运行脚本

要使其正常工作,最关键的部分包含在传递给 jsdom 的配置选项中:

{ runScripts: 'dangerously' }
Enter fullscreen mode Exit fullscreen mode

因为我已经告诉 jsdom 以危险方式运行脚本,它实际上会解释并执行index.html文件script标签中包含的代码。如果不启用此选项,JavaScript 代码将永远不会执行,因此测试按钮点击事件将无法进行。

我也喜欢过危险的生活

免责声明:请务必注意,切勿在此处运行不受信任的脚本。由于我控制着 HTML 文件及其中的 JavaScript,因此我认为这样做是安全的。但如果该脚本来自第三方,或者包含用户输入,则采用这种方式配置 jsdom 并不明智。


关键时刻

现在,完成上述设置后,当我运行 时yarn test,它……成功了!概念验证取得了巨大的成功,令人欣喜若狂。

通过测试

所有测试均通过

结论

那么,回到最初的问题:是否可以为不使用任何 UI 框架或开发人员工具的前端代码编写单元测试?

答案是:是的!

虽然我的演示应用程序肯定不能反映出生产就绪的应用程序的样子,但如果需要的话,以这种方式测试用户界面似乎是一个可行的选择。

感谢阅读!

鏂囩珷鏉ユ簮锛�https://dev.to/thawkin3/how-to-unit-test-html-and-vanilla-javascript-without-a-ui-framework-4io
PREV
2020 年最流行的 CSS 框架
NEXT
使用 Python 制作 Telegram 机器人课程大纲设置 Telegram 机器人 Telegram 机器人库