Updating Deeply Nested Attributes with RSpec

The problem: I had to find a way to test the update action that was nested four layers deep.

The solution: Create RSpecs params in a way I had never done before..

I had been tasked with overhauling the relationship between a Club and Promo (for a gym app), and because of the way a form needed to be setup, I ended up with a one-off controller bulk_promo_controller.rb that had nested attributes four levels deep. There are ways to cleanly test nested attributes, but all examples and documentation stops at one level deep, I was in uncharted territory to say the least.

To ensure you are on the same page, heres an example of my standard RSpec update setup:

describe 'PUT #update' do
  before do
    @promo = create(:promo)
  end

  context 'with valid attributes' do
    it 'should update the promo and redirect to the index' do
      process :update, method: :put, params: { id: @promo.id, promo: attributes_for(:promo, customer_promo_code: 'Updated Name') }
      @promo.reload

      expect(assigns(:promo)).to eq(@promo)
      expect(response).to redirect_to(admin_promos_path)
    end
  end
end

 

Pretty standard, heres whats happening:

process :update -> which controller action is being called

method: :put -> which type of route is to be used

params: -> params from the controller, in this case for promo

attributes_for(:promo) -> using the Promo model’s FactoryGirl configuration to update the object

customer_promo_code: -> overwriting the default FactoryGirl setting for customer_promo_code to a constant, to be able to test it.

Now, this is the bulk_promos_controller.rb . As you can see, the params include the promo , at the highest level, then the club_promos_attributes , which inherit the club_promo_plans_attributes , and that controller inherits the mms_prices_attributes, which is ultimately what I’m trying to get at.

# frozen_string_literal: true
class Admin::BulkPromosController < Admin::ApplicationController
  before_action :set_promo, only: :update

  def update
    if @bulk_promo.update(bulk_promo_params)
      redirect_to admin_promo_club_promos_path(@bulk_promo), notice: 'Promo was successfully updated.'
    else
      flash[:error] = 'Promo was not updated.'
      render :edit
    end
  end

  private
  def set_promo
    @bulk_promo = Promo.find(params[:id])
  end

  def bulk_promo_params
    params.require(:promo).permit(
      :customer_promo_code, club_ids:[],
      club_promos_attributes: [:id, :club_id, :promo_id,
      club_promo_plans_attributes: [:id, :club_promo_id, :plan_id,
      mms_prices_attributes: [:id, :payment_type, :mms_promo_code, :mms_installment_id, :mms_plan_id, :_destroy]]]
      )
  end
end

I tried to setup the bulk_promo_controller spec in a similar way to the promo controller (or any other updating action for that matter), using the attributes_for method, but the trouble was that I couldn’t burrow down through all the necessary associations to get at the proper mms_price. I don’t have examples of all the attempts, but I tried more iterations than I could count. I threw a binding.pry into the mix, to try and step through the process in the terminal, but it wasn’t happening.

Finally, I acknowledged that this was a bit over my head and requested a pair session with a senior dev. The first thing he asked me was ‘whats the readout from the server logs when you hit the controller from the web interface?’ I hadn’t even thought to check there, but it made total sense, especially because I knew the action was functioning correctly, I was essentially trying to duplicate that process in the test. (Thats definitely not the best practice in writing tests, but this has been an RSpec edge case from the get go). So, this is how I got the RSpec params for the test:

In the terminal running the local server, I ran command + k to clear the server log.

I went to my locally run browser page that had the bulk promo update action, and updated a record. Now, back in the server log, it will display all the parameters that were passed to update that mms_record.

Started PATCH "/admin/bulk_promos/394" for ::1 at 2017-10-05 11:29:16 -0600
Processing by Admin::BulkPromosController#update as HTML
  Parameters: {"utf8"=>"✓", "authenticity_token"=>"1l+8oy6dANoziB/PZ5HpRQAAnBU8Pp7j/BIdB2NxgQcyGqpglX7tV+T/EBY5yfPGFz1bKsJPT3hWyiOqpyh5dw==", "promo"=>{"club_promos_attributes"=>{"0"=>{"club_promo_plans_attributes"=>{"0"=>{"mms_prices_attributes"=>{"0"=>{"mms_plan_id"=>"thisistestmmscontent", "mms_installment_id"=>"", "mms_promo_code"=>"", "id"=>"2643"}, "1"=>{"mms_plan_id"=>"", "mms_installment_id"=>"", "mms_promo_code"=>"", "id"=>"2642"}}, "id"=>"1217"}, "1"=>{"mms_prices_attributes"=>{"0"=>{"mms_plan_id"=>"", "mms_installment_id"=>"", "mms_promo_code"=>"", "id"=>"2645"}, "1"=>{"mms_plan_id"=>"", "mms_installment_id"=>"", "mms_promo_code"=>"", "id"=>"2644"}}, "id"=>"1218"}, "2"=>{"mms_prices_attributes"=>{"0"=>{"mms_plan_id"=>"", "mms_installment_id"=>"", "mms_promo_code"=>"", "id"=>"2647"}, "1"=>{"mms_plan_id"=>"", "mms_installment_id"=>"", "mms_promo_code"=>"", "id"=>"2646"}}, "id"=>"1219"}, "3"=>{"mms_prices_attributes"=>{"0"=>{"mms_plan_id"=>"", "mms_installment_id"=>"", "mms_promo_code"=>"", "id"=>"2649"}, "1"=>{"mms_plan_id"=>"", "mms_installment_id"=>"", "mms_promo_code"=>"", "id"=>"2648"}}, "id"=>"1220"}}, "id"=>"481"}}}, "commit"=>"Update Promo Plans", "id"=>"394"}

I don’t know about anyone else, but I have a terrible time trying to read this output, so I copy/pasted it into my text editor, starting after the authenticity token . Now in the editor, I found where it started repeating, and broke out those lines. The indicators are the "id" labels, because thats what I was trying to identify, the id of the club_promoclub_promo_plan, and the mms_pricing.

I ended up with a pretty long hash (to help you read it, I replaced the random integers with the model id). The output was something like this:

{"club_promos_attributes" => {"0" => {"club_promo_plans_attributes" => {"0" => {"mms_prices_attributes" => {"0" => {"mms_promo_code" => "teststring","mms_installment_id" => "","mms_plan_id" => "","id" => "mms_price_id"}},"id" => "club_promo_plan_id"}},"id" => "club_promo_id"}}}

Using that pattern, I was able to assign the dynamic ids from the RSpec tests and place that hash (as a variable) into the params. With some formatting of that hash my working RSpec test looks like this:

describe 'PUT #update' do
  context 'with valid attributes' do
    it 'should update the promo with mms attribute and redirect to the index' do
      promo = create(:promo)
      promo.reload
      @club_promo_id = promo.club_promos.first.id
      @club_promo_plan_id = promo.club_promos.first.club_promo_plans.first.id
      @mms_price = promo.club_promos.first.club_promo_plans.first.mms_prices.first

      promo_attributes =  {
                            "club_promos_attributes" => {
                              "0" => {
                                "club_promo_plans_attributes" => {
                                  "0" => {
                                    "mms_prices_attributes" => {
                                      "0" => {
                                        "mms_promo_code" => "teststring",
                                        "mms_installment_id" => "",
                                        "mms_plan_id" => "",
                                        "id" => "#{@mms_price.id}"
                                      }
                                    },
                                    "id" => "#{@club_promo_plan_id}"
                                  }
                                },
                                "id" => "#{@club_promo_id}"
                              }
                            }
                          }

      process :update, method: :put, params: { id: promo.id, promo: promo_attributes }

      @mms_price.reload

      expect(@mms_price.mms_promo_code).to eq("teststring")
      expect(response).to redirect_to(admin_promo_club_promos_path(promo))
    end
  end
end

It’s not pretty, probably the ugliest RSpec I’ve written to date, but its functional and not brittle. I also feel like another dev could look at this test and have a pretty good understanding of whats going on, which is important. I’m open to feedback if anyone has a better strategy, let me know!

Sources:

http://www.rubydoc.info/gems/rspec-rails/https://github.com/thoughtbot/factory_girl/blob/master/GETTING_STARTED.md#using-factories

 

2 thoughts on “Updating Deeply Nested Attributes with RSpec”

  1. Wouldn’t it be easier to backtrack? E.g.

    @mms_price = MmsPrice.first
    @club_promo_plan = @mms_price.club_promo_plan
    @club_promo = @club_promo_plan.club_promo

  2. To make your test more focused (smaller/easier to read), extract the variable declarations to lets (instead of instance variables which when unassigned are nil). It’s also nice to use a before(:each) for setup code. One last thing, it looks like you can get rid of the first reload.

    Maybe also a personal preference, “should,” is weak. I prefer more concrete definitions like, “updates the promo with mms attribute and redirect to the index.” Since we’re here, if you see something like a “with” in your statement, move it up a level to a context block, so it reads like … context ‘with valid mms attribute’ > it ‘updates the promo’ . At that point, you can stuff the redirection test into the same test block, or make it another test block with, it ‘ redirects to the index’ . It will also be easier to set yourself up to write the ‘with invalid mms attribute’ test . It’s nice to check unhappy paths. Sorry about the rambling 🙂 .

Leave a Reply

Your email address will not be published. Required fields are marked *