如何在没有 UI 框架的情况下对 HTML 和 Vanilla JavaScript 进行单元测试
最近我对一些事情很好奇:是否可以为不使用任何类型的 UI 框架或开发人员工具的前端代码编写单元测试?
换句话说,没有 React、Angular 或 Vue。没有 webpack 或 rollup。没有任何构建工具。只有一个普通的index.html
文件和一些原生 JavaScript。
这样的设置可以测试吗?
本文及其附带的 GitHub repo就是该问题的结果。
先前经验
在我的职业生涯中,我做过不少测试。我主要是一名前端软件工程师,因此我的专业领域包括使用 Jest 作为测试框架编写单元测试,以及在使用 React 时使用Enzyme或React Testing Library作为测试库。我还使用Cypress或Selenium进行过端到端测试。
我通常选择使用 React 构建用户界面。几年前,我开始使用 Enzyme 来测试这些界面,但后来我更倾向于使用 React 测试库,并秉持着“测试应用应该以用户使用应用的方式进行,而不是测试实现细节”的理念。
Kent C. Dodds 的 React 测试库建立在他的DOM 测试库之上,顾名思义,DOM 测试库是一个帮助你测试 DOM 的库。我觉得这可能是一个很好的起点。
初步研究
在软件工程的世界里,很少有人能像你一样率先尝试。几乎所有事情都已经有人以各种形式做过了。因此,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> |
<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> |
测试文件: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) | |
}) | |
}) |
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) | |
}) | |
}) |
源文件概述
正如您在文件中看到的index.html
,它没有什么特别之处。如果您是第一次学习如何创建一个简单的网页,那么您的结果很可能与此非常相似,其中包含一些基本的 HTML、CSS 和 JavaScript。为了简单起见,我将 CSS 和 JavaScript 内联到文件中,而不是链接到其他源文件。
JavaScript 创建一个双关语数组,为按钮添加一个点击事件监听器,然后在每次点击按钮时在屏幕上插入一个新的双关语。很简单,对吧?
深入测试文件
由于这是一篇关于测试的文章,所以测试文件是这里的关键。让我们一起来看看一些比较有趣的代码片段。
检索 HTML 文件
我遇到的第一个问题是如何将 HTML 文件导入到测试文件中。如果要测试 JavaScript 文件,通常会像这样导入要测试的文件中导出的方法:
import { methodA, methodB } from './my-source-file'
但是,这种方法在我的情况下不适用于 HTML 文件。因此,我使用内置的fs
Node 模块读取 HTML 文件并将其存储在变量中:
const html = fs.readFileSync(path.resolve(__dirname, './index.html'), 'utf8');
创建 DOM
现在我有了一个包含文件 HTML 内容的字符串,我需要以某种方式渲染它。默认情况下,Jest 在运行测试时使用jsdom来模拟浏览器。如果需要配置 jsdom,也可以在测试文件中显式导入它,我就是这么做的:
import { JSDOM } from 'jsdom'
然后,在我的beforeEach
方法中,我使用 jsdom 来渲染我的 HTML,以便我可以对其进行测试:
let dom
let container
beforeEach(() => {
dom = new JSDOM(html, { runScripts: 'dangerously' })
container = dom.window.document.body
})
在 jsdom 环境中运行脚本
要使其正常工作,最关键的部分包含在传递给 jsdom 的配置选项中:
{ runScripts: 'dangerously' }
因为我已经告诉 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