本文包含在我们的选集Modern JavaScript中 。 如果您希望所有内容都集中在一个地方,以加快使用现代JavaScript的速度,请注册SitePoint Premium并下载一个副本。
您可能已经熟悉自动测试及其好处。 为您的应用程序提供一组测试,使您可以放心地更改代码,因为只要您破坏了任何测试,测试就可以为您效劳。 在编写代码之前,可以更进一步并编写测试。 一种称为测试驱动开发(TDD)的实践。
在本教程中,我们将讨论TDD是什么以及它为开发人员带来的好处。 我们将使用TDD来实现表单验证器,以确保用户输入的任何值都符合一组指定的规则。
请注意,本文将重点介绍测试前端代码。 如果您正在寻找针对后端的内容,请务必查看我们的课程: Node.js中的测试驱动开发
什么是TDD?
测试驱动的开发是一种编程方法,利用它可以解决代码单元的设计,实现和测试,并在某种程度上解决程序的预期功能。
补充极限编程的测试优先方法 ,在这种方法中,开发人员在实现功能或单元之前先编写测试,而TDD还可简化代码的重构。 这通常称为“ 红绿色重构循环” 。
编写失败的测试 –编写调用您的逻辑并断言产生正确行为的测试
- 在单元测试中,这将是断言函数的返回值或验证是否按预期方式调用了模拟的依赖项。
- 在功能测试中,这将确保UI或API在许多操作中行为可预测
使测试通过 –实施导致测试通过的最少代码量,并确保所有其他测试继续通过
重构实现 –在不违反任何公共合同的情况下更新或重写实现,以提高其质量而又不破坏新的和现有的测试
自从我在职业生涯初期就开始使用TDD以来,就已经在某种程度上使用过TDD,但是随着我在开发具有更复杂要求的应用程序和系统方面的进步,我个人发现该技术既省时又有益对我的工作的质量和稳定性。
在继续之前,可能需要熟悉一些可以编写的各种类型的自动测试。 埃里克·艾略特(Eric Elliot) 总结得很好 :
- 单元测试 –确保应用程序的各个单元(例如功能和类)按预期工作。 断言测试所述单元返回任何给定输入的预期输出
- 集成测试 –确保单元协作按预期进行。 断言可能会测试可能导致副作用(例如数据库I / O,日志记录等)的API,UI或交互。
- 端到端测试 –确保从用户的角度来看软件能够按预期工作,并且确保每个单元在系统的整体范围内均正常运行。 断言主要测试用户界面
测试驱动开发的好处
立即测试覆盖
通过在功能实现之前编写测试用例,可以立即保证代码覆盖率,而且行为错误可以在项目的开发生命周期中更早地捕获。 当然,这需要涵盖所有行为的测试,包括错误处理,但始终应该以这种思维方式来实践TDD。
信心十足地重构
参考上面的红绿色重构循环,可以通过确保现有测试继续通过来验证对实现的任何更改。 编写尽快运行的测试将缩短此反馈循环。 尽管涵盖所有可能的场景很重要,并且不同计算机之间的执行时间可能会略有不同,但是编写精益和重点突出的测试将长期节省时间。
合同设计
测试驱动的开发使开发人员可以考虑如何使用API,以及如何使用API,而不必担心实现。 在测试用例中调用一个单元实质上反映了生产中的呼叫站点,因此可以在实现阶段之前修改外部设计。
避免多余的代码
只要在更改关联的实现时频繁或什至自动运行测试,满足现有测试就可以减少不必要的附加代码的可能性,可以说可以使代码库更易于维护和理解。 因此,TDD帮助人们遵循KISS(保持简单,愚蠢!)原则 。
不依赖整合
在编写单元测试时,如果一个单元符合要求的输入,那么一旦集成到代码库中,单元将表现出预期的效果。 但是,还应该编写集成测试以确保正确调用新代码的调用站点。
例如,让我们考虑下面的函数,该函数确定用户是否是管理员:
'use strict'
function isUserAdmin(id, users) {
const user = users.find(u => u.id === id);
return user.isAdmin;
}
我们期望将其作为参数,而不是对用户数据进行硬编码。 这使我们可以在测试中通过预填充的数组:
const testUsers = [
{
id: 1,
isAdmin: true
},
{
id: 2,
isAdmin: false
}
];
const isAdmin = isUserAdmin(1, testUsers);
// TODO: assert isAdmin is true
这种方法允许与系统的其余部分隔离地实施和测试该单元。 一旦我们的数据库中有用户,我们就可以集成该单元并编写集成测试以验证我们是否正确地将参数传递给该单元。
使用JavaScript进行测试驱动的开发
随着用JavaScript编写的全栈软件的出现,出现了许多测试库,可以测试客户端和服务器端代码。 此类库的一个示例是Mocha ,我们将在练习中使用它。
我认为,TDD的一个好用例是表单验证。 这是一个比较复杂的任务,通常遵循以下步骤:
- 从
<input>
读取应验证的值 - 针对所述值调用规则(例如,字母,数字)
- 如果无效,请向用户提供有意义的错误
- 重复下一个有效输入
此练习有一个CodePen ,其中包含一些样板测试代码以及一个空的validateForm
函数。 在开始之前,请先分叉。
我们的表单验证API将采用HTMLFormElement
( <form>
)的实例,并验证每个具有data-validation
属性的输入,其可能的值为:
-
alphabetical
–英文字母的26个字母的任何不区分大小写的组合 -
numeric
– 0到9之间的numeric
任意组合
我们将编写一个端到端测试,以针对真实的DOM节点以及最初将支持的两种验证类型来验证validateForm
的功能。 一旦第一个实现工作成功,我们将遵循TDD,通过编写更小的单元来逐步重构它。
这是我们的测试将使用的形式:
<form class="test-form">
<input name="first-name" type="text" data-validation="alphabetical" />
<input name="age" type="text" data-validation="numeric" />
</form>
在每次测试之间,我们都会创建该表格的新克隆,以消除潜在副作用的风险。 传递给cloneNode
的true
参数可确保还克隆表单的子节点:
let form = document.querySelector('.test-form');
beforeEach(function () {
form = form.cloneNode(true);
});
编写我们的第一个测试用例
describe('the validateForm function', function () {})
套件将用于测试我们的API。 在内部函数中,编写第一个测试用例,这将确保字母和数字规则的合法值都将被识别为有效:
it('should validate a form with all of the possible validation types', function () {
const name = form.querySelector('input[name="first-name"]');
const age = form.querySelector('input[name="age"]');
name.value = 'Bob';
age.value = '42';
const result = validateForm(form);
expect(result.isValid).to.be.true;
expect(result.errors.length).to.equal(0);
});
将更改保存到派生后,您应该看到测试失败:
现在让这个测试变成绿色! 请记住,我们应该努力编写最少,合理(不return true;
!)的代码以满足测试要求,因此,现在就不必担心错误报告。
这是初始实现,该实现遍历表单的input
元素并使用正则表达式验证每个元素的值:
function validateForm(form) {
const result = {
errors: []
};
const inputs = Array.from(form.querySelectorAll('input'));
let isValid = true;
for (let input of inputs) {
if (input.dataset.validation === 'alphabetical') {
isValid = isValid && /^[a-z]+$/i.test(input.value);
} else if (input.dataset.validation === 'numeric') {
isValid = isValid && /^[0-9]+$/.test(input.value);
}
}
result.isValid = isValid;
return result;
}
现在您应该看到我们的测试通过了:
错误处理
在我们的第一个测试之下,让我们编写另一个测试,以验证当字母字段无效时,返回result
对象的error
数组包含带有预期消息的Error
实例:
it('should return an error when a name is invalid', function () {
const name = form.querySelector('input[name="first-name"]');
const age = form.querySelector('input[name="age"]');
name.value = '!!!';
age.value = '42';
const result = validateForm(form);
expect(result.isValid).to.be.false;
expect(result.errors[0]).to.be.instanceof(Error);
expect(result.errors[0].message).to.equal('!!! is not a valid first-name value');
});
保存CodePen分支后,您应该在输出中看到新的失败测试用例。 让我们更新实现以同时满足两个测试用例:
function validateForm(form) {
const result = {
get isValid() {
return this.errors.length === 0;
},
errors: []
};
const inputs = Array.from(form.querySelectorAll('input'));
for (let input of inputs) {
if (input.dataset.validation === 'alphabetical') {
let isValid = /^[a-z]+$/i.test(input.value);
if (!isValid) {
result.errors.push(new Error(`${input.value} is not a valid ${input.name} value`));
}
} else if (input.dataset.validation === 'numeric') {
// TODO: we'll consume this in the next test
let isValid = /^[0-9]+$/.test(input.value);
}
}
return result;
}
现在让我们添加一个测试,该测试断言数字验证错误已正确处理:
it('should return an error when an age is invalid', function () {
const name = form.querySelector('input[name="first-name"]');
const age = form.querySelector('input[name="age"]');
name.value = 'Greg';
age.value = 'a';
const result = validateForm(form);
expect(result.isValid).to.be.false;
expect(result.errors[0]).to.be.instanceof(Error);
expect(result.errors[0].message).to.equal('a is not a valid age value');
});
见证测试失败后,请更新validateForm
函数:
} else if (input.dataset.validation === 'numeric') {
let isValid = /^[0-9]+$/.test(input.value);
if (!isValid) {
result.errors.push(new Error(`${input.value} is not a valid ${input.name} value`));
}
}
最后,让我们添加一个测试以确保处理多个错误:
it('should return multiple errors if more than one field is invalid', function () {
const name = form.querySelector('input[name="first-name"]');
const age = form.querySelector('input[name="age"]');
name.value = '!!!';
age.value = 'a';
const result = validateForm(form);
expect(result.isValid).to.be.false;
expect(result.errors[0]).to.be.instanceof(Error);
expect(result.errors[0].message).to.equal('!!! is not a valid first-name value');
expect(result.errors[1]).to.be.instanceof(Error);
expect(result.errors[1].message).to.equal('a is not a valid age value');
});
考虑到我们针对第二个和第三个测试的错误处理实现,这种新情况应立即通过。 您可以通过针对我的实施验证来确认您已正确执行了这些步骤。
重构我们的验证器
尽管我们有一个包含测试的工作功能,但它会发出许多代码气味:
多重职责
紧耦合
- 我们当前的实现方式使上述职责相互交织,使得对每个关注点的更新变得脆弱; 如果我们引入问题,则对大型方法的一个细节进行更改将使调试变得困难
- 此外,我们不能在不更新
if
语句的情况下添加或更改验证规则。 这违反了SOLID的开放/封闭原则
逻辑重复 –如果我们希望更新错误消息的格式,或将另一个对象推送到数组中,则必须在两个位置进行更新
幸运的是,当我们为验证器函数编写了功能测试时,我们可以使我们的代码变得更好,并且充满信心,我们不会破坏它。
让我们使用TDD为以下功能编写单独的函数:
- 将我们的输入映射到验证查询
- 从适当的数据结构中读取我们的验证规则
createValidationQueries
函数
通过将HTMLInputElement
的NodeList
映射到表示表单字段名称,应针对其进行验证的类型以及所述字段的值的对象,不仅可以将validateForm
函数与DOM分离,而且还可以促进验证当我们替换硬编码的正则表达式时进行规则查找。
例如, first-name
字段的验证查询对象将是:
{
name: 'first-name',
type: 'alphabetical',
value: 'Bob'
}
在validateForm
函数上方,创建一个名为createValidationQueries
的空函数。 然后, 在validateForm
的describe
套件之外 ,创建另一个名为“ createValidationQueries函数”的describe suite
。
它应包括单个测试用例:
describe('the createValidationQueries function', function () {
it(
'should map input elements with a data-validation attribute to an array of validation objects',
function () {
const name = form.querySelector('input[name="first-name"]');
const age = form.querySelector('input[name="age"]');
name.value = 'Bob';
age.value = '42';
const validations = createValidationQueries([name, age]);
expect(validations.length).to.equal(2);
expect(validations[0].name).to.equal('first-name');
expect(validations[0].type).to.equal('alphabetical');
expect(validations[0].value).to.equal('Bob');
expect(validations[1].name).to.equal('age');
expect(validations[1].type).to.equal('numeric');
expect(validations[1].value).to.equal('42');
}
);
});
一旦看到失败,请编写实现代码:
function createValidationQueries(inputs) {
return Array.from(inputs).map(input => ({
name: input.name,
type: input.dataset.validation,
value: input.value
}));
}
当这通过时,更新validateForm
的for
循环以调用我们的新函数,并使用查询对象来确定表单的有效性:
for (let validation of createValidationQueries(form.querySelectorAll('input'))) {
if (validation.type === 'alphabetical') {
let isValid = /^[a-z]+$/i.test(validation.value);
if (!isValid) {
result.errors.push(new Error(`${validation.value} is not a valid ${validation.name} value`));
}
} else if (validation.type === 'numeric') {
let isValid = /^[0-9]+$/.test(validation.value);
if (!isValid) {
result.errors.push(new Error(`${validation.value} is not a valid ${validation.name} value`));
}
}
}
如笔中所示 ,如果我们的新测试和现有测试都通过了,那么我们可以做出更大的改变; 解耦验证规则。
validateItem
函数
为了删除我们的硬编码规则,让我们编写一个函数,将规则作为Map
并声明输入的有效性。
像createValidationQueries
一样,我们将在实现之前编写一个新的测试套件。 在validateForm
的实现上方,编写一个名为validateItem
的空函数。 然后在我们的主要describe
套件中,为新添加的内容编写另一个describe
套件:
describe('the validateItem function', function () {
const validationRules = new Map([
['alphabetical', /^[a-z]+$/i]
]);
it(
'should return true when the passed item is deemed valid against the supplied validation rules',
function () {
const validation = {
type: 'alphabetical',
value: 'Bob'
};
const isValid = validateItem(validation, validationRules);
expect(isValid).to.be.true;
}
);
});
我们要明确地将规则Map
从测试传递给我们的实现,因为我们想要独立于主要功能来验证其行为; 这使其成为单元测试。 这是我们的validateItem()
第一个实现:
function validateItem(validation, validationRules) {
return validationRules.get(validation.type).test(validation.value);
}
测试通过后,编写第二个测试用例,以验证验证查询无效时我们的函数返回false
; 由于我们当前的实现,这应该通过:
it(
'should return false when the passed item is deemed invalid',
function () {
const validation = {
type: 'alphabetical',
value: '42'
};
const isValid = validateItem(validation, validationRules);
expect(isValid).to.be.false;
}
);
最后,编写一个测试用例,以确定未找到验证类型时validateItem
返回false
:
it(
'should return false when the specified validation type is not found',
function () {
const validation = {
type: 'foo',
value: '42'
};
const isValid = validateItem(validation, validationRules);
expect(isValid).to.be.false;
}
);
我们的实现应在对相应的正则表达式测试任何值之前,先检查validationRules
映射中是否存在指定的验证类型:
function validateItem(validation, validationRules) {
if (!validationRules.has(validation.type)) {
return false;
}
return validationRules.get(validation.type).test(validation.value);
}
看到测试通过后,让我们在createValidationQueries
上方创建一个新Map
,其中将包含我们的API使用的实际验证规则:
const validationRules = new Map([
['alphabetical', /^[a-z]+$/i],
['numeric', /^[0-9]+$/]
]);
最后,让我们重构validateForm
函数以使用新函数和规则:
function validateForm(form) {
const result = {
get isValid() {
return this.errors.length === 0;
},
errors: []
};
for (let validation of createValidationQueries(form.querySelectorAll('input'))) {
let isValid = validateItem(validation, validationRules);
if (!isValid) {
result.errors.push(
new Error(`${validation.value} is not a valid ${validation.name} value`)
);
}
}
return result;
}
希望您会看到所有测试都通过了。 祝贺您使用测试驱动的开发来重构和提高我们的代码质量! 您的最终实现应类似于此Pen :
见笔TDD表单验证完全由SitePoint( @SitePoint上) CodePen 。
包起来
通过遵循TDD,我们已经能够进行表单验证的初始实施并将其分为独立且易于理解的部分。 我希望您喜欢本教程,并将此实践带入日常工作中。
您是否在实际项目中使用过TDD? 你觉得呢? 如果不是,这篇文章是否已说服您尝试一下? 在评论中让我知道!
如果您想了解更多有关JavaScript的TDD的信息 ,请查看我们简短的迷你课程Test-Driven Development withNode.js 。
本文由Vildan Softic进行同行评审。 感谢所有SitePoint的同行评审人员使SitePoint内容达到最佳状态!
From: https://www.sitepoint.com/learning-javascript-test-driven-development-by-example/