【问题标题】:Using shoulda to refactor rspec tests on Rails models使用 shoulda 重构 Rails 模型上的 rspec 测试
【发布时间】:2012-08-20 01:13:06
【问题描述】:

在通过回答another StackOverflow question on attribute accessibility tests 了解shoulda-matchers(并认为它们非常棒)后,我决定尝试重构我在The Rails Tutorial 中所做的模型测试,以使它们更加简洁和彻底。我之所以这样做,要归功于模块 Shoulda::Matchers::ActiveRecord 和 Shoulda::Matchers::ActiveModel 的文档以及 this StackOverflow answer 关于在模型中构建 shoulda 测试的一些灵感。但是,仍有一些事情我不确定,我想知道如何才能使这些测试变得更好。

我将使用 Rails 教程中的用户规范作为我的示例,因为它是最详细的,并且涵盖了许多可以改进的领域。以下代码示例已从原始 user_spec.rb 更改,并将代码替换到 describe "micropost associations" 行。该规范针对user.rb 模型进行测试,其工厂在factories.rb 中定义。

spec/models/user_spec.rb

# == Schema Information
#
# Table name: users
#
#  id              :integer          not null, primary key
#  name            :string(255)
#  email           :string(255)
#  created_at      :datetime         not null
#  updated_at      :datetime         not null
#  password_digest :string(255)
#  remember_token  :string(255)
#  admin           :boolean          default(FALSE)
#
# Indexes
#
#  index_users_on_email           (email) UNIQUE
#  index_users_on_remember_token  (remember_token)
#

require 'spec_helper'

describe User do

  let(:user) { FactoryGirl.create(:user) }

  subject { user }

  describe "database schema" do
    it { should have_db_column(:id).of_type(:integer)
                              .with_options(null: false) }
    it { should have_db_column(:name).of_type(:string) }
    it { should have_db_column(:email).of_type(:string) }
    it { should have_db_column(:created_at).of_type(:datetime)
                              .with_options(null: false) }
    it { should have_db_column(:updated_at).of_type(:datetime)
                              .with_options(null: false) }
    it { should have_db_column(:password_digest).of_type(:string) }
    it { should have_db_column(:remember_token).of_type(:string) }
    it { should have_db_column(:admin).of_type(:boolean)
                              .with_options(default: false) }
    it { should have_db_index(:email).unique(true) }
    it { should have_db_index(:remember_token) }
  end

  describe "associations" do
    it { should have_many(:microposts).dependent(:destroy) }
    it { should have_many(:relationships).dependent(:destroy) }
    it { should have_many(:followed_users).through(:relationships) }
    it { should have_many(:reverse_relationships).class_name("Relationship")
                         .dependent(:destroy) }
    it { should have_many(:followers).through(:reverse_relationships) }
  end

  describe "model attributes" do
    it { should respond_to(:name) }
    it { should respond_to(:email) }
    it { should respond_to(:password_digest) }
    it { should respond_to(:remember_token) }
    it { should respond_to(:admin) }
    it { should respond_to(:microposts) }
    it { should respond_to(:relationships) }
    it { should respond_to(:followed_users) }
    it { should respond_to(:reverse_relationships) }
    it { should respond_to(:followers) }
  end

  describe "virtual attributes and methods from has_secure_password" do
    it { should respond_to(:password) }
    it { should respond_to(:password_confirmation) }
    it { should respond_to(:authenticate) }
  end

  describe "accessible attributes" do
    it { should_not allow_mass_assignment_of(:password_digest) }
    it { should_not allow_mass_assignment_of(:remember_token) }
    it { should_not allow_mass_assignment_of(:admin) }
  end

  describe "instance methods" do
    it { should respond_to(:feed) }
    it { should respond_to(:following?) }
    it { should respond_to(:follow!) }
    it { should respond_to(:unfollow!) }
  end

  describe "initial state" do
    it { should be_valid }
    it { should_not be_admin }
    its(:remember_token) { should_not be_blank }
    its(:email) { should_not =~ /\p{Upper}/ }
  end

  describe "validations" do
    context "for name" do
      it { should validate_presence_of(:name) }
      it { should_not allow_value(" ").for(:name) }
      it { should ensure_length_of(:name).is_at_most(50) }
    end

    context "for email" do
      it { should validate_presence_of(:email) }
      it { should_not allow_value(" ").for(:email) }
      it { should validate_uniqueness_of(:email).case_insensitive }

      context "when email format is invalid" do
        addresses = %w[user@foo,com user_at_foo.org example.user@foo.]
        addresses.each do |invalid_address|
          it { should_not allow_value(invalid_address).for(:email) }
        end
      end

      context "when email format is valid" do
        addresses = %w[user@foo.COM A_US-ER@f.b.org frst.lst@foo.jp a+b@baz.cn]
        addresses.each do |valid_address|
          it { should allow_value(valid_address).for(:email) }
        end
      end
    end

    context "for password" do
      it { should ensure_length_of(:password).is_at_least(6) }
      it { should_not allow_value(" ").for(:password) }

      context "when password doesn't match confirmation" do
        it { should_not allow_value("mismatch").for(:password) }
      end
    end

    context "for password_confirmation" do
      it { should validate_presence_of(:password_confirmation) }
    end
  end

  # ...
end

关于这些测试的一些具体问题:

  1. 值得测试数据库模式吗? StackOverflow answer mentioned above 中的一条评论说“我只测试与行为相关的东西,我不考虑列或索引行为的存在。除非有人故意删除它们,否则数据库列不会消失,但你可以通过代码审查和信任来防止这种情况”,我同意,但是有什么正当理由可以测试数据库模式的结构,从而证明Shoulda::Matchers::ActiveRecord 模块的存在是合理的?也许只有重要的指标值得测试...?
  2. "associations" 下的should have_many 测试是否替换了"model attributes" 下相应的should respond_to 测试?我不知道should have_many 测试是只是在模型文件中查找相关的has_many 声明,还是实际上执行与should respond_to 相同的功能。
  3. 您是否有任何其他 cmets/建议可以使这些测试在内容和结构上更简洁/可读/彻底?

【问题讨论】:

    标签: ruby-on-rails rspec refactoring railstutorial.org shoulda


    【解决方案1】:

    1) Shoulda::Matchers::ActiveRecord 模块中包含的不仅仅是列和索引匹配器。我会在included classes 中挖掘一下,看看你能找到什么。这就是have_many、belong_to 等的来源。不过,作为记录,我认为其中的大部分内容几乎没有价值。

    2) 是的,像have_many 这样的宏测试比模型是否响应方法要多得多。从source code,您可以确切地看到它正在测试什么:

    def matches?(subject)
      @subject = subject
      association_exists? &&
        macro_correct? &&
        foreign_key_exists? &&
        through_association_valid? &&
        dependent_correct? &&
        class_name_correct? &&
        order_correct? &&
        conditions_correct? &&
        join_table_exists? &&
        validate_correct?
    end
    

    3) 使测试更具可读性和/或简洁性绝对是一个需要回答的主观问题。每个人都会根据他们的背景和经验给你不同的答案。我个人会摆脱所有respond_to 测试并用有价值的测试替换它们。当有人查看您的测试时,他们应该能够理解该类的公共 API。当我看到您的对象对“跟随?”之类的内容做出响应时,我可以做出假设,但并不真正知道这意味着什么。需要争论吗?它是否返回布尔值?对象是跟随某物还是某物跟随该对象?

    【讨论】:

    • 这是一个很好的答案,正是我想要的。非常感谢!我认为“当有人查看您的测试时,他们应该能够理解该类的公共 API”是一个非常好的规则;我认为是时候进行一些重构了。
    【解决方案2】:

    你的问题涉及到几点,我想谈谈其中两点:

    答案是主观的,所以我将给你我个人的看法。

    1) 以这种方式测试 ActiveRecord?
    我的回答是肯定的。你可以用真实数据编写复杂的测试,但如果你基本上信任 ActiveRecord,你可以这样做,如果你开始做 tdd,首先通过这些测试,它们可以在这个过程中提供帮助。

    2) 编写模型测试?
    我的回答是肯定的。我所做的是关注控制器并在快乐路径上请求规范,然后对于需要验证等的情况,我为它们编写单元模型测试。事实证明,这对我来说是一个很好的责任分工。

    【讨论】:

    • 非常感谢您的回复。 +1 获得第 2 条建议);非常感谢。
    【解决方案3】:

    我认为这整件事应该从规范的角度来看。

    如果您有一个涵盖给定模型所需的数据库列的组件测试级规范,则应该这样做,否则不要。

    如果没有涵盖,但作为负责任的开发人员,您觉得拥有它很重要(您的软件及其质量特性会更好),您必须安排将此信息包含在规范中,然后您可以将这些测试放入测试套件。

    【讨论】:

    • 感谢您的回答。如果有人,比如客户,递给我一个规范,无论出于何种原因,绝对必须包括这种低级模型测试,那么我当然会这样做。但我想我会质疑你关于成为“负责任的开发人员”的第二点:做这些低级测试是否“负责任”或值得,或者这只是编写更多不必要的测试规范的借口与行为相关的测试相比,没有提供太多额外的价值?另外,您对上述第二个问题关于have_many 和respond_to 之间的区别有何评论?
    【解决方案4】:

    较低测试级别的要求主要来自您的组织内部(内部文档),客户大多只提供客户要求规范(假设这是测试 V 模型的最高级别)。 当您的组织开始设计时,软件会逐步创建较低的测试级别规范。

    对于“我们真的需要这个”问题:这取决于很多因素:应用程序的复杂性、安全性与否、要遵循的标准、合同/法律/行业法规等。

    通常我会说,对于一个正确的理想应用程序,负责单元测试的需求应该编写单元级规范,测试人员应该根据这个规范实施测试。

    对于“have_many 和 respond_to”,恐怕我没有背景信息它们是如何实现的,所以无法回答。

    【讨论】:

      【解决方案5】:

      我发现编写测试数据库列是否存在的一些价值。原因如下:

      1) 编写它们让我保持在 TDD 的节奏中。
      2)迁移通常非常棒,直到它们不是。我知道您不应该编辑现有的迁移,但是当我自己做某事时,我有时还是会这样做。如果其他人正在开发同一个应用程序并更改现有迁移而不是编写新迁移,那么这些测试对我来说很快就会解决问题。

      如果您因列名和类型过多而陷入困境,您可以执行以下操作来节省自己的输入时间:

      describe User do
      
        describe 'database' do 
      
          describe 'columns' do 
      
            %w[reset_password_sent_at remember_created_at current_sign_in_at 
              last_sign_in_at confirmed_at confirmation_sent_at 
              created_at updated_at
              ].each do |column|
              it { should have_db_column(column.to_sym).of_type(:datetime) }
            end
          end
      
          describe 'indexes' do 
      
            %w[confirmation_token email reset_password_token
            ].each do |index|
              it { should have_db_index(index.to_sym).unique(true)}
            end
          end
        end  
      end
      

      希望对您有所帮助。

      【讨论】:

        猜你喜欢
        • 2013-08-10
        • 1970-01-01
        • 2016-06-30
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2014-04-05
        相关资源
        最近更新 更多