测试控制器是否适当处理唯一性验证的正确方法是什么?

What is the proper way to test that a controller appropriately handles a uniqueness validation?

总结

我正在构建一个包含用户注册过程的 Rails 应用程序。 usernamepassword 是在数据库中创建 user 对象所必需的; username 必须是唯一的。 我正在寻找正确的方法来测试唯一性验证提示控制器方法的特定操作,即UsersController#create

上下文

用户模型包含相关验证:

# app/models/user.rb
#
#  username        :string           not null
#  ...

class User < ApplicationRecord
  validates :username, presence: true
# ... more validations, class methods, and instance methods
end

此外,User 模型的规范文件使用 shoulda-matchers:

测试此验证
# spec/models/user_spec.rb

RSpec.describe User, type: :model do
  it { should validate_uniqueness_of(:username)}
# ... more model tests
end

方法UsersController#create定义如下:

# app/controllers/users_controller.rb

class UsersController < ApplicationController
  def create
    @user = User.new(user_params)
    if @user.save
      render :show
    else
      flash[:errors] = @user.errors.full_messages
      redirect_to new_user_url
    end
  end
# ... more controller methods
end

自从 username 唯一性的 User 规范通过后,我 知道 POST 包含 username 的请求已经在数据库中会导致UsersController#create进入条件的else部分,我想测试验证这种情况。

目前,我测试 UsersController#create 如何通过以下方式处理 username 的唯一性验证:

# spec/controllers/users_controller_spec.rb
require 'rails_helper'

RSpec.describe UsersController, type: :controller do
  describe 'POST #create' do
    context "username exists in db" do
      before(:all) do
        User.create!(username: 'jarmo', password: 'good_password')
      end

      before(:each) do
        post :create, params: { user: { username: 'jarmo', password: 'better_password' }}
      end

      after(:all) do
        User.last.destroy
      end

      it "redirects to new_user_url" do
        expect(response).to redirect_to new_user_url
      end

      it "sets flash errors" do
        should set_flash[:errors]
      end
    end
# ... more controller tests
end

问题

我主要关心的是 beforeafter 挂钩。如果没有 User.last.destroy,此测试将在将来 运行 时失败:无法创建新记录,因此创建 second 记录相同的 username 不会发生。

问题

我应该在控制器规范中测试这个特定的模型验证吗?如果是这样,这些挂钩是 right/best 实现此目标的方法吗?

我将避开对 'should I...' 部分的意见,但有几个方面值得考虑。首先,虽然控制器测试还没有被正式弃用,但一段时间以来 Rails 和 Rspec 团队普遍不鼓励使用它们。来自 RSpec 3.5 release notes:

The official recommendation of the Rails team and the RSpec core team is to write request specs instead. Request specs allow you to focus on a single controller action, but unlike controller tests involve the router, the middleware stack, and both rack requests and responses. This adds realism to the test that you are writing, and helps avoid many of the issues that are common in controller specs.

场景是否保证相应的请求规范是一个明智的gement 调用,但如果您想在模型级别对验证进行单元测试,请查看 shoulda matchers gem,这有助于模型验证测试)。

就你关于挂钩的问题而言,before(:all) 在数据库事务之外挂钩 运行,所以即使你在 [=] 中将 use_transactional_fixtures 设置为 true 37=] 配置,它们不会自动回滚。所以,匹配 after(:all) 就像你拥有的那样。备选方案包括:

  1. before(:each) 挂钩中创建用户,它在事务中执行 运行 并被回滚。这是一些测试性能的潜在成本。
  2. 使用像 Database Cleaner gem 这样的工具,它可以让您 fine-grained 控制清理测试数据库的策略。

如果您想涵盖控制器以及用户反馈方面的内容,我会建议功能规范:

RSpec.feature "User creation" do
  context "with duplicate emails" do
    let!(:user) { User.create!(username: 'jarmo', password: 'good_password') }

    it "does not allow duplicate emails" do
      visit new_user_path
      fill_in 'Email', with: user.email
      fill_in 'Password', with: 'p4ssw0rd'
      fill_in 'Password Confirmation', with: 'p4ssw0rd'
      expect do
        click_button 'Sign up'
      end.to_not change(User, :count)
      expect(page).to have_content 'Email has already been taken'
    end
  end
end

这不是深入控制器内部,而是从用户故事中驱动完整的堆栈,并测试视图实际上也有验证错误的输出——因此它提供了控制器规范提供的价值很少的价值。

使用 let/let! 为特定示例设置 givens,因为它的优点是您可以通过它生成的辅助方法在示例中引用它们。 before(:all) 通常应避免使用,除了诸如删除 API 之类的东西。每个示例都应该有自己的 setup/teardown.

但是你还需要处理控制器本身坏掉的情况。它应该是:

class UsersController < ApplicationController
  def create
    @user = User.new(user_params)
    if @user.save
      redirect_to @user
    else
      render :new, status: :unprocessable_entity
    end
  end
end

当记录无效时,您不应重定向回来。在显示执行 POST 请求的结果时再次呈现表单。重定向回来会导致糟糕的用户体验,因为所有字段都将被清空。

成功创建资源后,您应该将用户重定向到新创建的资源,以便浏览器 URL 实际上指向新资源。如果您不重新加载页面,则会加载索引。

这也消除了在会话中填充错误消息的需要。如果您想通过 Flash 提供有用的反馈,您可以这样做:

class UsersController < ApplicationController
  def create
    @user = User.new(user_params)
    if @user.save
      redirect_to @user
    else
      flash.now[:error] = "Signup failed."
      render :new, status: :unprocessable_entity
    end
  end
end

您可以使用以下方法进行测试:

expect(page).to have_content "Signup failed."