Rails 风格指导

10 条评论 , 7 次修正,3474 次阅读, 最后更新于 2015年7月31日

序幕

风格是从伟大事物中萃取出的美好事物。
-- Bozhidar Batsov

这份指南目的于演示一整套 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.rb, active_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-z0-9_+\.]+\@([-a-z0-9]+\.)+[a-z0-9]{2,4}$/i
      validates_length_of :content, :maximum => 500
    end
    

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

ActiveRecord

  • 避免改动缺省的 ActiveRecord(表的名字、主键,等等),除非你有一个非常好的理由(像是不受你控制的数据库)。
  • 把宏风格的方法放在类别定义的前面(has_many, validates, 等等)。
  • 偏好 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: /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i }
    end
    
    # 好
    class EmailValidator < ActiveModel::EachValidator
      def validate_each(record, attribute, value)
        record.errors[attribute] << (options[:message] || 'is not a valid email') unless value =~ /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/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),需要某些额外的格式解析,创一个你惯用的格式,并在类别中使用它。惯用的格式应当实作下列方法:extension, mime_type, encode 以及 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::Baseelement_pathcollection_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 方法取代 updown 方法。

    # 过去的方式
    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] =~ /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i
              self.status = 200
            else
              self.status = 404
            end
            super
          end
        end
      end
      
    • 建立一个新文件 public/javascripts/rails.validations.custom.js.coffee 并在你的 application.js.coffee 文件加入一个它的参照:

      # 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/
  • 第三方代码如: jQuerybootstrap 应放置在 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-rails3roadie

  • 应避免页面产生响应时寄送 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 "user@test.com"
      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
    
  • 大量使用 descibecontext

  • 如下地替 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)

    ```Ruby
    # 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 

    ```Ruby
    # 差
    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 方法时,这些方法需要被 stubbedStubbing 这些 helper 方法是在 `template` 完成的:

    ```Ruby
    # 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, 等等。

        ```Ruby
        # 常用的控制器 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

    ```Ruby
    # 一个在控制器中使用 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 别的模型或子对象是可接受的。
* 在测试里建立所有例子的模型来避免重复。

    ```Ruby
    describe Article do
      let(:article) { Fabricate(:article) }
    end
    ```

* 加入一个例子确保捏造的模型是可行的。

    ```Ruby
    describe Article do
      it 'is valid with valid attributes' do
        article.should be_valid
      end
    end
    ```

* 当测试验证时,使用 `have(x).errors_on` 来指定要被验证的属性。使用 `be_valid` 不保证问题在目的的属性。

    ```Ruby
    # 差
    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`

    ```Ruby
    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`

    ```Ruby
    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 包含了需要的信息

     ```Ruby
     describe SubscriberMailer
       let(:subscriber) { mock_model(Subscription, email: 'johndoe@test.com', 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](https://github.com/jnicklas/carrierwave) 图片上传器的示例 spec

    ```Ruby
    # 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 风格的资源,若有闲暇时应当考虑延伸阅读:

* [The Rails 3 Way](http://tr3w.com/)
* [Ruby on Rails Guides](http://guides.rubyonrails.org/)
* [The RSpec Book](http://pragprog.com/book/achbd/the-rspec-book)

# 贡献

在本指南所写的每个东西都不是定案。这只是我渴望想与同样对 Rails 编码风格有兴趣的大家一起工作,以致于最终我们可以替整个 Ruby 社群创造一个有益的资源。

欢迎开票或发送一个带有改进的更新请求。在此提前感谢你的帮助!

# 口耳相传

一份社群驱动的风格指南,对一个社群来说,只是让人知道有这个社群。微博转发这份指南,分享给你的朋友或同事。我们得到的每个注解、建议或意见都可以让这份指南变得更好一点。而我们想要拥有的是最好的指南,不是吗?