测试驱动开发实践:JavaScript 字符串的复制函数

《测试驱动的JavaScript开发》一书第四章中提到「从测试中学习」,这是一个很值得推荐的学习方式。如果是为自己的应用代码设计断言编写测试,可以让我们在动手写代码之前清楚我们到底需要做什么,并且之后的重构可以有保证的进行;如果是为他人的应用代码设计断言编写测试,那将是很好的机会去学习他人的代码编写方式。

比如学习流行的 JavaScript 库的代码(例如 underscore.js )的时候可以采用这种方法。为 underscore.js 的函数编写测试,让测试通过,然后再自己着手编写函数,再进行测试;然后再比对 underscore.js 里函数的实现。此方法一方面可以练习编程能力,同时可以学习流行库里久经考验的写法。

继上文「Qunit快速上手」之后,本文将展示一次测试驱动开发实践:实现一个复制字符串本身的函数。采用的单元测试工具是 QUnit

测试驱动开发遵循四个迭代步骤

  1. 编写测试代码
  2. 运行测试,观察测试失败
  3. 使测试通过
  4. 重构

步骤一、测试驱动,先编写测试

JavaScript 字符串类(String)并未提供一个复制字符串本身的方法。我们要实现的是复制字符串本身,即函数应该可以将 "abcd" 复制成 n * "abcd",其中 n 代表要复制的次数。从代码的角度来看就是:

1
"abcd".duplicate(2); // "abcdabcd"

同时,当 n 的值小于等于 1 时,仅返还字符串本身。

1
"abcd".duplicate(0); // "abcd"

这便是我们的断言。明确了函数的目标之后,我们就可以编写测试脚本(假定测试脚本名称为 string-test.js,并且与源码 string.js 在同一目录下):

1
2
3
4
5
6
7
8
9
10
test("Function: duplicate", function() {
// 当 n <= 1 时
equal("abcd".duplicate(-1), "abcd", "It should be 'abcd' when n &lt; 1");
equal("abcd".duplicate(0), "abcd", "It should be 'abcd' when n &lt; 1");
equal("abcd".duplicate(1), "abcd", "It should be 'abcd' when n = 1");

// 复制字符串 2 次、3次
equal("abcd".duplicate(2), "abcdabcd", "It should be 'abcdabcd');
equal("abcd".duplicate(3), "abcdabcdabcd", "It should be 'abcdabcdabcd');
});

我们设计了 5 个断言。其中,前三个断言测试当 n 小于 1 的情况;最后两个测试是否正确的对字符串进行了 n 次复制。

步骤二、运行测试,观察测试失败

这一步将确认测试用例,确保测试用例的准确性。在执行测试的 html 中(假设是 tests.html )引用测试脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>QUnit Example</title>
<link rel="stylesheet" href="qunit/qunit.css">
</head>

<body>
<div id="qunit"></div>
<div id="qunit-fixture"></div>
<script src="qunit/qunit.js"></script>
<!-- 源文件 -->

<!-- 测试脚本 -->
<script src="string-test.js"></script>
</body>
</html>

在浏览器中打开 tests.html 执行测试,可以看到测试失败了,因为我们还没有编写源代码。

步骤三、使测试通过

有了测试的保证,此时,我们可以采用最为直接的方式来实现功能(假定源代码文件为 string.js):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(function(){
if (typeof String.prototype.duplicate !== "function") {
String.prototype.duplicate = function (n) {
var duplicated = "";

if (n <= 1) {
return this.toString();
}

for (var i = 0; i < n; i++) {
duplicated += this;
}

return duplicated;
};
}
}());

新建一个字符串 duplicated ,循环 n 次,每次循环中添加待复制字符串(this)到 duplicated 中。

源代码编写完成后,在 tests.html 中引用源代码文件并在浏览器中运行测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>QUnit Example</title>
<link rel="stylesheet" href="qunit/qunit.css">
</head>

<body>
<div id="qunit"></div>
<div id="qunit-fixture"></div>
<script src="qunit/qunit.js"></script>

<!-- 源文件 -->
<script src="string.js"></script>

<!-- 测试脚本 -->
<script src="string-test.js"></script>
</body>
</html>

如果有遇到断言失败,说明源代码中逻辑存在问题,需要进行检查修改。

步骤四、重构

回看第三步的代码,在循环中每次让 duplicated 加上待复制字符串 this 。而 JavaScript 字符串的相加操作按照以下步骤进行:

  1. 创建存储 this 的字符串
  2. 创建存储 duplicated 当前值的字符串
  3. 创建存储 this + duplicated 结果的字符串
  4. 把 this 复制到结果字符串中
  5. 把 duplicated 当前的内容复制到结果字符串中
  6. 更新 duplicated,使他指向结果字符串

从步骤上可以看出字符串相加操作的效率偏低。所以考虑到数组的 join 方法,将代码重构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(function () {
if (typeof String.prototype.duplicate !== "function") {
String.prototype.duplicate = function (n) {
var duplicated = [];

if (n <= 1) {
return this.toString();
}

for (var i = 0; i < n; i++) {
duplicated.push(this);
}

return duplicated.join("");
};
}
}());

重构的代码中,新建一个数组,循环n次将待复制的字符串添加到数组中,最后通过数组的 join 。再次运行测试,保证重构可以通过测试。

完成一次测试驱动迭代

到这里就是一个测试驱动开发的完整迭代:编写测试——运行测试,观察失败——使测试通过——重构。

后续

后续对于 duplicate 函数的修改都可以在 string-test.js 中用例的保证下进行安全的重构。比如我们再观察步骤四中重构后的代码:将待复制字符串作为数组的元素并采用数组的 join 方法将字符串连接以达到复制的目的。换种思路,如果我们能够将待复制字符串作为 join 方法的参数,那么只要新建一个长度为 n + 1 的数组就可以达到复制的目的。相比使用循环添加元素到数组中,后者从算法和代码量都可以达到精简!最终版本的 duplicated 方法如下:

1
2
3
4
5
6
7
(function () {
if (typeof String.prototype.duplicate !== "function") {
String.prototype.duplicate = function (n) {
return n <= 1 ? this.toString() : new Array(n+1).join(this);
};
}
}());

运行测试,确保重构的代码能够通过测试。