27 November 2014

前言

不知道在搜什么的时候,看到了这两篇风格指南(Ruby和Rails),C&P,并打算阅读一下。

正文

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.rb, active_admin.rb, 等等。
  • 相应调整配置开发、测试及生产环境(在 config/environments/ 下对应的文件)
    • 标记额外的资产给(如有任何)预编译:

      ```Ruby
      # 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 路由。collection表示所有人都可以访问,member表示特定授权的人可以访问。

      # 差
      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_many, validates, 等等)放在类别定义的前面。

      class User < ActiveRecord::Base
        # 默认的scope放在最前面(如果有), 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 的记录。它可被另一个具人类可读的属性覆写。

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

      为了要转换成对网址友好 (URL-friendly)的数值,字串应当调用 parameterize 。 对象的 id 要放在开头,以便给 ActiveRecord 的 find 方法查找。

    • 使用此 friendly_id gem。它允许藉由某些具描述性的模型属性,而不是用 id 来创建人类可读的网址。

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

      查看 gem 文档获得更多关于使用的信息。

ActiveResource

  • 当 HTTP 响应是一个与存在的格式不同的格式时(XML 和 JSON),需要某些额外的格式解析,创一个你惯用的格式,并在类别中使用它。惯用的格式应当实作下列方法:extension, mime_type, encode 以及 decode

      module ActiveResource  # 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

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

      ```Ruby
      #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 文件里描述这些目录。

      ```Ruby
      # 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 文件里配置。

      ```Ruby
      # 配置文件的定义:
      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 方法没有传入参数时,是这么使用的。

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

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

      # 一个在控制器中使用 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: '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 图片上传器的示例 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




傲娇的使用Disqus