I've been trying to create a shared example
to quickly and efficiently test controllers with CRUD operations that might also be nested.
I came up with this solution that works, but I feel that can be improved.
module ApiHelper
RSpec.shared_examples "a CRUD controller" do |model:|
def self.controller_has_action?(action)
described_class.action_methods.include?(action.to_s)
end
def self.model_belongs_to_associations(model)
model.reflect_on_all_associations(:belongs_to).map(&:name).nil?
end
resource_singular = model.name.underscore.to_sym
parent_resource = model.reflect_on_all_associations(:belongs_to).map(&:name).first
#let(:parent_resource) { model.reflect_on_all_associations(:belongs_to).map(&:name) }
let(:records) { FactoryBot.create_list(resource_singular, 10) }
let(:parent_record) { FactoryBot.create(parent_resource.to_sym) }
before(:each) { request.env["HTTP_ACCEPT_LANGUAGE"] = "en" }
describe "#show", if: controller_has_action?(:show) do
context "when requested record exists" do
let(:record) { records[rand 10] }
before(:each) do
get :show, params: {id: record.id}
end
it "succeeds" do
expect(response).to have_http_status(:success)
end
it "returns the requested record" do
expect(json["id"]).to eq(record.id)
end
end
end
describe "#index", if: controller_has_action?(:index) do
let(:params) {
params = {}
unless parent_resource.nil?
params.merge!((parent_resource.to_s << "_id").to_sym => parent_record.id)
end
}
before(:each) do
get :index, params: params
end
it "succeeds" do
expect(response).to have_http_status(:success)
end
it "returns the factory names" do
record_names = json.map { |c| c["name"] }
expect(record_names).to all(be_a(String).and(include(resource_singular.capitalize.to_s)))
end
end
# noinspection RubyDuplicatedKeysInHashInspection
describe "#create", if: controller_has_action?(:create) do
let(:request_params) {
params = {}
unless parent_resource.nil?
params.merge!((parent_resource.to_s << "_id").to_sym => parent_record.id)
end
params.merge!(resource_singular => record_attrs)
}
before(:each) do
post :create, params: request_params
end
context "when valid" do
let(:record_attrs) { attributes_for(resource_singular) }
it "succeeds" do
expect(response).to have_http_status(:created)
expect(response.headers["Content-Type"]).to eql("application/json; charset=utf-8")
end
it "saves the new record" do
expect { post :create, params: request_params }.to change(model, :count).by(1)
end
end
context "when invalid" do
let(:record_attrs) { attributes_for(resource_singular, :invalid) }
it "fails" do
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe "#update" do
let(:record) { records[rand 10] }
before(:each) do
patch :update, params: {resource_singular => new_attrs, :id => record.id}
end
context "when valid" do
let(:new_attrs) { attributes_for(resource_singular) }
it "succeeds" do
expect(response).to have_http_status(:success)
end
it "saves update" do
record.reload
expect(record).to have_attributes(new_attrs)
end
end
context "when invalid" do
let(:new_attrs) { attributes_for(resource_singular, :invalid) }
it "fails" do
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe "#destory", if: controller_has_action?(:destroy) do
context "when record exists" do
let(:record) { records[rand 10] }
before(:each) do
delete :destroy, params: {id: record.id}
end
it "success" do
expect(response).to have_http_status(:no_content)
end
it "remove record" do
expect(model.all).not_to include(record)
end
end
context "when requested record doesn't exist" do
it "throws exception" do
bypass_rescue
expect { delete :destroy, params: {id: -1} }.to raise_exception(ActiveRecord::RecordNotFound)
end
end
end
describe "#translations" do
context "#show DE" do
before(:each) { request.env["HTTP_ACCEPT_LANGUAGE"] = "de" }
let(:record) { records[rand(10)] }
it "returns the title in DE" do
get :show, params: {id: record.id}
puts(record.attributes)
expect(json["name"]).to eq(record.name)
end
end
end
end
end
This can be called like this:
it_behaves_like "a CRUD controller", model: Island
1 Answer 1
A couple notes.
1) You don't have to pass :each
to before
calls, it's the default:
# Before
before(:each) do
get :index, params: params
end
# After
before { get :index, params: params }
2) The code may benefit from the use of be
matchers:
# Before
it "success" do
expect(response).to have_http_status(:no_content)
end
it "succeeds" do
expect(response).to have_http_status(:success)
end
# After
it { is_expected.to be_no_content }
it { is_expected.to be_successful }
3) In some cases the content of params
object is ambiguous, because unless
returns nil
when the condition is falsy. Here is an example of different params
initialization:
# Before
let(:params) {
params = {}
unless parent_resource.nil?
params.merge!((parent_resource.to_s << "_id").to_sym => parent_record.id)
end
}
# After
let(:params) { parent_resource.nil? ? {} : { "#{parent_resource}_id".to_sym => parent_record.id } }
5) You're likely setting the language header in multiple places throughout the code, so a helper would come handy:
def set_language_header(lang)
request.env["HTTP_ACCEPT_LANGUAGE"] = lang
end
before { set_language_header(:en) }
before { set_language_header(:de) }
Hope it helps.
-
\$\begingroup\$ Thank you, what do you think of this part
parent_resource = model.reflect_on_all_associations(:belongs_to).map(&:name).first
? With multiplebelongs_to
associations obviously it will be problematic. \$\endgroup\$Manos– Manos2019年12月15日 12:36:42 +00:00Commented Dec 15, 2019 at 12:36