Ruby on Rails 3 Style Guide。

序幕

Role models are important. 
-- 機械戰警 Alex J. Murphy

這份指南目的於演示一整套 Rails 3 開發的風格慣例及最佳實踐。這是一份與由現存社羣所驅動的Ruby 編碼風格指南互補的指南。

而本指南中測試 Rails 應用小節擺在開發 Rails 應用之後,因爲我相信行爲驅動開發 (BDD) 是最佳的軟體開發之道。銘記在心吧。

Rails 是一個堅持己見的框架,而這也是一份堅持己見的指南。在我的心裏,我堅信 RSpec 優於 Test::Unit,Sass 優於 CSS 以及 Haml,(Slim) 優於 Erb。所以不要期望在這裏找到 Test::Unit, CSS 及 Erb 的忠告。

某些忠告僅適用於 Rails 3.1+ 以上版本。

你可以使用 Transmuter 來產生本指南的一份 PDF 或 HTML 複本。

目錄

本指南被翻譯成下列語言:

開發 Rails 應用程序

配置

  • 把慣用的初始化代碼放在 config/initializers。 在 initializers 內的代碼於應用啓動時執行。
  • 每一個 gem 相關的初始化代碼應當使用同樣的名稱,放在不同的文件裏,如: carrierwave.rbactive_admin.rb, 等等。
  • 相應調整配置開發、測試及生產環境(在 config/environments/ 下對應的文件)

    • 標記額外的資產給(如有任何)預編譯:

        # config/environments/production.rb
        # 預編譯額外的資產(application.js, application.css, 以及所有已經被加入的非 JS 或 CSS 的文件)
        config.assets.precompile += %w( rails_admin/rails_admin.css rails_admin/rails_admin.js )
  • 將所有環境皆通用的配置檔放在 config/application.rb 文件。

  • 構建一個與生產環境(production enviroment)相似的,一個額外的 staging 環境。

路由

  • 當你需要加入一個或多個動作至一個 RESTful 資源時(你真的需要嗎?),使用 member and collection 路由。

    # 差
    get 'subscriptions/:id/unsubscribe'
    resources :subscriptions
    
    # 好
    resources :subscriptions do
      get 'unsubscribe', on: :member
    end
    
    # 差
    get 'photos/search'
    resources :photos
    
    # 好
    resources :photos do
      get 'search', on: :collection
    end
  • 若你需要定義多個 member/collection 路由時,使用替代的區塊語法(block syntax)。

    resources :subscriptions do
      member do
        get 'unsubscribe'
        # 更多路由
      end
    end
    
    resources :photos do
      collection do
        get 'search'
        # 更多路由
      end
    end
  • 使用嵌套路由(nested routes)來更佳地表達與 ActiveRecord 模型的關係。

    class Post < ActiveRecord::Base
      has_many :comments
    end
    
    class Comments < ActiveRecord::Base
      belongs_to :post
    end
    
    # routes.rb
    resources :posts do
      resources :comments
    end
  • 使用命名空間路由來羣組相關的行爲。

    namespace :admin do
      # Directs /admin/products/* to Admin::ProductsController
      # (app/controllers/admin/products_controller.rb)
      resources :products
    end
  • 不要在控制器裏使用留給後人般的瘋狂路由(legacy wild controller route)。這種路由會讓每個控制器的動作透過 GET 請求存取。

    # 非常差
    match ':controller(/:action(/:id(.:format)))'

控制器

  • 讓你的控制器保持苗條 ― 它們應該只替視圖層取出數據且不包含任何業務邏輯(所有業務邏輯應當放在模型裏)。
  • 每個控制器的行動應當(理想上)只調用一個除了初始的 find 或 new 方法。
  • 控制器與視圖之間共享不超過兩個實例變量(instance variable)。

模型

  • 自由地引入不是 ActiveRecord 的類別吧。
  • 替模型命名有意義(但簡短)且不帶縮寫的名字。
  • 如果你需要模型有著 ActiveRecord 行爲的對象,比方說驗證這一塊,使用 ActiveAttr gem。

    class Message
      include ActiveAttr::Model
    
      attribute :name
      attribute :email
      attribute :content
      attribute :priority
    
      attr_accessible :name, :email, :content
    
      validates_presence_of :name
      validates_format_of :email, :with => /\A[-a-z0-9_+\.]+\@([-a-z0-9]+\.)+[a-z0-9]{2,4}\z/i
      validates_length_of :content, :maximum => 500
    end

    更完整的示例,參考 RailsCast on the subject

ActiveRecord

  • 避免改動缺省的 ActiveRecord(表的名字、主鍵,等等),除非你有一個非常好的理由(像是不受你控制的數據庫)。
  • 把宏風格的方法(has_manyvalidates, 等等)放在類別定義的前面。

    class User < ActiveRecord::Base
      # 默認的scope放在最前面(如果有)
      default_scope { where(active: true) }
    
      # 接下來是常量
      GENDERS = %w(male female)
    
      # 然後放一些attr相關的宏
      attr_accessor :formatted_date_of_birth
    
      attr_accessible :login, :first_name, :last_name, :email, :password
    
      # 僅接着是關聯的宏
      belongs_to :country
    
      has_many :authentications, dependent: :destroy
    
      # 以及宏的驗證
      validates :email, presence: true
      validates :username, presence: true
      validates :username, uniqueness: { case_sensitive: false }
      validates :username, format: { with: /\A[A-Za-z][A-Za-z0-9._-]{2,19}\z/ }
      validates :password, format: { with: /\A\S{8,128}\z/, allow_nil: true}
    
      # 接着是回調
      before_save :cook
      before_save :update_username_lower
    
      # 其它的宏 (像devise的) 應該放在回調的後面
    
      ...
    end
  • 偏好 has_many :through 勝於 has_and_belongs_to_many。 使用 has_many :through 允許在 join 模型有附加的屬性及驗證

    # 使用 has_and_belongs_to_many
    class User < ActiveRecord::Base
      has_and_belongs_to_many :groups
    end
    
    class Group < ActiveRecord::Base
      has_and_belongs_to_many :users
    end
    
    # 偏好方式 - using has_many :through
    class User < ActiveRecord::Base
      has_many :memberships
      has_many :groups, through: :memberships
    end
    
    class Membership < ActiveRecord::Base
      belongs_to :user
      belongs_to :group
    end
    
    class Group < ActiveRecord::Base
      has_many :memberships
      has_many :users, through: :memberships
    end
  • 使用新的 "sexy" validation

  • 當一個慣用的驗證使用超過一次或驗證是某個正則表達映射時,創建一個慣用的 validator 文件。

    # 差
    class Person
      validates :email, format: { with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i }
    end
    
    # 好
    class EmailValidator < ActiveModel::EachValidator
      def validate_each(record, attribute, value)
        record.errors[attribute] << (options[:message] || 'is not a valid email') unless value =~ /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i
      end
    end
    
    class Person
      validates :email, email: true
    end
  • 所有慣用的驗證器應放在一個共享的 gem 。

  • 自由地使用命名的作用域(scope)。

    class User < ActiveRecord::Base
      scope :active, -> { where(active: true) }
      scope :inactive, -> { where(active: false) }
    
      scope :with_orders, -> { joins(:orders).select('distinct(users.id)') }
    end
  • 將命名的作用域包在 lambda 裏來惰性地初始化。

    # 差勁
    class User < ActiveRecord::Base
      scope :active, where(active: true)
      scope :inactive, where(active: false)
    
      scope :with_orders, joins(:orders).select('distinct(users.id)')
    end
    
    # 好
    class User < ActiveRecord::Base
      scope :active, -> { where(active: true) }
      scope :inactive, -> { where(active: false) }
    
      scope :with_orders, -> { joins(:orders).select('distinct(users.id)') }
    end
  • 當一個由 lambda 及參數定義的作用域變得過於複雜時,更好的方式是建一個作爲同樣用途的類別方法,並返回一個 ActiveRecord::Relation 對象。你也可以這麼定義出更精簡的作用域。

    class User < ActiveRecord::Base
      def self.with_orders
        joins(:orders).select('distinct(users.id)')
      end
    end
  • 注意 update_attribute 方法的行爲。它不運行模型驗證(不同於 update_attributes )並且可能把模型狀態給搞砸。

  • 使用用戶友好的網址。在網址顯示具描述性的模型屬性,而不只是 id 。 有不止一種方法可以達成:

    • 覆寫模型的 to_param 方法。這是 Rails 用來給對象建構網址的方法。缺省的實作會以字串形式返回該 id 的記錄。它可被另一個具人類可讀的屬性覆寫。

        class Person
          def to_param
            "#{id} #{name}".parameterize
          end
        end

      爲了要轉換成對網址友好 (URL-friendly)的數值,字串應當調用 parameterize 。 對象的 id 要放在開頭,以便給 ActiveRecord 的 find 方法查找。

    • 使用此 friendly_id gem。它允許藉由某些具描述性的模型屬性,而不是用 id 來創建人類可讀的網址。

        class Person
          extend FriendlyId
          friendly_id :name, use: :slugged
        end

      查看 gem 文檔獲得更多關於使用的信息。

ActiveResource

  • 當 HTTP 響應是一個與存在的格式不同的格式時(XML 和 JSON),需要某些額外的格式解析,創一個你慣用的格式,並在類別中使用它。慣用的格式應當實作下列方法:extensionmime_typeencode 以及 decode

    module ActiveResource
      module Formats
        module Extend
          module CSVFormat
            extend self
    
            def extension
              'csv'
            end
    
            def mime_type
              'text/csv'
            end
    
            def encode(hash, options = nil)
              # 數據以新格式編碼並返回
            end
    
            def decode(csv)
              # 數據以新格式解碼並返回
            end
          end
        end
      end
    end
    
    class User < ActiveResource::Base
      self.format = ActiveResource::Formats::Extend::CSVFormat
    
      ...
    end
  • 若 HTTP 請求應當不擴展發送時,覆寫 ActiveResource::Base 的 element_path 及 collection_path 方法,並移除擴展的部份。

    class User < ActiveResource::Base
      ...
    
      def self.collection_path(prefix_options = {}, query_options = nil)
        prefix_options, query_options = split_options(prefix_options) if query_options.nil?
        "#{prefix(prefix_options)}#{collection_name}#{query_string(query_options)}"
      end
    
      def self.element_path(id, prefix_options = {}, query_options = nil)
        prefix_options, query_options = split_options(prefix_options) if query_options.nil?
        "#{prefix(prefix_options)}#{collection_name}/#{URI.parser.escape id.to_s}#{query_string(query_options)}"
      end
    end

    如有任何改動網址的需求時,這些方法也可以被覆寫。

遷移

  • 把 schema.rb 保存在版本管控之下。
  • 使用 rake db:scheme:load 取代 rake db:migrate 來初始化空的數據庫。
  • 使用 rake db:test:prepare 來更新測試數據庫的 schema。
  • 避免在表裏設置缺省數據。使用模型層來取代。

    def amount
      self[:amount] or 0
    end

    然而 self[:attr_name] 的使用被視爲相當常見的,你也可以考慮使用更羅嗦的(爭議地可讀性更高的) read_attribute 來取代:

    def amount
      read_attribute(:amount) or 0
    end
  • 當編寫建設性的遷移時(加入表或欄位),使用 Rails 3.1 的新方式來遷移 - 使用 change 方法取代 up 與 down 方法。

    # 過去的方式
    class AddNameToPerson < ActiveRecord::Migration
      def up
        add_column :persons, :name, :string
      end
    
      def down
        remove_column :person, :name
      end
    end
    
    # 新的偏好方式
    class AddNameToPerson < ActiveRecord::Migration
      def change
        add_column :persons, :name, :string
      end
    end

視圖

  • 不要直接從視圖調用模型層。
  • 不要在視圖構造複雜的格式,把它們輸出到視圖 helper 的一個方法或是模型。
  • 使用 partial 模版與佈局來減少重複的代碼。
  • 加入 client side validation 至慣用的 validators。 要做的步驟有:

    • 聲明一個由 ClientSideValidations::Middleware::Base 而來的自定 validator

        module ClientSideValidations::Middleware
          class Email < Base
            def response
              if request.params[:email] =~ /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i
                self.status = 200
              else
                self.status = 404
              end
              super
            end
          end
        end
    • 建立一個新文件 public/javascripts/rails.validations.custom.js.coffee 並在你的 application.js.coffee 文件加入一個它的參照:

      ```Ruby
      # app/assets/javascripts/application.js.coffee
      #= require rails.validations.custom
      ```
      
    • 添加你的用戶端 validator:

        #public/javascripts/rails.validations.custom.js.coffee
        clientSideValidations.validators.remote['email'] = (element, options) ->
          if $.ajax({
            url: '/validators/email.json',
            data: { email: element.val() },
            async: false
          }).status == 404
            return options.message || 'invalid e-mail format'

國際化

  • 視圖、模型與控制器裏不應使用語言相關設置與字串。這些文字應搬到在 config/locales 下的語言文件裏。
  • 當 ActiveRecord 模型的標籤需要被翻譯時,使用activerecord 作用域:

    en:
      activerecord:
        models:
          user: Member
        attributes:
          user:
            name: "Full name"
    

    然後 User.model_name.human 會返回 "Member" ,而 User.human_attribute_name("name") 會返回 "Full name"。這些屬性的翻譯會被視圖作爲標籤使用。

  • 把在視圖使用的文字與 ActiveRecord 的屬性翻譯分開。 把給模型使用的語言文件放在名爲 models 的文件夾,給視圖使用的文字放在名爲 views 的文件夾。

    • 當使用額外目錄的語言文件組織完成時,爲了要載入這些目錄,要在 application.rb 文件裏描述這些目錄。

        # config/application.rb
        config.i18n.load_path += Dir[Rails.root.join('config', 'locales', '**', '*.{rb,yml}')]
  • 把共享的本土化選項,像是日期或貨幣格式,放在 locales 的根目錄下。

  • 使用精簡形式的 I18n 方法: I18n.t 來取代 I18n.translate 以及使用 I18n.l 取代 I18n.localize
  • 使用 "懶惰" 查詢視圖中使用的文字。假設我們有以下結構:

    en:
      users:
        show:
          title: "User details page"
    

    users.show.title 的數值能這樣被 app/views/users/show.html.haml 查詢:

    = t '.title'
  • 在控制器與模型使用點分隔的鍵,來取代指定 :scope 選項。點分隔的調用更容易閱讀及追蹤層級。

    # 這樣子調用
    I18n.t 'activerecord.errors.messages.record_invalid'
    
    # 而不是這樣
    I18n.t :record_invalid, :scope => [:activerecord, :errors, :messages]
  • 關於 Rails i18n 更詳細的信息可以在這裏找到 Rails Guides

Assets

利用這個 assets pipeline 來管理應用的結構。

  • 保留 app/assets 給自定的樣式表,Javascripts 或圖片。
  • 把自己開發,但不適合用在這個應用的函式庫,放在 lib/assets/
  • 第三方代碼如: jQuery 或 bootstrap 應放置在 vendor/assets
  • 當可能的時候,使用 gem 化的 assets 版本。(如: jquery-rails)。

Mailers

  • 把 mails 命名爲 SomethingMailer。 沒有 Mailer 字根的話,不能立即顯現哪個是一個 Mailer,以及哪個視圖與它有關。
  • 提供 HTML 與純文本視圖模版。
  • 在你的開發環境啓用信件失敗發送錯誤。這些錯誤缺省是被停用的。

    # config/environments/development.rb
    
    config.action_mailer.raise_delivery_errors = true
  • 在開發模式使用 smtp.gmail.com 設置 SMTP 服務器(當然了,除非你自己有本地 SMTP 服務器)。

    # config/environments/development.rb
    
    config.action_mailer.smtp_settings = {
      address: 'smtp.gmail.com',
      # 更多設置
    }
  • 提供缺省的配置給主機名。

    # config/environments/development.rb
    config.action_mailer.default_url_options = {host: "#{local_ip}:3000"}
    
    
    # config/environments/production.rb
    config.action_mailer.default_url_options = {host: 'your_site.com'}
    
    # 在你的 mailer 類
    default_url_options[:host] = 'your_site.com'
  • 如果你需要在你的網站使用一個 email 鏈結,總是使用 _url 方法,而不是 _path 方法。 _url 方法包含了主機名,而 _path 方法沒有。

    # 錯誤
    You can always find more info about this course
    = link_to 'here', url_for(course_path(@course))
    
    # 正確
    You can always find more info about this course
    = link_to 'here', url_for(course_url(@course))
  • 正確地顯示寄與收件人地址的格式。使用下列格式:

    # 在你的 mailer 類別
    default from: 'Your Name <info@your_site.com>'
  • 確定測試環境的 email 發送方法設置爲 test :

    # config/environments/test.rb
    
    config.action_mailer.delivery_method = :test
  • 開發與生產環境的發送方法應爲 smtp :

    # config/environments/development.rb, config/environments/production.rb
    
    config.action_mailer.delivery_method = :smtp
  • 當發送 HTML email 時,所有樣式應爲行內樣式,由於某些用戶有關於外部樣式的問題。某種程度上這使得更難管理及造成代碼重用。有兩個相似的 gem 可以轉換樣式,以及將它們放在對應的 html 標籤裏: premailer-rails3 和 roadie

  • 應避免頁面產生響應時寄送 email。若多個 email 寄送時,造成了頁面載入延遲,以及請求可能逾時。使用 delayed_job gem 的幫助來克服在背景處理寄送 email 的問題。

Bundler

  • 把只給開發環境或測試環境的 gem 適當地分組放在 Gemfile 文件中。
  • 在你的項目中只使用公認的 gem。 如果你考慮引入某些鮮爲人所知的 gem ,你應該先仔細複查一下它的源代碼。
  • 關於多個開發者使用不同操作系統的項目,操作系統相關的 gem 缺省會產生一個經常變動的 Gemfile.lock 。 在 Gemfile 文件裏,所有與 OS X 相關的 gem 放在 darwin 羣組,而所有 Linux 相關的 gem 放在 linux 羣組:

    # Gemfile
    group :darwin do
      gem 'rb-fsevent'
      gem 'growl'
    end
    
    group :linux do
      gem 'rb-inotify'
    end

    要在對的環境獲得合適的 gem,添加以下代碼至 config/application.rb :

    platform = RUBY_PLATFORM.match(/(linux|darwin)/)[0].to_sym
    Bundler.require(platform)
  • 不要把 Gemfile.lock 文件從版本控制裏移除。這不是隨機產生的文件 - 它確保你所有的組員執行 bundle install 時,獲得相同版本的 gem 。

無價的 Gems

一個最重要的編程理念是 "不要重造輪子!" 。若你遇到一個特定問題,你應該要在你開始前,看一下是否有存在的解決方案。下面是一些在很多 Rails 項目中 "無價的" gem 列表(全部兼容 Rails 3.1):

  • active_admin - 有了 ActiveAdmin,創建 Rails 應用的管理界面就像兒戲。你會有一個很好的儀表盤,圖形化 CRUD 界面以及更多東西。非常靈活且可客製化。
  • better_errors - Better Errors 用更好更有效的錯誤頁面,取代了 Rails 標準的錯誤頁面。不僅可用在 Rails,任何將 Rack 當作中間件的 app 都可使用。
  • bullet - Bullet 就是爲了幫助提升應用的效能(通過減少查詢)而打造的 gem。會在你開發應用時,替你注意你的查詢,並在需要 eager loading (N+1 查詢)時、或是你在不必要的情況使用 eager loading 時,或是在應該要使用 counter cache 時,都會提醒你。
  • cancan - CanCan 是一個權限管理的 gem, 讓你可以管制用戶可存取的支援。所有的授權都定義在一個檔案裏(ability.rb),並提供許多方便的方法,讓你檢查及確保整個應用內權限是否是可得的。
  • capybara - Capybara 旨在簡化整合測試 Rack 應用的過程,像是 Rails、Sinatra 或 Merb。Capybara 模擬了真實用戶使用 web 應用的互動。 它與你測試在運行的驅動無關,並原生搭載 Rack::Test 及 Selenium 支持。透過外部 gem 支持 HtmlUnit、WebKit 及 env.js 。與 RSpec & Cucumber 一起使用時工作良好。
  • carrierwave - Rails 最後一個文件上傳解決方案。支持上傳檔案(及很多其它的酷玩意兒的)的本地儲存與雲儲存。圖片後處理與 ImageMagick 整合得非常好。
  • client_side_validations - 一個美妙的 gem,替你從現有的服務器端模型驗證自動產生 Javascript 用戶端驗證。高度推薦!
  • compass-rails - 一個優秀的 gem,添加了某些 css 框架的支持。包括了 sass mixin 的蒐集,讓你減少 css 文件的代碼並幫你解決瀏覽器兼容問題。
  • cucumber-rails - Cucumber 是一個由 Ruby 所寫,開發功能測試的頂級工具。 cucumber-rails 提供了 Cucumber 的 Rails 整合。
  • devise - Devise 是 Rails 應用的一個完整解決方案。多數情況偏好使用 devise 來開始你的客制驗證方案。
  • fabrication - 一個很好的假數據產生器(編輯者的選擇)。
  • factory_girl - 另一個 Fabrication 的選擇。一個成熟的假數據產生器。 Fabrication 的精神領袖先驅。
  • ffaker - 實用的 gem 來產生仿造的數據(名字、地址,等等)。
  • feedzirra - 非常快速及靈活的 RSS 或 Atom 種子解析器。
  • friendly_id - 透過使用某些具描述性的模型屬性,而不是使用 id,允許你創建人類可讀的網址。
  • globalize3 - Globalize3 是 Globalize 的後繼者,針對 ActiveRecord 3.x 設計。基於新的 I18n API 打造而成,並幫 ActiveRecord 模型添加了事務功能。
  • guard - 極佳的 gem 監控文件變化及任務的調用。搭載了很多實用的擴充。遠優於 autotest 與 watchr
  • haml-rails - haml-rails 提供了 Haml 的 Rails 整合。
  • haml - Haml 是一個簡潔的模型語言,被很多人認爲(包括我)遠優於 Erb。
  • kaminari - 很棒的分頁解決方案。
  • machinist - 假數據不好玩,Machinist 纔好玩。
  • rspec-rails - RSpec 是 Test::MiniTest 的取代者。我不高度推薦 RSpec。 rspec-rails 提供了 RSpec 的 Rails 整合。
  • simple_form - 一旦用過 simple_form(或 formatastic),你就不想聽到關於 Rails 缺省的表單。它是一個創造表單很棒的DSL。
  • simplecov-rcov - 爲了 SimpleCov 打造的 RCov formatter。若你想使用 SimpleCov 搭配 Hudson 持續整合服務器,很有用。
  • simplecov - 代碼覆蓋率工具。不像 RCov,完全兼容 Ruby 1.9。產生精美的報告。必須用!
  • slim - Slim 是一個簡潔的模版語言,被視爲是遠遠優於 HAML(Erb 就更不用說了)的語言。唯一會阻止我大規模地使用它的是,主流 IDE 及編輯器對它的支持不好。但它的效能是非凡的。
  • spork - 一個給測試框架(RSpec 或 現今 Cucumber)用的 DRb 服務器,每次運行前確保分支出一個乾淨的測試狀態。 簡單的說,預載很多測試環境的結果是大幅降低你的測試啓動時間,絕對必須用!
  • sunspot - 基於 SOLR 的全文檢索引擎。

這不是完整的清單,以及其它的 gem 也可以在之後加進來。以上清單上的所有 gems 皆經測試,處於活躍開發階段,有社羣以及代碼的質量很高。

缺陷的 Gems

這是一個有問題的或被別的 gem 取代的 gem 清單。你應該在你的項目裏避免使用它們。

  • rmagick - 這個 gem 因大量消耗內存而聲名狼藉。使用 minimagick 來取代。
  • autotest - 自動測試的老舊解決方案。遠不如 guard 及 watchr
  • rcov - 代碼覆蓋率工具,不兼容 Ruby 1.9。使用 SimpleCov 來取代。
  • therubyracer - 極度不鼓勵在生產模式使用這個 gem,它消耗大量的內存。我會推薦使用 node.js 來取代。

這仍是一個完善中的清單。請告訴我受人歡迎但有缺陷的 gems 。

管理進程

  • 若你的項目依賴各種外部的進程使用 foreman 來管理它們。

測試 Rails 應用

也許 BDD 方法是實作一個新功能最好的方法。你從開始寫一些高階的測試(通常使用 Cucumber),然後使用這些測試來驅使你實作功能。一開始你給功能的視圖寫測試,並使用這些測試來創建相關的視圖。之後,你創建丟給視圖數據的控制器測試來實現控制器。最後你實作模型的測試以及模型自身。

Cucumber

  • 用 @wip (工作進行中)標籤標記你未完成的場景。這些場景不納入考慮,且不標記爲測試失敗。當完成一個未完成場景且功能測試通過時,爲了把此場景加至測試套件裏,應該移除 @wip 標籤。
  • 配置你的缺省配置文件,排除掉標記爲 @javascript 的場景。它們使用瀏覽器來測試,推薦停用它們來增加一般場景的執行速度。
  • 替標記著 @javascript 的場景配置另一個配置文件。

    • 配置文件可在 cucumber.yml 文件裏配置。

        # 配置文件的定義:
        profile_name: --tags @tag_name
    • 帶指令運行一個配置文件:

        cucumber -p profile_name
      
  • 若使用 fabrication 來替換假數據 (fixtures),使用預定義的 fabrication steps

  • 不要使用舊版的 web_steps.rb 步驟定義!最新版 Cucumber 已移除 web steps,使用它們導致冗贅的場景,而且它並沒有正確地反映出應用的領域。
  • 當檢查一元素的可視文字時,檢查元素的文字而不是檢查 id。這樣可以查出 i18n 的問題。
  • 給同種類對象創建不同的功能特色:

    # 差
    Feature: Articles
    # ... 功能實作 ...
    
    # 好
    Feature: Article Editing
    # ... 功能實作 ...
    
    Feature: Article Publishing
    # ... 功能實作 ...
    
    Feature: Article Search
    # ... 功能實作 ...
    
  • 每一個功能有三個主要成分:

    • Title
    • Narrative - 簡短說明這個特色關於什麼。
    • Acceptance criteria - 每個由獨立步驟組成的一套場景。
  • 最常見的格式稱爲 Connextra 格式。

    In order to [benefit] ...
    A [stakeholder]...
    Wants to [feature] ...

這是最常見但不是要求的格式,敘述可以是依賴功能複雜度的任何文字。

  • 自由地使用場景概述使你的場景備作它用 (keep your scenarios DRY)。

    Scenario Outline: User cannot register with invalid e-mail
      When I try to register with an email "<email>"
      Then I should see the error message "<error>"
    
    Examples:
      |email         |error                 |
      |              |The e-mail is required|
      |invalid email |is not a valid e-mail |
  • 場景的步驟放在 step_definitions 目錄下的 .rb 文件。步驟文件命名慣例爲 [description]_steps.rb。步驟根據不同的標準放在不同的文件裏。每一個功能可能有一個步驟文件 (home_page_steps.rb) 。也可能給每個特定對象的功能,建一個步驟文件 (articles_steps.rb)。

  • 使用多行步驟參數來避免重複

    場景: User profile
      Given I am logged in as a user "John Doe" with an e-mail "[email protected]"
      When I go to my profile
      Then I should see the following information:
        |First name|John         |
        |Last name |Doe          |
        |E-mail    |user@test.com|
    
    # 步驟:
    Then /^I should see the following information:$/ do |table|
      table.raw.each do |field, value|
        find_field(field).value.should =~ /#{value}/
      end
    end
  • 使用複合步驟使場景備作它用 (Keep your scenarios DRY)

    # ...
    When I subscribe for news from the category "Technical News"
    # ...
    
    # 步驟:
    When /^I subscribe for news from the category "([^"]*)"$/ do |category|
      steps %Q{
        When I go to the news categories page
        And I select the category #{category}
        And I click the button "Subscribe for this category"
        And I confirm the subscription
      }
    end
  • 總是使用 Capybara 否定匹配來取代正面情況搭配 should_not,它們會在給定的超時時重試匹配,允許你測試 ajax 動作。 見 Capybara 的 讀我文件獲得更多說明。

RSpec

  • 一個例子僅用一個期望值。

    # 差
    describe ArticlesController do
      #...
    
      describe 'GET new' do
        it 'assigns new article and renders the new article template' do
          get :new
          assigns[:article].should be_a_new Article
          response.should render_template :new
        end
      end
    
      # ...
    end
    
    # 好
    describe ArticlesController do
      #...
    
      describe 'GET new' do
        it 'assigns a new article' do
          get :new
          assigns[:article].should be_a_new Article
        end
    
        it 'renders the new article template' do
          get :new
          response.should render_template :new
        end
      end
    
    end
  • 大量使用 descibe 及 context 。

  • 如下地替 describe 區塊命名:

    • 非方法使用 "description"
    • 實例方法使用井字號 "#method"
    • 類別方法使用點 ".method"

      class Article
        def summary
          #...
        end
      
        def self.latest
          #...
        end
      end
      
      # the spec...
      describe Article do
        describe '#summary' do
          #...
        end
      
        describe '.latest' do
          #...
        end
      end
  • 使用 fabricators 來創建測試對象。

  • 大量使用 mocks 與 stubs。

    # mocking 一個模型
    article = mock_model(Article)
    
    # stubbing 一個方法
    Article.stub(:find).with(article.id).and_return(article)
  • 當 mocking 一個模型時,使用 as_null_object 方法。它告訴輸出僅監聽我們預期的訊息,並忽略其它的訊息。

    article = mock_model(Article).as_null_object
  • 使用 let 區塊而不是 before(:each) 區塊替 spec 例子創建數據。let 區塊會被懶惰求值。

    # 使用這個:
    let(:article) { Fabricate(:article) }
    
    # ... 而不是這個:
    before(:each) { @article = Fabricate(:article) }
  • 當可能時,使用 subject

    describe Article do
      subject { Fabricate(:article) }
    
      it 'is not published on creation' do
        subject.should_not be_published
      end
    end
  • 如果可能的話,使用 specify。它是 it 的同義詞,但在沒 docstring 的情況下可讀性更高。

    # 差
    describe Article do
      before { @article = Fabricate(:article) }
    
      it 'is not published on creation' do
        @article.should_not be_published
      end
    end
    
    # 好
    describe Article do
      let(:article) { Fabricate(:article) }
      specify { article.should_not be_published }
    end
  • 當可能時,使用 its 。

    # 差
    describe Article do
      subject { Fabricate(:article) }
    
      it 'has the current date as creation date' do
        subject.creation_date.should == Date.today
      end
    end
    
    # 好
    describe Article do
      subject { Fabricate(:article) }
      its(:creation_date) { should == Date.today }
    end
  • Use shared_examples if you want to create a spec group that can be shared by many other tests.

    # bad
    describe Array do
      subject { Array.new [7, 2, 4] }
    
      context "initialized with 3 items" do
        its(:size) { should eq(3) }
      end
    end
    
    describe Set do
      subject { Set.new [7, 2, 4] }
    
      context "initialized with 3 items" do
        its(:size) { should eq(3) }
      end
    end
    
    #good
    shared_examples "a collection" do
      subject { described_class.new([7, 2, 4]) }
    
      context "initialized with 3 items" do
        its(:size) { should eq(3) }
      end
    end
    
    describe Array do
      it_behaves_like "a collection"
    end
    
    describe Set do
      it_behaves_like "a collection"
    end
    
    
    ### 視圖
    
    * 視圖測試的目錄結構要與 `app/views` 之中的相符。 舉例來說,在 `app/views/users` 視圖被放在 `spec/views/users`* 視圖測試的命名慣例是添加 `_spec.rb` 至視圖名字之後,舉例來說,視圖 `_form.html.haml` 有一個對應的測試叫做 `_form.html.haml_spec.rb`* 每個視圖測試文件都需要 `spec_helper.rb`* 外部描述區塊使用不含 `app/views` 部分的視圖路徑。 `render` 方法沒有傳入參數時,是這麼使用的。
    
    ```Ruby
    # spec/views/articles/new.html.haml_spec.rb
    require 'spec_helper'
    
    describe 'articles/new.html.haml' do
      # ...
    end
    • 永遠在視圖測試來 mock 模型。視圖的目的只是顯示信息。
    • assign 方法提供由控制器提供視圖使用的實例變量(instance variable)。
    # spec/views/articles/edit.html.haml_spec.rb
    describe 'articles/edit.html.haml' do
    it 'renders the form for a new article creation' do
      assign(
        :article,
        mock_model(Article).as_new_record.as_null_object
      )
      render
      rendered.should have_selector('form',
        method: 'post',
        action: articles_path
      ) do |form|
        form.should have_selector('input', type: 'submit')
      end
    end
    • 偏好 capybara 否定情況選擇器,勝於搭配正面情況的 should_not 。
    # 差
    page.should_not have_selector('input', type: 'submit')
    page.should_not have_xpath('tr')
    
    # 好
    page.should have_no_selector('input', type: 'submit')
    page.should have_no_xpath('tr')
    • 當一個視圖使用 helper 方法時,這些方法需要被 stubbed。Stubbing 這些 helper 方法是在 template 完成的:
    # app/helpers/articles_helper.rb
    class ArticlesHelper
      def formatted_date(date)
        # ...
      end
    end
    
    # app/views/articles/show.html.haml
    = "Published at: #{formatted_date(@article.published_at)}"
    
    # spec/views/articles/show.html.haml_spec.rb
    describe 'articles/show.html.haml' do
      it 'displays the formatted date of article publishing' do
        article = mock_model(Article, published_at: Date.new(2012, 01, 01))
        assign(:article, article)
    
        template.stub(:formatted_date).with(article.published_at).and_return('01.01.2012')
    
        render
        rendered.should have_content('Published at: 01.01.2012')
      end
    end
    • 在 spec/helpers 目錄的 helper specs 是與視圖 specs 分開的。

    控制器

    • Mock 模型及 stub 他們的方法。測試控制器時不應依賴建模。
    • 僅測試控制器需負責的行爲:
    • 執行特定的方法
    • 從動作返回的數據 - assigns, 等等。
    • 從動作返回的結果 - template render, redirect, 等等。

        # 常用的控制器 spec 示例
        # spec/controllers/articles_controller_spec.rb
        # 我們只對控制器應執行的動作感興趣
        # 所以我們 mock 模型及 stub 它的方法
        # 並且專注在控制器該做的事情上
      
        describe ArticlesController do
          # 模型將會在測試中被所有控制器的方法所使用
          let(:article) { mock_model(Article) }
      
          describe 'POST create' do
            before { Article.stub(:new).and_return(article) }
      
            it 'creates a new article with the given attributes' do
              Article.should_receive(:new).with(title: 'The New Article Title').and_return(article)
              post :create, message: { title: 'The New Article Title' }
            end
      
            it 'saves the article' do
              article.should_receive(:save)
              post :create
            end
      
            it 'redirects to the Articles index' do
              article.stub(:save)
              post :create
              response.should redirect_to(action: 'index')
            end
          end
        end
    • 當控制器根據不同參數有不同行爲時,使用 context。

    # 一個在控制器中使用 context 的典型例子是,對象正確保存時,使用創建,保存失敗時更新。
    
    describe ArticlesController do
      let(:article) { mock_model(Article) }
    
      describe 'POST create' do
        before { Article.stub(:new).and_return(article) }
    
        it 'creates a new article with the given attributes' do
          Article.should_receive(:new).with(title: 'The New Article Title').and_return(article)
          post :create, article: { title: 'The New Article Title' }
        end
    
        it 'saves the article' do
          article.should_receive(:save)
          post :create
        end
    
        context 'when the article saves successfully' do
          before { article.stub(:save).and_return(true) }
    
          it 'sets a flash[:notice] message' do
            post :create
            flash[:notice].should eq('The article was saved successfully.')
          end
    
          it 'redirects to the Articles index' do
            post :create
            response.should redirect_to(action: 'index')
          end
        end
    
        context 'when the article fails to save' do
          before { article.stub(:save).and_return(false) }
    
          it 'assigns @article' do
            post :create
            assigns[:article].should be_eql(article)
          end
    
          it 're-renders the "new" template' do
            post :create
            response.should render_template('new')
          end
        end
      end
    end

    模型

    • 不要在自己的測試裏 mock 模型。
    • 使用捏造的東西來創建真的對象
    • Mock 別的模型或子對象是可接受的。
    • 在測試裏建立所有例子的模型來避免重複。
    describe Article do
      let(:article) { Fabricate(:article) }
    end
    • 加入一個例子確保捏造的模型是可行的。
    describe Article do
      it 'is valid with valid attributes' do
        article.should be_valid
      end
    end
    • 當測試驗證時,使用 have(x).errors_on 來指定要被驗證的屬性。使用 be_valid 不保證問題在目的的屬性。
    # 差
    describe '#title' do
      it 'is required' do
        article.title = nil
        article.should_not be_valid
      end
    end
    
    # 偏好
    describe '#title' do
      it 'is required' do
        article.title = nil
        article.should have(1).error_on(:title)
      end
    end
    • 替每個有驗證的屬性加另一個 describe
    describe Article do
      describe '#title' do
        it 'is required' do
          article.title = nil
          article.should have(1).error_on(:title)
        end
      end
    end
    • 當測試模型屬性的獨立性時,把其它對象命名爲 another_object
    describe Article do
      describe '#title' do
        it 'is unique' do
          another_article = Fabricate.build(:article, title: article.title)
          article.should have(1).error_on(:title)
        end
      end
    end

    Mailers

    • 在 Mailer 測試的模型應該要被 mock。 Mailer 不應依賴建模。
    • Mailer 的測試應該確認如下:
    • 這個 subject 是正確的
    • 這個 receiver e-mail 是正確的
    • 這個 e-mail 寄送至對的郵件地址
    • 這個 e-mail 包含了需要的信息

      describe SubscriberMailer
       let(:subscriber) { mock_model(Subscription, email: '[email protected]', name: 'John Doe') }
      
       describe 'successful registration email' do
         subject { SubscriptionMailer.successful_registration_email(subscriber) }
      
         its(:subject) { should == 'Successful Registration!' }
         its(:from) { should == ['info@your_site.com'] }
         its(:to) { should == [subscriber.email] }
      
         it 'contains the subscriber name' do
           subject.body.encoded.should match(subscriber.name)
         end
       end
      end

    Uploaders

    • 我們如何測試上傳器是否正確地調整大小。這裏是一個 carrierwave 圖片上傳器的示例 spec:
    # rspec/uploaders/person_avatar_uploader_spec.rb
    require 'spec_helper'
    require 'carrierwave/test/matchers'
    
    describe PersonAvatarUploader do
      include CarrierWave::Test::Matchers
    
      # 在執行例子前啓用圖片處理
      before(:all) do
        UserAvatarUploader.enable_processing = true
      end
    
      # 創建一個新的 uploader。模型被模仿爲不依賴建模時的上傳及調整圖片。
      before(:each) do
        @uploader = PersonAvatarUploader.new(mock_model(Person).as_null_object)
        @uploader.store!(File.open(path_to_file))
      end
    
      # 執行完例子時停用圖片處理
      after(:all) do
        UserAvatarUploader.enable_processing = false
      end
    
      # 測試圖片是否不比給定的維度長
      context 'the default version' do
        it 'scales down an image to be no larger than 256 by 256 pixels' do
          @uploader.should be_no_larger_than(256, 256)
        end
      end
    
      # 測試圖片是否有確切的維度
      context 'the thumb version' do
        it 'scales down an image to be exactly 64 by 64 pixels' do
          @uploader.thumb.should have_dimensions(64, 64)
        end
      end
    end

    延伸閱讀

    有幾個絕妙講述 Rails 風格的資源,若有閒暇時應當考慮延伸閱讀:

    貢獻

    在本指南所寫的每個東西都不是定案。這只是我渴望想與同樣對 Rails 編碼風格有興趣的大家一起工作,以致於最終我們可以替整個 Ruby 社羣創造一個有益的資源。

    歡迎開票或發送一個帶有改進的更新請求。在此提前感謝你的幫助!

    授權

    Creative Commons License This work is licensed under a Creative Commons Attribution 3.0 Unported License

    口耳相傳

    一份社羣驅動的風格指南,對一個社羣來說,只是讓人知道有這個社羣。微博轉發這份指南,分享給你的朋友或同事。我們得到的每個註解、建議或意見都可以讓這份指南變得更好一點。而我們想要擁有的是最好的指南,不是嗎?

    共勉之,
    Bozhidar

    Ruby on Rails 3 Style Guide

發佈了18 篇原創文章 · 獲贊 11 · 訪問量 34萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章