使用原生 JavaScript 进行单元测试:基础知识
由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!
在上一篇教程中,我介绍了 JavaScript 测试的基础知识,或者更确切地说,我阐述了 JavaScript 测试的概念和实现方法。但是,使用框架进行 JavaScript 测试效果会更好。因此,在本教程中,我将使用Jasmine(一个用于测试 JavaScript 代码的行为驱动开发框架)来测试一个简单的待办事项应用程序。
我发现,如果把它看作是为了给我们的测试提供结构和更强的鲁棒性,那就很容易理解了,尤其是与以前的普通方法相比。
项目设置
我们将构建一个基本的待办事项应用程序。它将包含两个组件:一个用于控制数据,另一个用于将数据注入 DOM。
为了简单起见,我们不使用任何构建工具。我们只需要四个文件:
index.html- 实际的应用程序将从这里渲染并提供给客户端。ToDo.js我们将在这里编写应用程序代码。SpecRunner.html测试结果将显示在此处。ToDoSpec.js我们将使用 Jasmine 来测试我们在这里编写的代码ToDo.js。
对于更大型的应用程序,我们当然会采用不同的文件结构,但为了简单起见,所有文件都放在根文件夹中。此外,在这里讨论 CSS 有点多余,但显然你会使用 CSS 来设置首页代码的样式。
这index.html部分内容将为空,所有内容都将通过 JavaScript 注入。
<!DOCTYPE html>
<html lang="en">
<head>
<title>Todo</title>
</head>
<body>
</body>
<script src="ToDo.js"></script>
</html>
同样,这里SpecRunner.html也是空的,但我们会链接到 Jasmine 文件,后面跟着 `<path>`ToDo.js和 ` <path> ToDoSpec.js`。原因是 `<path>`ToDoSpec.js需要读取 `<path>` 中的方法ToDo.js,以便检查它们是否按预期运行。
<!DOCTYPE html>
<html lang="en">
<head>
<title>Testing with Jasmine</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/jasmine/2.8.0/jasmine.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jasmine/2.8.0/jasmine.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jasmine/2.8.0/jasmine-html.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jasmine/2.8.0/boot.min.js"></script>
<script src="ToDo.js"></script>
<script src="ToDoSpec.js"></script>
</head>
<body>
</body>
</html>
以上就是样板代码部分。现在让我们来思考一下我们希望应用程序实现哪些功能。
我们需要应用程序完成的任务清单
以下是功能测试清单:
- 应该添加一个项目
- 应该删除一个项目
- 应将项目标记为已完成
测试 DOM 操作:
- 应该注入初始 HTML
- 应显示新项目
- 应该触发表单并将项目添加到待办事项数组中
本教程结束时,Jasmine 将跟踪上述清单,最终效果如下所示:
在研究过程中,我了解到了不同的测试方法。其中一种让我印象深刻的是“测试优先”方法。这意味着先编写测试,然后再实现能够通过测试的代码。但在编写本教程的代码时,我不得不两者兼顾。然而,无论采用哪种方法,我认为测试带来的直接好处之一是,它会迫使我们认真思考模块化问题。
茉莉花的基本结构
在我之前的教程中,我使用 if 语句来检查我的函数是否完成了我需要做的事情,Jasmine 也做了类似的事情,但方式如下:
describe('Testing the functionality, this is the checklist', ()=>{
it('should add an item', ()=>{
//...
})
it('should delete an item', ()=>{
//...
})
it('should mark item as complete', ()=>{
//...
})
})
请注意它与我们的检查清单和上面的屏幕截图是否一致。然后,我们将“测试 DOM 操作”测试归入另一个describe组。
现在让我们开始逐一攻克每个测试题。
应该添加一个项目测试和实现
待办事项列表将是一个对象,其原型中将包含用于修改待办事项列表的方法。
接下来ToDoSpec.js我们将开始第一次测试。
describe('Testing the functionality, this is the checklist', ()=>{
it('should add an item', ()=>{
let todo = new ToDo();
let item = {
title: "get milk",
complete: false
}
const done = todo.addTodo(item)
expect(todo.getItems().length).toBe(1);
})
})
在第一个测试中,我们尝试创建一个ToDo()对象实例,然后将一个硬编码的(伪造的)列表项对象传递给它todo.addTodo,接下来是最重要的部分:我们检查它是否成功,方法是检查我们的项是否被正确存储。简单来说,我们要求 Jasmine “期望”todo.getItems().length返回项数组的长度,并且该长度为1n(因为我们刚刚在一个原本为空的数组中添加了一个项)(此时我们并不关心它是否是一个数组,但它最终会是一个数组)。
在浏览器中打开SpecRunner.html。显然会收到一个错误提示,显示“ToDo 未定义”。
让我们通过那项测试
我们ToDoSpec.js正在尝试测试将存储在 . 的生产代码ToDo.js。所以打开该文件,让我们尝试修复测试中的错误。
测试首先尝试实例化ToDo对象。创建对象后,刷新SpecRunner.html浏览器页面。
function ToDo(){
this.todo = [];
}
现在它ToDoSpec.js试图运行todo.addTodo一个不存在的程序。
让我们编写通过测试所需的全部代码:
function ToDo(){
this.todo = [];
}
ToDo.prototype.addTodo= function(item){
this.todo.push(item)
}
ToDo.prototype.getItems= function(){
return this.todo
}
这样就通过了测试。我们有了addTodo方法getItems(也称为getter和setter)。
应该删除测试和实现项
每个测试和功能的实现都将遵循相同的模式:我们先创建测试,然后创建通过测试的方法。
it('should delete an item', ()=>{
let todo = new ToDo();
let item = {
id: 1,
title: "get milk 1",
complete: false
}
let item2 = {
id: 2,
title: "get milk 2",
complete: false
}
todo.addTodo(item)
todo.addTodo(item2)
todo.delete(2)
expect(todo.getItems()[todo.getItems().length-1].id).toBe(1);
})
为了测试删除功能,我们需要添加一个项目,然后能够将其删除。我们添加了两个项目,以测试该delete方法是否确实删除了我们想要删除的项目。
现在我们需要delete在另一边创建该方法。ToDo.js
ToDo.prototype.delete = function(id){
this.todo = this.todo.filter(item => item.id !== id)
}
正如我们在测试中计划的那样,我们筛选项目并删除未通过id测试的项目。
应将该项目标记为已完成测试和实施
我们希望能够将属性complete从更改false为true。为了确保操作正确,我正在添加项目并尝试将其中一个更改为完成(我越想越觉得这不是必需的,但它让我感觉更安心,因为它确实有效)。
it('should mark item as complete', function(){
let todo = new ToDo();
let item = {
id: 1,
title: "get milk 1",
complete: false
}
let item2 = {
id: 2,
title: "get milk 2",
complete: false
}
todo.addTodo(item)
todo.addTodo(item2)
todo.complete(2)
expect(todo.getItems().find(item => item.id == 2).complete).toBe(true);
})
id上述我们期望“ of”项的2属性complete设置为 true。
实际todo.complete方法如下:
ToDo.prototype.complete = function(id){
this.todo.find(item => item.id == id).complete = true;
}
重构代码
如您所见,我们ToDo在每次测试中都会初始化对象。Jasmine 允许我们在每次测试之前运行一些代码。
在所有测试的最前面,我们可以添加明显重复的代码。
describe('Testing the functionality, this is the checklist', ()=>{
let todo, item, item2;
beforeEach(function(){
todo = new ToDo();
item = {
id: 1,
title: "get milk 1",
complete: false
}
item2 = {
id: 2,
title: "get milk 2",
complete: false
}
})
//...
})
太棒了!当然,之后我们会从每个测试用例中删除那些重复的代码片段。
好了,我们计划在“功能测试”部分检查的所有测试都顺利通过了!
测试 DOM 操作
在这一批测试中,我们希望确保 DOM 注入能够按预期工作。
对于这组新的测试,我们使用了一种新describe方法。我们还利用该beforeEach方法实例化DomManipulation对象(我们需要创建它),并创建一个虚拟项(稍后会用到)。
describe('Testing DOM manipulation', function(){
let Dom, item, todo;
beforeEach(function(){
todo = new ToDo();
Dom = new DomManipulation();
item = {
complete: false,
id : 1,
title: 'some Title'
}
})
// it methods will go here ...
})
有趣的是,如果我们刷新浏览器,仍然指向同一个页面,即使页面不存在SpecRunner.html,也不会看到任何错误。这证明,只有在有测试用例的情况下,程序才会真正运行。让我们创建第一个测试用例。DomManipulationbeforeEach
应该初始化 HTML
如果你还记得的话,我们这里什么都没有index.html。我选择这种方法是为了测试这个框架。所以我们需要创建 DOM 节点。这是第一个测试。
it('should initialise HTML', function(){
const form = document.createElement('form');
const input = document.createElement('input')
const ul = document.createElement('ul')
input.id = "AddItemInput"
form.id="addItemForm"
form.appendChild(input);
expect(Dom.init().form).toEqual(form)
expect(Dom.init().ul).toEqual(ul)
})
上面我们要确保Dom.init()创建正确的 DOM 节点。请注意,我们可以有多个预期结果,我们希望Dom.init()生成一个表单和一个无序列表。
我们ToDo.js可以创建DomManipulation它的init方法
function DomManipulation(){}
DomManipulation.prototype.init = function(){
const form = document.createElement('form');
const input = document.createElement('input')
const ul = document.createElement('ul')
input.id = "AddItemInput"
form.id="addItemForm"
form.appendChild(input);
return {
form, ul
}
}
应该创建项目
当用户提交项目时,我们希望创建一个列表 DOM 元素。由于这是在测试元素的响应而不是表单提交,因此我们伪造了数据,假装它来自表单(item是我们通过方法创建的对象beforeEach)。
it('should create item', function(){
const element = Dom.displayItem(item);
const result = document.createElement('li');
result.innerText = item.title
expect(element).toEqual(result)
})
Dom.displayItem应该创建我们在测试中创建的完全相同的元素。所以让我们创建这个方法:
DomManipulation.prototype.displayItem = function(item){
const li = document.createElement('li');
li.innerText = item.title
return li;
}
应该触发表单并将项目添加到待办事项数组中
这是我最难接受的部分。我觉得这简直就是个骗局!
我们需要检查表单是否已提交,以及输入内容是否已添加到待办事项数组中(来自之前的实现)。
由于测试是自动化的,而且我们无法访问原始 DOM,所以表单、输入框和触发器都必须是模拟的!让我们来看一下测试。
it('should trigger form and add item to todo array', function(){
const form = document.createElement('form');
form.innerHTML= `<input value="get milk" />
<button type="submit" />`;
document.body.appendChild(form)
const ul = document.createElement('ul');
Dom.addTodoEvent(
form,
todo.addTodo.bind(todo),
ul)
form.getElementsByTagName('button')[0].click();
document.body.removeChild(form)
expect(todo.todo[0].title).toEqual('get milk')
})
我们创建表单和一个硬编码的输入框(用户原本需要添加这个输入框)。然后将表单注入到 DOM 中!这是触发事件的唯一方法。之后,我们运行Dom.addTodoEvent该函数,并将表单、todo.addTodo方法和一个无序列表传递给它。
最后,我们“伪造”表单提交,并从 DOM 中删除表单(否则在浏览器加载时就会看到它SpecRunner.html)。
最后,我们希望添加一个项目,其标题与我们添加到表单输入框中的标题相同。
我觉得一定有比这样添加和删除 DOM 元素更好的方法!
最后,让我们创建DomManipulation.prototype.addTodoEvent上述测试所期望的对象。
DomManipulation.prototype.addTodoEvent = function(form, createTodo, unorderedList){
const displayItem = this.displayItem;
const id = new Date().getUTCMilliseconds();
form.addEventListener('submit', function(e){
e.preventDefault();
const input = document.querySelector('input').value
const item = {complete: false,id : id, title: input}
createTodo(item);
unorderedList.appendChild(displayItem(item))
})
}
该addTodoEvent函数处理表单。它需要表单、处理表单输出的方法以及需要更改的 DOM。
结论
我非常喜欢这个方法。从长远来看,它能让添加功能或修改现有代码的过程变得轻松许多。而且,我越是采用“测试驱动开发”的方法,我的代码就会越模块化。不过,我仍然担心像上次测试那样添加和删除 DOM 元素会不会遗漏一些东西,您觉得呢?
文章来源:https://dev.to/aurelkurtula/unit-testing-with-vanilla-javascript-the-very-basics-7jm
