通过单元测试写出高质量的前端代码

编写JavaScript有时候很痛苦。往往从一段简单而有趣的脚本,慢慢的就变得一团糟。我曾经发现我自己陷入到充满回调和耦合的大杂烩中。因此,我想必须寻找一种有效的方式解决这些问题。

简介

编写JavaScript有时候很痛苦。往往从一段简单而有趣的脚本,慢慢的就变得一团糟。我曾经发现我自己陷入到充满回调和耦合的大杂烩中。因此,我想必须寻找一种有效的方式解决这些问题。在这篇文章中,我想通过书写单元测试来探索这种更好的书写Javascript的方式。

我准备了一个demo来演示这种方式。通过精简不必要的逻辑,仅仅包含一个普通的带过滤功能的商品列表。没啥花哨的东西,但是能够表达出我了解到的这种产生高质量代码的解决方案的要点。

Download source code from Github

工具和环境搭建

我使用ASP.NET MVC 提供后端服务。在这个例子中,仅仅用到非常简单的数据库源,本文不会涉及后端相关的内容。

但是对于前端,我将使用Grunt和Mocha来编写单元测试。这个工具运行在node.js下面,因此需要在你的机器上安装相应环境。你可以在他们相关的网站上找到关于这些工具的介绍。所以这里也不便赘述。不必多说,但是你可能需要通过全局安装的方式安装grunt-cli 的npm 包。

下面是Gruntfile.js文件

module.exports = function (grunt) {
grunt.initConfig({
    pkg: grunt.file.readJSON('package.json'),
    simplemocha: {
        all: {
            src: ['Scripts/spec/**/*.js'],
            options: {
                ui: 'bdd',
                reporter: 'spec'
            }
        }
    }
});
grunt.loadNpmTasks('grunt-simple-mocha');
grunt.registerTask('default', ['simplemocha']);

};

以及package.json 文件:

{
"name": "WriteDomLessJavaScriptUnitTests",
"version": "0.1.0",
"devDependencies": {
    "grunt": "~0.4.5",
    "grunt-simple-mocha": "~0.4.0",
    "should": "~5.2.0"
}
}

到这里,我们已经准备好所有的单元测试环境。在控制台(译者ps:linux shell 或 windows 的命令提示符)输入 npm install 命令,这些应该很简单完成。确认你所在的目录和配置文件的目录相同。

用户界面

在我开始前,希望你能开始思考这个问题,怎样更好的组织这些代码。这个表格看起来如下:
ZU86Ly2.jpg

不需要什么设计的内容。我们的工作是让下拉框和按钮相应用户的操作。下拉菜单过滤由提交的条件和通过ajax刷新获取数据。Razor代码如下(PS:翻译到这里才发现使用的Razor,读者可以百度下,这里的代码是服务器端模板语言):

@model ProductAllViewModel
@{
    ViewBag.Title = "Demo";
    var departments = Model
        .Departments
        .Select(d => new SelectListItem { Value = d.Id.ToString(), Text = d.Name })
        .ToList();
    departments.Insert(0, new SelectListItem { Value = "0", Text = "All" });
}
@Html.DropDownList("departments", departments)
<button id="retrieve" data-url="@Url.Action("Index", "Product")">Retrieve</button>
<p>List of products:</p>
<table>
    <thead>
        <tr>
            <th>Name</th>
            <th>Price</th>
            <th>Department</th>
        </tr>
    </thead>
    <tbody id="products"
           data-products="@Json.Encode(Model.Products)"
           data-departments="@Json.Encode(Model.Departments)">
        @foreach (var product in Model.Products)
        {
            <tr data-department="@product.DepartmentId">
                <td>@product.Name</td>
                <td>@product.Price.ToString("c")</td>
                <td>@Model.Departments.First(x => x.Id == product.DepartmentId).Name</td>
            </tr>
        }
    </tbody>
</table>

正如你看到的这样,这些只是干净的html 代码,没有任何JavaScript,对代码保持分开,是出于一个很好的原因,后面你会了解到。

分而治之

看到Html,你能想象到JavaScript是怎么样的呢?我把产品列表中的数据对产品属性里面#products标签。注意我使用了ID作为排序时的参考。这是为了容易从DOM中查到该记录,但是实际中不应该这样写。

基本的思想是DOM是承载数据的一种结构。当你看到这种结构的时候,JavaScript本身应该从DOM拆解。
这样JavaScript就可以做好不依赖DOM元素的方式,这样更加易于测试和解耦。试着重新思考DOM,DOM是数据的元素,就像灵魂和肉体的关系。这样获得一个新的抽象层次,我们可以换以用一种新的方式思考问题。

describe('A product module', function () {
it('filters by department', function () {
    var list = [{ "DepartmentId": 1, "Name": "X" }, 
    { "DepartmentId": 2, "Name": "Y" }],
        result = product.findByDepartmentId(list, 2);
    result.length.should.equal(1);
    result[0].Name.should.equal('Y');
    result = product.findByDepartmentId(list, 0);
    result.length.should.equal(2);
});

});

单元测试帮我们更加明确的思考问题。测试驱动开发的目的是开发出更加好的代码。让我们通过测试编写出业务代码:

var product = (function () {
return {
    findByDepartmentId: function (list, departmentId) {
        return list.filter(function (prod) {
            return departmentId === 0 || prod.DepartmentId === departmentId;
        });
    }
};
}());
if (typeof module === 'object') {
    module.exports = product;
}

需要注意的是我使用了module.exports,因为我可以在Node.js使用相同的代码却不需要一个浏览器解析。我编写了了简单个filter()方法完成这些工作。

DOM事件测试

原来,DOM元素不需要在线JavaScript响应事件。从固定思维中跳出,DOM元素不需要和具体的业务逻辑结合得太过紧密,因此可以像下面这样:

(function () {
var departmentSelect = document.getElementById('departments'),
    retrieve = document.getElementById('retrieve'),
    elProducts = document.getElementById('products');

if (departmentSelect) {
    departmentSelect.addEventListener('change', function (e) {
        var departmentId = parseInt(e.currentTarget.value, 10),
            productList = JSON.parse(elProducts.dataset.products),
            departmentList = JSON.parse(elProducts.dataset.departments),
            filteredList = product.findByDepartmentId(productList, departmentId);
        elProducts.innerHTML = product.renderTable(filteredList, departmentList);
    });
}
}());

我是使用一个新的模块product.findByDepartmentId() 这样来封装具体的操作。这个组件能够通过测试即可,我们就可以知道它能正常工作。需要注意使用 需要检查departmentSelect DOM元素存在。为了检查是否存在,我使用了if来判断。如果找到了这个元素就动态的绑定一个元素。至于product.renderTable()呢?同样的需要用同样的方式检查,请继续向下看。

The DOM API
最后,elProducts.innerHTML获取到的必须是一个我们期望的字符串,因此我们可以编写单元测试:

it('renders a table', function () {
var products = [
        { "DepartmentId": 1, "Name": "X", "Price": 3.2 },
        { "DepartmentId": 2, "Name": "Y", "Price": 1.11 }],
    departments = [{ "Id": 1, "Name": "A" }, { "Id": 2, "Name": "B" }],
    html = '<tr><td>X</td><td>$3.20</td><td>A</td>' +
        '</tr><tr><td>Y</td><td>$1.11</td><td>B</td></tr>',
    result = product.renderTable(products, departments);
result.should.equal(html);
});

编写需要被测试的业务代码如下:

renderTable: function (products, departments) {
var html = '';
products.forEach(function (p) {
    var department;
    departments.forEach(function (d) {
        department = d.Id === p.DepartmentId ? d.Name : department;
    });
    html +=
        '<tr>' +
            '<td>' + p.Name + '</td>' +
            '<td>$' + p.Price.toFixed(2) + '</td>' +
            '<td>' + department + '</td>' +
        '</tr>';
});
return html;
}

Ajax
有的时候对于一个大而复杂的ajax应用,简直就是打BOSS(PS:游戏里面术语,你懂得)。我们依然需要想些办法保持良好的设计原则。往下看:

retrieve.addEventListener('click', function (e) {
var bustCache = '?' + new Date().getTime(),
    oReq = new XMLHttpRequest();
elProducts.innerHTML = 'loading...';
oReq.onload = function () {
    var data = JSON.parse(this.responseText),
        departmentId = parseInt(departmentSelect.value, 10),
        fileredList = product.findByDepartmentId(data.Products, departmentId);
    elProducts.innerHTML = product.renderTable(fileredList, data.Departments);
};
oReq.open('GET', e.currentTarget.dataset.url + bustCache, true);
oReq.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
oReq.send();
});

总之,这里没有必要增加一个新的模块的必要。这个product 模块的职责很明确,所以我能够重用这些代码。这里最巧妙的是所有的product 数据没有耦合到DOM上面,因此我可以灵活的运用并测试它。
现在使用grunt运行你的测试并且可以看到代表通过测试的绿色指示。
ZZZZtAW.jpg

从上面的图片中我们无法看出是否写出了干净的代码,但是每个干净的测试标签显示了每个模块具有清晰的职责。例如 本文中"A product filters by"就是你的代码职责的体现。在这个解决方案中通过简单的测试,我便能够找出那些前端模块具体做了什么。当然,不要奢望你能够把一些设计很糟糕的代码通过测试。

结论

我希望你能看到一个更好的组织代码的方案。在我的心目中,可测试的代码是编写良好的代码。尤其是在编写JavaScript这种很难组织的代码。有的时候,我浪费了很多时间在仅仅处理一个JavaScript问题,却把整个项目加载到浏览器中分析。所以,通过grunt的自动化单元测试是一个巨大的生产力的提高。

单元测试的背后思想可以让你避免在无用的地方花费大量时间,同时有更多的时间写代码。如果有兴趣,你可以在GitHub找到整个解决方案的代码。

版权声明

文章,以及附带的任何源码和文件,在Code Project Open License(CPOL)署名下。欢迎转载,但请保留文章版权声明以及译者、原文信息。

翻译

少个分号 http://www.printf.cn

原文地址

http://www.codeproject.com/Articles/989913/Exploring-TypeScript-and-What-Makes-It-Sweet