about 2 years ago

上一次我們完成了酷炫的單頁式的 CRUD 應用,這次我們來介紹如何再 Rails 中為你的 AngularJS code 做 Unit Test。

我們承接上一次完成的 Project 繼續往下做,一起來寫 AngularJS 的測試吧!!

環境設置

Jasmine - Behavior-Driven JavaScript

AngularJS 的官網教學上就是使用這套 JavaScript Testing Framework 幫忙做測試。

既然有很多人用,就自然而然會有人將它整合到 Rails 中:jasmine-rails (Github Repo),在 Gemfile 中我們加上:

group :test, :development do
  gem 'jasmine-rails'
end

並安裝:

$ bundle install
$ rails generate jasmine_rails:install

guard

只要偵測到 Project 下的檔案被更動,它就會幫你執行已經寫好的 test,應該有不少人在使用吧。

Gemfile 裡剛剛增加的 group :test, :development 中加上:

gem 'guard'

並安裝:

$ bundle install
$ bundle exec guard init

guard-jasmine

裝上這個,guard 會自動去呼叫 jasmine 做測試,在 Gemfile 裡剛剛增加的 group :test, :development 中加上:

gem 'guard-jasmine', :git => 'https://github.com/guard/guard-jasmine', :branch => 'jasmine-2'

由於 Jasmine 更新到 2.0,我們使用這個套件在 Github 上的新版本配合它的 API。

接者我們在 routes.rb 中增加:

mount JasmineRails::Engine => '/specs' if defined?(JasmineRails)

並且置換 Guardfile 中的:

guard :jasmine do

guard :jasmine, server: :webrick, server_mount: '/specs' do

angular-mocks

我們安裝 angular-mocks 來為 AngularJS 做測試,它是 AngularJS 的輔助測試工具,

Gemfile 中我們加上:

gem 'rails-assets-angular-mocks'
$ bundle install

開始寫測試

我們先在 spec/javascript 中增加這些檔案:

螢幕快照 2014-11-01 下午10.46.41.png

lib.js 中我們載入 angular-mocks 這個套件:

//= require angular-mocks

Unit Testing for indexCtrl

index_ctrl_spec.js 中,我們為 indexCtrl 來寫測試:

describe('indexCtrl', function(){

  beforeEach(module('languageApp'));

  it('should create "languages" model with empty array', inject(function($controller) {
    var scope = {},
        ctrl = $controller('indexCtrl', {$scope: scope});

    expect(scope.languages.length).toBe(0);
  }));

  it('should update "languages" model', inject(
    function($controller, $httpBackend) {

    var scope = {},
        ctrl = $controller('indexCtrl', {$scope: scope});

    $httpBackend.expectGET('/languages.json').respond([{name: 'Chinese', id: 1},
                                                       {name: 'English', id: 2}]);
    scope.update_languages();
    $httpBackend.flush();

    expect(scope.languages.length).toBe(2);
  }));
});

module('languageApp') 選定了 languageApp 這個 AngularJS App,

第一個 it 區塊中我們使用 $controller 來建立 indexCtrl 的物件,並測試他的建構是否正確

第二個 it 區塊中我們使用 $httpBackend ,造一個假介面給 $scope.update_languages() 中的 $http 使用,

並測試他是否正確執行這個 Request。

Unit Testing for listCtrl

list_ctrl_spec.js 中,我們為 listCtrl 來寫測試:

describe('listCtrl', function(){

  beforeEach(module('languageApp'));

  beforeEach(inject(function ($rootScope) {
    $rootScope.$apply(function() {
      $rootScope.update_languages = function() { /* nothing todo */};
      spyOn($rootScope, 'update_languages');
    });
  }));

  it('should call "update_languages" when listCtrl init', inject(
    function($controller, $rootScope) {
    
    var scope = $rootScope.$new(),
        ctrl = $controller('listCtrl', {$scope: scope});

    expect($rootScope.update_languages).toHaveBeenCalled();
  }));
});

我們要測試的是 listCtrl 在建構時,是否正確執行 Parent Controller 的 method update_languages()

因此我們在之前以 $rootScopespyOn 去 stub 這個 method,並在之後透過 toHaveBeenCalled() 檢查是否有被執行過。

Unit Testing for formCtrl

form_ctrl_spec.js 中,我們為 formCtrl 來寫測試:

describe('formCtrl', function(){

  beforeEach(module('languageApp'));

  beforeEach(inject(function ($rootScope) {
    $rootScope.$apply(function() {
      $rootScope.update_languages = function() { /* nothing todo */};
      spyOn($rootScope, 'update_languages');
    });
  }));

  it('should call "update_languages" with method completed', inject(
    function($controller, $rootScope) {
    
    var scope = $rootScope.$new(),
        ctrl = $controller('formCtrl', {$scope: scope});

    scope.completed({});

    expect($rootScope.update_languages).toHaveBeenCalled();
  }));
});

這檢查的內容裏跟剛剛在 listCtrl 中所做的差不多。

Unit Testing for itemCtrl

item_ctrl_spec.js 中,我們為 itemCtrl 來寫測試:

describe('itemCtrl', function(){

  beforeEach(module('languageApp'));

  beforeEach(inject(function ($rootScope) {
    $rootScope.$apply(function() {
      $rootScope.update_languages = function() { /* nothing todo */};
      $rootScope.language = {name: 'Chinese', id: 1};

      spyOn($rootScope, 'update_languages');
    });
  }));

  it('should change "isEdit" copy "onedit_language" with method "edit_toggle"', inject(function($controller, $rootScope) {
    var scope = $rootScope.$new(),
        ctrl = $controller('itemCtrl', {$scope: scope});
    scope.edit_toggle(true);

    expect(scope.isEdit).toBe(true);
    expect(scope.onedit_language).toEqual($rootScope.language);
  }));

  it('should call "update_languages" with method "update"', inject(
    function($controller, $rootScope, $httpBackend) {

    var scope = $rootScope.$new(),
        ctrl = $controller('itemCtrl', {$scope: scope});
    scope.edit_toggle(true);

    $httpBackend.expect('PUT', '/languages/1.json', $rootScope.language).respond('');
    scope.update();
    $httpBackend.flush();

    expect($rootScope.update_languages).toHaveBeenCalled();
  }));

  it('should call "update_languages" with method "delete"', inject(
    function($controller, $rootScope, $httpBackend) {

    var scope = $rootScope.$new(),
        ctrl = $controller('itemCtrl', {$scope: scope});

    $httpBackend.expect('DELETE', '/languages/1.json').respond('');
    scope.delete();
    $httpBackend.flush();

    expect($rootScope.update_languages).toHaveBeenCalled();
  }));
});

這裏雖然看起來做很多事,但是大部分的技巧剛剛已經提及,相信你也可以在有文件的狀況下寫出來。

測試看看吧!

在 Console 中執行:

$ guard

如果你看到的跟我一樣,恭喜你!你也會在 Rails 中為 AngularJS 寫測試囉!!

原始碼在這裏:Github Repo

 
about 2 years ago

承襲前一篇的教學,我們來實踐另一個實作吧。

單頁式的 CRUD 應用,也就是在同一頁面上,實現完整 CRUD 的功能,聽起來酷吧!

透過 AngularJS data-binding 的優勢,我們可以不必像使用 JQuery 時,在程式碼內大量的去操作 DOM。

使用僅僅不過 50 行的 AngularJS code,完整接上 Rails Restful API,與你的網頁做互動。

我們承襲上一篇設定好的 Project,繼續往下做吧!

安裝表單處理套件

在 Rails 中,我本身習慣使用 simple-form (Github Repo) 作為 Rails 中產生表單的套件,先來安裝吧!在 Gemfile 中先加上:

gem 'simple_form'

而在 AngularJS 中,我習慣使用 ngUpload (Github Repo) 這個套件來處理表單的上傳,

我們可以透過 Rails-Assets 來安裝,在 Gemfile 中加上:

gem 'rails-assets-ngUpload', '0.5.11'

透過 Bundler 安裝這些套件:

$ bundler install

安裝 simple_form 到 Rails 中:

$ rails g simple_form:install

增加 ngUpload 到 assets pipeline 中,在 application.js 中新增:

//= require ngUpload

使用 Bootstrap 3 讓 UI 看起來漂亮點吧!

layout/application.html.erb 我們新增 Bootstrap 的 css 來源:

<link href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css" rel="stylesheet">

開始 Coding 吧!

使用 AngularJS Nested Controller (Nested Scope)

記得我們上次新增了 scaffold:languages ,並在 LanguageController 新增了一個 action:ng_index,

我們就在 ng_index.html.erb 這個頁面繼續往下完成更多功能吧!

首先我們先重新建立 list 的功能:

<div class="container" ng-controller="indexCtrl">
  <h1>Languages</h1>
  <div class="row">
    <div class="col-md-6">
      <div class="panel panel-default">
        <div class="panel-heading">Language list</div>
        <div class="panel-body">
          <ul class="list-group" ng-controller="listCtrl">
            <li class="list-group-item" ng-repeat="language in languages">
              {{ language.name }}                
            </li>
          </ul>
        </div>
      </div>
    </div>
  </div>    
</div>

我們看到最外層 divng-controller="indexCtrl",而在裡面 ul 上則為 ng-controller="listCtrl"

這是所謂的 Nested Controller ,也就是 html 結構中 controller 中又有一個 controller,

languages.js 中我們可以適合這樣實作:

var languageApp = angularApplication.module('languageApp', []);

languageApp.controller('indexCtrl', function($scope, $http) {
  $scope.languages = [];

  // 更新列表

  $scope.update_languages = function() {
    $http.get('/languages.json').
    success(function(languages) {
      $scope.languages = languages;
    });
  };
});

languageApp.controller('listCtrl', function($scope, $http) {
  $scope.update_languages();
});

這裏我們可以看到 indexCtrl 中有 $scope.languages 這個 model,和 $scope.update_languages() 這個 method,

而在 listCtrl 中去執行了 $scope.update_languages() 這個 method,但為什麼能夠這樣執行?

因為在 AngularJS 中, $scope 如同 DOM 一樣,本身是個樹狀結構,

如果在 listCtrl$scope.update_languages() 這個 method 沒有被定義 ,他會往 parent controller 中去尋找,

而在 li 中取用 $scope.languages 也是異曲同工之妙。

使用表單

我們來新增 create languages 的表單吧!在 ng_index.html.erb<div class="row"> 中新增:

<div class="col-md-6">
  <div class="panel panel-default">
    <div class="panel-heading">New Language</div>
      <div class="panel-body">
        <%= simple_form_for @language, url: '/languages.json', html: {
          :'ng-controller' => 'formCtrl', :'ng-upload' => 'completed(content)', 
          role: 'form'} do |f| %>
          <div class="form-group">
            <%= f.input_field :name, class: 'form-control',  
                              :'ng-model' => 'name', placeholder: 'Name' %>
          </div>
        <button type="submit" class="btn btn-default" ng-disabled="$isLoading">Create</button>
      <% end %>
    </div>
  </div>
</div>

我們用 simple-form 建立了一個表單,這個表單用到 :'ng-controller' => 'formCtrl'

並且使用到 AngularJS 的套件 ngUpload:'ng-upload' => 'completed(content)'

我們也在 input 上 define 了一個 :'ng-model' => 'name'

回到 languages.js 我們必須先載入有用到的套件到 AngularJS App 中:

var languageApp = angularApplication.module('languageApp', ['ngUpload']);

接者我們要新增 formCtrl 給這個表單來使用:

languageApp.controller('formCtrl', function($scope, $http) {
  // 上傳完成

  $scope.completed = function(response) {
    $scope.name = '';
    $scope.update_languages();
  };
});

ngUpload 上傳完後去執行 $scope.completed(content) 這個 method 去更新 languages list,和清掉 input:name 的內容。

而在 model/language.rb 中新增必填屬性 name

validates :name, presence: true

在 languages list 上新增編輯的功能

我們想賦予 li 能夠編輯和刪除的功能,我們修改 li 的內容。

<li class="list-group-item" ng-repeat="language in languages" ng-controller="itemCtrl">
  <div ng-if="!isEdit">
    {{ language.name }}
    <button class="btn btn-danger btn-xs pull-right"  ng-click="delete()">Remove</button>
    <button class="btn btn-default btn-xs pull-right" ng-click="edit_toggle(true)">Edit</button>
  </div>
  <div ng-if="isEdit">
    <input ng-model="onedit_language.name">
    <button class="btn btn-default btn-xs pull-right" ng-click="edit_toggle(false)">Cancel</button>
    <button class="btn btn-primary btn-xs pull-right" ng-click="update()">Update</button>
  </div>
</li>

我們新增 ng-controller="itemCtrl" ,並且透過 $scope.isEdit 來判斷是否進入編輯模式。

languageApp.controller('itemCtrl', function($scope, $http) {
  $scope.isEdit = false;

  // 展開或關閉編輯模式

  $scope.edit_toggle = function(isEdit) {
    $scope.isEdit = isEdit;
    $scope.onedit_language = angular.copy($scope.language);
  };

  $scope.update = function() {
    $http.put('/languages/' + $scope.onedit_language.id + '.json', $scope.onedit_language).
    success(function(response) {
      $scope.update_languages();
    });
  };

  $scope.delete = function() {
    $http.delete('/languages/' + $scope.language.id + '.json').
    success(function(response) {
      $scope.update_languages();
    });
  };
});

$scope.update()$scope.delete() 中,我們使用 $http.put$http.delete 和 LanguageController 互動,

並且在執行完後更新 languages list。

看看成果吧!!

$ rails s

瀏覽剛剛寫好的頁面 http://localhost:3000/languages/ng_index

原始碼在這裏:Github Repo

趕快來嘗試看看吧!

 
about 2 years ago

為什麼是選擇 AngularJS 作為前端架構?

以我自己的對 AngularJS 認識,我認為 AngularJS 和 Rails 一樣,符合 DRY(Don't Repeat Yourself)的精神。

AngularJS 本身 Directive 與 Module 的設計對於套件封裝非常方便,加上他逐漸蓬勃發展的社群,

讓開發者在AngularJS的架構下有了豐富的套件支援,開發任何應用都比以往方便,快速,

另外如果你是 TDD 的信仰者,AngularJS也提供了豐富的測試工具,讓你可以無憂無慮的寫程式,

這兩點是我會如此愛不釋手的原因。

AngularJS 與 Rails 功能重疊上的取捨?

但是由於 AngularJS 本身的設計,很依賴 Client-Side Rendering, Routing,

這部分的功能與 Rails 部分重疊,我會做一些取捨,我認為 AngularJS 本身想要攬很多事情去做,

但有些事情完全抓到前端去做,效益似乎不大,管理上會變得複雜,而且就沒有發揮到 Rails 的優勢。

Routing 我認為應該單純化用 Rails 去處理,否則前後端都有各自的 Routing,架構會變得過於複雜,不好維護。

Rendering 對於 AngularJS 則是一個優勢(data-binding),如果 Rendering 交給 AngularJS 去做,

就不用在 Rails 中造大量的 Helper 去產生 html,可以減少伺服器處理 Request 的負擔。

使用 Bower 及 Rails-Assets 做 Javascript 套件相依管理

Bower 是 Twitter 推出的 前端套件相依性管理器,非常推薦,

在往後我們如果用到任何 Angular 套件,我們都可以從這個來源來安裝,

Rails-Assets 則是 Bower 在 Bundler 中的 Proxy,讓你可以簡單在 Gemfile 裡面,安裝 Bower 中的套件。

https://rails-assets.org,他的官網中,有使用方法,及套件列表查詢。

那我們就來做看看吧

首先先創造一個Project來練習吧:

$ rails new angular-rails-basic

安裝 AngularJS

單純一點,我們先暫時不用CoffeeScript,先註解掉吧:

# gem 'coffee-rails', '~> 4.0.0'

由於我們要用 Rails-Assets 安裝 Bower 上的套件,我們在 Gemfile 中上方,先加入 Rails-Assets 的 Source:

source 'https://rails-assets.org'

我們先安裝一個穩定版本的 AngularJS 吧:

gem 'rails-assets-angular', '1.2.10'

在 Console 下執行 bundle install:

$ bundle install

在 asset pipeline 中加入 AngularJS

turbolink 是 Rails 4 以後,幫助 Rendering 加速的一個套件,

但由於它與 AngularJS 初始化時會互斥,所以不能同時使用,

如果你還是很想要同時使用這兩個功能,可以藉由調整 AngularJS 初始化的時間點達成 (Stackoverflow 討論)

但在此我們還是先在 application.js 中將它拿掉,並加入 AngularJS:

//= require jquery

//= require jquery_ujs

//= require angular

//= require_tree .

開始第一個實作

我們新增一個 Scaffold:

$ rails g scaffold language name
$ rake db:migrate

並且在 LanguagesController 中新增一個 action,ng_index,來實作 AngularJS 的單頁式應用。

記得在 config/routes.rb 上增加路由設定:

resources :languages do
  collection do
    get 'ng_index'
  end
end

準備開始寫 AngularJS

我自己習慣將一個頁面當作ㄧ個 Angular App 看待,

但是往往在一個 Rails 中會有很多頁面,所以往往會需要有很多 Angular App 的物件被產生

但這時如果有共用的設定,或者共用的元件該如何處理?

這邊有一個秘訣就是使用 Factory Pattern,我們不直接透過 angular.module 造出頁面App,而是透過自訂方法造出

我們新增一個 angularApplication 物件來生產初始化的 Angular App

新增一個 angular_application.js.erb 在 assets pipeline 中

var angularApplication = (function() {

  function set_rails_csrf(app) {
    app.config(function($httpProvider) {
      $httpProvider.defaults.headers.common['X-CSRF-Token'] =
        $('meta[name=csrf-token]').attr('content');
    });
  }

  return {
    module: function(name, modules) {
      var new_app = angular.module(name, modules);

      set_rails_csrf(new_app);

      return new_app;
    }
  };
})();

以這個例子來說,這個工廠模式幫我做了一件事,每當我透過 angularApplication 造出來的 Angular App,

他都會幫我加上 CSRF 的設定,而不用一一設定,讓我透過 AngularJS 和後端的互動行為都可以通過 Rails 的驗證。

我們在 Rails Layout 和 Controller 中指定 AngularJS APP

layout/application.html.erb 中修改 <body> 為:

<body ng-app="<%= @ng_app %>">

在 LanguagesController 中加上:

before_action :set_ng_app

及在 private 底下加入這個 method:

def set_ng_app
    @ng_app = 'languageApp'
end

開始寫 AngularJS 吧

language.js 中我們新增一個 languageApp,供 LanguagesController 底下的頁面使用:

var languageApp = angularApplication.module('languageApp', []);

我們想在剛剛建立的 LanguagesController#ng_index 中透過 AngularJS 列出所有的 language

ng_index.html.erb 中新增一個 ul list,他用到一個 Angular Controller:listCtrl,列出 languages 的內容

<ul ng-controller="listCtrl">
  <li ng-repeat="language in languages">{{ language.name }}</li>
</ul>

language.js 中新增對應的 Angular Controller 吧:

languageApp.controller('listCtrl', function($scope, $http) {
  $http.get('/languages.json').success(function(languages) {
    $scope.languages = languages;
  });
});

裡面我們透過 ajax 到 /languages.json 取得 languages,並 Assign 給 $scope.languages

由於 $scope 有 data-binding 的特性,因此 $scope.languages 一被更改,html 中透過 languages 迭代出的內容也跟者增加。

到 Rails Console 中新增一些資料吧。

$ rails c
Language.create(name: 'English')
Language.create(name: 'Japanese')
Language.create(name: 'Chinese')

看看成果

$ rails s

瀏覽剛剛寫好的頁面 http://localhost:3000/languages/ng_index

原始碼在這裏:Github Repo

恭喜你!!已經完成一個 Rails 與 AngularJS 混搭風的 HelloWorld

下一篇我們來玩玩看更多 AngularJS 的功能,實現單頁式的 CRUD 應用!

 
over 2 years ago

我們常常拿到 Raspberry Pi 要開發
遇到最麻煩的問題就是常常要先外接螢幕,鍵盤,滑鼠
才能開機做初始設定

但真的只有這麼麻煩的做法嗎?
我們其實可以透過 USB to TTL 連接到Raspberry Pi上

Read on →