posted by Trần Phước Tú on 2014-01-15 00:13

Backbone là một javascript framework rất tốt để phát triển các ứng dụng phía client browser, cùng với cộng đồng sử dụng khá lớn và nhiều thư viện hỗ trợ, việc phát triển các ứng dụng javascript phức tạp không còn quá khó khăn.

Có thể tham khảo ở http://backbonejs.org/http://addyosmani.github.io/backbone-fundamentals/ để hiểu rõ hơn về backbone và xây dựng ứng dụng trên backbone

Sau khi bước đầu xây dựng ứng dụng và các chức năng thì việc quan trọng là phải viết các test case cho các chức năng đã làm để tìm lỗi, đảm bảo là chúng chạy tốt và ổn định.Vậy làm thế nào để test các ứng dụng backbone? Đáng tiếc là backbone không hỗ trợ hay đưa vào các chức năng cần thiết cho việc test, vậy ta phải sử dụng các javascript test framework và các thư viện hỗ trợ hiện có để test các app backbone.

Bài viết này hướng dẫn cách để test các ứng dụng backbone, và javascript app nói chung.

Điều đầu tiên: chúng ta cần các thư viện gì?

Hiện tại có rất nhiều framework dùng để test ứng dụng javascript, vd Qunit, Jasmine, Buster..., mỗi framework đều có thế mạnh riêng, việc lựa chọn ra framework tốt nhất là rất khó.

Vì vậy tiêu chí đặc ra khi lựa chọn là đơn giản, nhanh, dễ hiểu.Một framework đáng chú ý là Mocha

Mocha is a feature-rich JavaScript test framework running on node.js and the browser, making asynchronous testing simple and fun.

Mocha phù hợp cho cả test ứng dụng javascript client và server dựa trên nodejs.

Mocha hỗ trợ bất kỳ thư viện assertion nào, đặc biệt thư viện rất hay là should.js

Có các loại assertion style sau: assert, expect,should. Chúng đều tương đương nhau, loại style này có thể dễ dàng chuyển qua style kia, quan trọng là dùng loại style nào có thể đọc được dễ dàng.Should style là loại dễ đọc nhất, should.js là thư viện assertion hỗ trợ style này

Một ví dụ để thấy sự khác biệt của các loại assertion style này:

để kiểm tra đoạn mã đơn giản sau:

var a=true;

Assert style:

assert(a, true, “a should be true”);

Expect style:

expect(a).to.equal(true);

Should style:

a.should.to.be.true;

Trong quá trình test sẽ có những lúc mà ta muốn kiểm tra xem một function được gọi hay chưa hoặc mockup function nào đó thì có 1 tool rất hay hỗ trợ ta test những trường hợp như vậy là sinon.js

Sinon có các khái niệm spy, stubmock

1 spy là 1 function ghi lại các tham số, giá trị trả về... liên quan đến lần gọi của function

ví dụ: trong chương trinh của chúng ta có sử dung 1 biến model là 1 Backbone.Model, 1 chức năng nào đó muốn thay đổi các attributes của model này và sau đó gọi hàm save của model đê lưu vào database.Khi đó chúng ta muốn test xem hàm save của model có được gọi hay không thì có thể dùng spy để test như sau:

var spy_func=sinon.spy(model, “save”); //spy hàm save của model
//thực hiện chức năng
spy_func.should.be.calledOnce; //kiểm tra rằng hàm model.save() đã được gọi và chỉ gọi 1 lần duy nhất
spy_func.restore(); //retore lại hàm save hay xóa đi spy wrapper

1 stub cũng giống như 1 spy, nhưng thay vì chỉ đơn thuần ghi lại các tham số hay các lần gọi function thì nó sẽ thay thế function ở dưới được wrap, hoặc thay đổi hoạt động của function bằng cách return lại giá trị nào đó mong muốn

Lấy ví dụ, giống như trường hợp spy ở trên, nhưng ta không muốn data được lưu vào database(vì 1 lý do nào đó), ta chỉ muốn kiểm tra xem hàm save có được gọi hay không thì ta sẽ dùng stub ở trường hợp này, như sau:

var stub_func=sinon.stub(model, “save”);
stub_func.should.be.called; //kiểm tra rằng hàm save đã được gọi
stub_func.restore(); //restore lại function model.save() để nó hoạt động bình thường

Mock là sự kết hợp spy và stub lên 1 đối tượng

Ngoài ra Sinon còn có thể giả server return value về client

Để tham khảo thêm, vào trang http://sinonjs.org/docs/

 

Tiếp theo cần môi trường test hay test runner:

Chúng ta sẽ dùng Test’em

Test’em is a collection of handy scripts to set up and run a continuous test environment.

 

Tiếp theo: cài đặt các software cần thiết

  • Nodejs:
sudo apt-get install python-software-properties python g++ make
sudo add-apt-repository ppa:chris-lea/node.js
sudo apt-get update
sudo apt-get install nodejs
  • test'em + mocha
npm install mocha testem -g

Cuối cùng: chương trình demo

Với chương trình demo thì ta nên test 1 chương trình đơn giản, nhưng cũng cần phải bao hàm các khía cạnh thường thấy ở các app backbone

Theo đó ta sử dụng todo app download tại https://github.com/tastejs/todomvc/tree/gh-pages/dependency-examples/backbone_require

để làm chương trình cần test và thực hiện viết test code cho app đó

 

Todo app là chương trình

  • Xây dựng trên backbone
  • Sử dụng thư viện modular requirejs http://requirejs.org/
  • Lưu dữ liệu xuống localStorage(lưu xuống database cũng tương tự)
  • Sử dụng các chức năng, thành phần thường thấy ở 1 app backbone, bao gồm: Model, Collection, View, Router

Giao diện khi chạy:

 

Các khía cạnh cần test:

  • Test các chức năng thuộc Model
  • Test các chức năng thuộc Collection
  • Test các chức năng ở View

Chương trình demo có thể download tại đây

 

Mã nguồn test nằm trong folder src_test

Trong bài viết sẽ cover các khía cạnh chủ yếu hay gặp khi test

 

Giả sử toàn bộ mã nguồn nằm trong folder TODO_TEST

 

Config:

Trong file TODO_TEST/testem.json chứa testem config, là các thiết lập trước khi chạy test

{
  "framework": "mocha",
  "test_page": "testem.html",
  "src_files":[
    "src_test/*.js"
  ]
}

bao gồm:

  • framework sử dụng, ở đây là mocha
  • test_page: là init page, khi chạy test se sử dụng file này làm mồi
  • src_test: các file chứa test case
  1. Trang init testem.html:
<!doctype html>
<html>
<head>
<title>Test'em</title>
<link rel="stylesheet" href="/testem/mocha.css">
<script src="/testem/mocha.js"></script>
<script src="/testem.js"></script>
<script>mocha.setup('bdd')</script>
<script src="/vendor/require.js" data-main="main_test.js" ></script>
</head>
<body>
<div id="mocha"></div>
<div id="appview" style="display: none">
        <section id="todoapp">
            <header id="header">
                <h1>todos</h1>
                <input id="new-todo" placeholder="What needs to be done?" autofocus>
            </header>
            <section id="main">
                <input id="toggle-all" type="checkbox">
                <label for="toggle-all">Mark all as complete</label>
                <ul id="todo-list"></ul>
            </section>
            <footer id="footer"></footer>
        </section>
        <footer id="info">
            <p>Double-click to edit a todo</p>
            <p>Written by <a href="http://addyosmani.github.com/todomvc/">Addy Osmani</a></p>
            <p>Part of <a href="http://todomvc.com">TodoMVC</a></p>
        </footer>
</div>
</body>
</html>

 

Các điểm quan trọng:

  • Include các file js cần thiết để mocha và testem hoạt động
  • Phần html markup <div id=”appview”>, đây là nội dung phần main view copy từ TODO_TEST/index.html. Phần này cần thiết đê test view và các action trên view
  • <script src="/vendor/require.js" data-main="main_test.js" ></script>, cũng như bất kỳ app nào có sử dụng requirejs, ta đều cần 1 file js mồi của chương trình, trong viết test code cũng vậy, ta cần 1 file mồi để khởi tạo những object cần thiết và chạy test

     2. File mồi main_test.js:

require.config({
  baseUrl:'./js/',
  urlArgs: "v="+(new Date()).getTime(),
  shim: {
        underscore: {
            exports: '_'
        },
        backbone: {
            deps: [
                'underscore',
                'jquery'
            ],
            exports: 'Backbone'
        },
        backboneLocalstorage: {
            deps: ['backbone'],
            exports: 'Store'
        },
        sendkey: {
            deps: [
                'jquery',
                'bililiterange'
            ],
            exports: 'sendkey'
        },
        bililiterange: {
            exports: 'bililiterange'
        }
    },
    paths: {
        jquery: '../bower_components/jquery/jquery',
        underscore: '../bower_components/underscore/underscore',
        backbone: '../bower_components/backbone/backbone',
        backboneLocalstorage: '../bower_components/backbone.localStorage/backbone.localStorage',
        text: '../bower_components/requirejs-text/text',
        sendkey: '../../jquery.sendkeys',
        bililiterange: '../../bililiteRange'
    }
});

AppTest={};
AppTest.baseUrl='../';
AppTest.testSrc=AppTest.baseUrl+'src_test/';

var requireFiles=[
    AppTest.baseUrl+'vendor/chai',
    AppTest.baseUrl+'vendor/sinon',
    AppTest.baseUrl+'vendor/sinon-chai',
    AppTest.testSrc+'model_test',
    AppTest.testSrc+'collection_test',
    AppTest.testSrc+'view_test'
];



// Require libraries
require(requireFiles, function(
        chai, 
        sinon,
        sinonChai
){

  // Chai
  assert = chai.assert;
  should = chai.should();
  chai.use(sinonChai);
  
  console.log(sinon);
  

  // Mocha
  mocha.setup('bdd');

  // Require base tests before starting
  require([], function(){
    // Start runner
    mocha.run();
  });

});

Các điểm quan trọng:

  • +Config baseUrl chỉ tới folder js.

Tại sao phải làm vậy? Default thì baseUrl chỉ tới folder mà file mồi(ở đây là main_test.js đang đứng), nều để như thế thì các file source của chương trình todo app(nằm trong folder js) sẽ include sai đường dẫn(vì file mồi của todo app là js/main.js, theo đó baseUrl se chỉ tới folder js).Vì vậy cần phải config baseUrl của test và của app giống nhau

  • Phần còn lại của config thì copy từ file TODO_TEST/js/main.js qua(có thêm đôi chỗ).Phần config này cần thiết để requirejs nhận ra đường dẫn đến các thư viện
  • Require tất cả các file thư viện test cần dùng và khởi tạo chúng, đồng thời require tất cả các file source chứa test case để mocha chạy lần lượt từng test case trong từng file. Ở đây các file source chứa test case để trong folder src_test, bao gồm:
    • model_test.js: test todo model
    • collection_test.js: test todo collection
    • view_test.js: test phần view của chương trình
  • +Cuối cùng gọi mocha.run(), bắt đầu thực thi các test case

     3. Test model:

Todo model được định nghĩa trong TODO_TEST/js/models/todo.js

Todo model có function sau:

// Toggle the `completed` state of this todo item.
toggle: function () {
    this.save({
        completed: !this.get('completed')
    });
}

tức là sẽ toggle attribute completed khi gọi hàm toggle, sau đó save vào database.

Muốn test function này thì có 1 vấn đề là hàm save, bởi vì database ở đây chưa được định nghĩa, nếu gọi se báo lỗi, nhưng ta cần phải biết xem thử attribute completed có được thay đổi đúng không, cho nên chỗ này cần sử dụng các hàm của sinon

Ta sẽ test function này như sau:

it("should change completed flag to true when toggle method is called", function(){
    this.todo.get('completed').should.be.false;
            
    //stub the todo.sync method so data not actually save to storage
    var sync_stub=sinon.stub(this.todo, 'sync');
        
    this.todo.toggle();
            
    sync_stub.should.be.called;
            
    this.todo.get('completed').should.be.true;
            
    sync_stub.restore();
})

it() ở đây định nghĩa 1 test case, truyền vào cho nó 1 mesage mô tả test case này làm gì, và function chính thực hiện test. Trong function này ta gặp this.todo, thì biến này được định nghĩa ở:

beforeEach(function() {
    this.todo = new TodoModel();
})

beforeEach là function được thực thi trước mỗi test case, đi với nó thì có afterEach.Ngoài ra còn có before(), là function được thưc thi 1 lần duy nhất trước tất cả các test case của 1 test suite.

Test suite được định nghĩa như sau:

describe('Todo Model', function(){
    //test case 
});

trong mỗi test suite chứa nhiều test case, và cũng có thể chức các test suite con

Trở lại với test case ở trên, ta lần lượt thực hiện:

  • Kiểm tra completed = false
  • stub function sync của model todo, bước này quan trọng, hàm save của todo model sẽ gọi hàm sync để lưu dữ liệu xuống storage, nên ta sub phương thức này nhắm mục đích không để dữ liệu được lưu xuống.Tại sao ta không sub hàm save? Tại vì trong hàm save sẽ thay đổi completed attribute, ta muốn kiểm tra dữ liệu thay đổi này nên sub hàm sync là hợp lý nhất
  • Gọi hàm toggle
  • Kiểm tra rằng hàm sync đã được gọi, nếu sync đã được gọi tức save được gọi
  • Kiểm tra completed đã được chuyển thành true
  • restore lại hàm sync

Các vấn đề trọng điểm đã giải thích, xem source để biết rõ hơn.

 

     4. Test collection:

Todo collection được định nghĩa trong TODO_TEST/js/collections/todos.js

 

Trong Todo collection có override 1 function của backbone là:

comparator: function (todo) {
    return todo.get('order');
}

function này được dùng vào mục đích là sort các model trong collection theo thứ tự được lấy từ thuộc tính order của từng model.Chúng ta viết test case để test function này như sau:

it("the order of models in collection should be right", function(){
    var datas=[
        {
                id: 1,
                completed: false,
                order: 5
        },
        {
                id: 2,
                completed: true,
                order: 4
        },
        {
                id: 3,
                completed: false,
                order: 3
        },
        {
                id: 4,
                completed: true,
                order: 2
        },
        {
                id: 5,
                completed: false,
                order:1
        }               
    ];
    
    this.collection.reset(datas);
    var models=this.collection.models;
    models[0].get('id').should.to.be.equal(5);
    models[1].get('id').should.to.be.equal(4);
    models[2].get('id').should.to.be.equal(3);
    models[3].get('id').should.to.be.equal(2);
    models[4].get('id').should.to.be.equal(1);
})

Chúng ta chèn dữ liệu vào collection theo thứ tự ngược của order và sau đó kiểm tra lại thứ tự đúng phải ngược lại

Bài viết chỉ trích 1 phần ví dụ từ source code, xem source để biết rõ hơn.

 

     5. Test view:

Test view rất quan trọng và cũng rắc rối hơn, vì phải test các DOM element và test các giao tiếp với người.

Để thực hiện test phần view thì ta phải sử dụng các hàm của jquery, và thực hiện tạo các view ẩn để test.

 

Các view của Todo app được định nghĩa trong TODO_TEST/js/views. Có 2 view là Todo item view là phần view cho 1 todo model, định nghĩa trong todos.js và App view là phần view cho toàn bộ app trong app.js

 

Với Todo item view, khi render sẽ output ra 1 checkbox khi toggle sẽ thay đổi status completed, phần label chức title của model, ta sẽ test phần render này như sau:

Trước hết phải tạo TodoView, model và render phần view ra, ta sẽ đưa vào beforeEach:

beforeEach(function(){
        this.todoItem=new TodoModel({'title': 'test item'});
                
        this.todoItemView=new TodoView({model: this.todoItem});
                
        this.todoItemView.render();
})

sau khi render thì this.todoItemView.$el sẽ chứa các DOM của view

Ta tạo test case để kiểm tra phần label title:

it("should have label of title", function(){              
        this.todoItemView.$el.find('label').should.have.length(1);
})

ở đây ta dùng hàm find của jquery để tìm kiếm label trên $el của view và kiểm tra nó có mặt bằng cách dùng should.have.length(1)

 

Test case kiểm tra checkbox:

it("should toggle checkbox present", function(){
        this.todoItemView.$el.find('.toggle').should.have.length(1);
        this.todoItemView.$el.find('.toggle').is(':checked').should.be.false;
})

ngoài kiểm tra có mặt còn kiểm tra nó không được checked

 

Khi người dùng toggle checkbox thì thuộc tính completed của model phải được set thành true, ta kiểm tra điều này:

it("todo's completed attribute should be changed when toggled checkbox", function(){
        var toggleStub=sinon.stub(this.todoItem, 'sync');
        createFixtureDiv(this.todoItemView.$el);
        this.todoItemView.$el.find('.toggle').click();
        this.todoItem.get('completed').should.to.be.true;
        this.todoItem.sync.restore();
        removeFixtureDiv();
})

Vì phải giao tiếp với người sử dụng, nên ta phải đưa tất cả các DOM ở $el lên browser, để thực hiện điều này ta tạo 1 thể div ẩn để chứa các DOM thông qua hàm createFixtureDiv, hàm này như sau:

var createFixtureDiv=function(dom){
    $("<div>").attr("id","fixture").appendTo("body");
    $('#fixture').append(dom);
}

Tiếp đó thì click lên toggle checkbox và kiểm tra completed được set thành true, cuối cùng là removeFixtureDiv. Chú ý là cần phải sub hàm sync của model

Đó là các test case quan trọng cho phần item view. Tiếp đến ta xem test cho app view

 

App view có sử dụng các DOM ẩn ở trang testem.html, nó có 1 chức năng là filter, có 3 filter là All, Active và Completed.

All thì view ra tất cả các item

Active chỉ view ra những item chưa được hoàn thành(completed attribute = false)

Completed ngươc lại active

Những filter này thông qua Backbone router(có thể hiểu nôm na là thông qua thay đổi hash url của browser) để thực thi action và từ đó thực hiện trigger các event tương ứng, dẫn đến thay đổi data của collection, từ đó view ra những item tương ứng.

Sau đây là cách để ta thực hiện test chuỗi sự kiện khá phức tạp này

Trước hết ta cần tạo thực thể của router:  

before(function(){
    this.workspace=new Workspace();
    Backbone.history.start();
})

Todo app router được đinh nghĩa trong file TODO_TEST/js/routers/router.js

Sau khi tạo thực thể chúng ta start nó để Backbone có thể lằng nge các thay đổi trên hash url

Test case của chúng ta như sau:

it("test All, Active, Completed filter action", function(done){
        var stub=sinon.stub(this.collection, 'sync');
                
        this.appView.$el.find('#todo-list li').should.have.length(0);
        this.collection.reset([
            {
                    title: 'test 1',
                    completed: false
            },
            {
                    title: 'test 2',
                    completed: false
            },
            {
                    title: 'test 3',
                    completed: true
            }
        ]);
                
        this.appView.$el.find('#todo-list li').should.have.length(3);
                
        $('#filters').find('li:first').find('a').attr('class').should.equal('selected');
        
        this.appView.$el.find('#todo-list li.hidden').should.have.length(0);
                
        var self=this;
                
        var completedFilterTest=function(){
            self.appView.$el.find('#todo-list li.hidden').should.have.length(2);
            self.appView.$el.find('#todo-list li').not('.hidden').find('label').html().should.equal('test 3');
                    
            window.location.hash='';
                    
            stub.restore();
            done();
        }
                
        var activeFilterTest=function(){
            self.appView.$el.find('#todo-list li.hidden').should.have.length(1);
            self.appView.$el.find('#todo-list li.hidden').find('label').html().should.equal("test 3");                    
            //completed link click
            window.location.hash='#/completed';
                    
            setTimeout(function(){
                completedFilterTest();
            }, 50);
        }
                
        //active link click
                
        window.location.hash='#/active';
                
        setTimeout(function(){
            activeFilterTest();                 
        }, 50)
                
})

Trước hết ta phải set data cho collection của ViewApp, ở đây ta set 3 item, item 1, 2 chưa hoàn thành, item 3 đã hoàn thành.Ban đầu thì filter mặc định là All nên ta kiểm tra phải có đủ 3 item được hiển thị

this.appView.$el.find('#todo-list li').should.have.length(3);
this.appView.$el.find('#todo-list li.hidden').should.have.length(0); //không có item nào bị ẩn đi

Tiếp theo test xem thử với filter là Active thì có hiển thị ra đúng 2 item không, để trigger được dãy event như đã nói ở trên thì ta làm bằng cách là thay đổi location hash:

window.location.hash='#/active';

vì cần 1 độ delay nhỏ khi chuyển hash nên tạm delay 1 khoảng thời gian sau đó gọi hàm activeFilterTest()

setTimeout(function(){
        activeFilterTest();                 
}, 50)

Trong activeFilterTest ta thực hiện kiểm tra là có 1 item bị ẩn đi và item đó chính là test 3 thông qua:

self.appView.$el.find('#todo-list li.hidden').should.have.length(1);
self.appView.$el.find('#todo-list li.hidden').find('label').html().should.equal("test 3");

Tiếp đó thực hiện test Completed filter tương tự thông qua gọi hàm completedFilterTest()

Điều chú ý là trên định nghĩa test case:

it("test All, Active, Completed filter action", function(done){...

thì ta có truyền vào tham số done, đây là 1 function callback được định nghĩa bởi mocha

Tại sao ta cần sử dụng hàm này?

Để ý là trong test case của ta có sử dụng setTimeout, đây là hàm bất đồng bộ, nều bình thường không có tham số done truyền vào thì test case của ta sau khi thực hiện câu lệnh setTimeout dưới cùng thì sẽ thoát ngay mà không chờ 50ms để thực hiện function truyền vào bên trong setTimeout. Vì vậy có thể hiểu done ở đây là 1 đánh dấu để mocha hiểu rằng trong test case của ta có sử dụng các hàm bất đồng bộ.Nếu vậy thì phải có cách gì đó để cho mocha biết rằng test case của ta đã kết thúc(vì nều thực hiện các function bất đồng bộ như vậy thì mocha sẽ chờ mãi nếu không có dấu hiệu cho biết lúc nào kết thúc), ở đây ta có thể thấy hàm done được gọi ở cuối function completedFilterTest, đó là điểm đánh dấu kết thúc của test case.

 

Như vậy ta đã đi qua những ý chính của viêc viết test code

 

Còn 1 phần rất quan trọng nữa là integrate testem với CI server

Continuous Integration(CI) — designed to work well with popular CI servers like Jenkins or Teamcity

Testem có thể làm việc tốt với Jenkin, có thể tham khảo ở đây: https://github.com/airportyh/testem/blob/master/docs/use_with_jenkins.md

 

Như vậy ta đã tìm hiểu về javascript unit test với Mocha+Testem, được demo trên 1 Backbone Application.Bài viết chỉ view những mục chính, có thể xem source demo để biết rõ hơn.

 

Kết quả demo:

  • Change dir vào folder TODO_TEST
  • Thực hiện lệnh: testem
  • Mở browser(ở đây dùng chrome) và vào đường dẫn: http://localhost:7357/  

 

 

Qua bài viết chúng ta đã demo cách viết unit test với Backbone application, nhưng chúng ta có thể thực hiện trên bất kỳ 1 javascript app nào chạy trên trình duyệt web hoặc nền nodejs

 

Cuối cùng mong mọi ý kiến đóng góp!


One comment

#3
2014-01-15 11:35
Like:)
Như vậy là nghiệp vụ test với js đã clear nhỉ?. Kết hợp được với jenkins thật là tốt.
Unit test: PHPUnit
Functional test: Selenium
JS: Mocha+Testem
Kết hợp tốt với Jenkins.
Như vậy thì hoàn tất tất cả các logic test cho Web Application rồi nhỉ:)

Leave a Comment

Fields with * are required.