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

 

The Tail of Truncation

Last week I came across a pretty solid problem, by the time I found the most appropriate solution it had evolved quite a bit. I felt it had the making for a worthwhile blog post, so here it goes.

The problem: we need to display only the first 220 characters of the body of a Press article on the Press Index page.

Initial solution: implement Rails truncate helper. It can be used in the view, as such:

truncate(press.description, length: 220)

This worked for the first 9 items on the page, running as a standard each loop. But we are also implementing infinite scroll on this page with javascript, which renders each item after the ninth through an HMTL template and JSON. So, for the template I had to make a method in the press_serializer.rb file that looks like this:

def description
    object.description.truncate(220)
end

That all worked, but didn’t render well with the other styles on the page, was showing some markup tags, and isn’t the most efficient solution. So, I moved the nuts and bolts from the view to the application_helper.rb and made it into a method:

def truncate_description(description)
    description.truncate(220).html_safe
 end

So now in the view it looks like this:

truncate_description(press.description)

I also modified the method in the serializer for the html template to this:

def description
    object.description.truncate(220).html_safe
end

It’s definitely cleaner code in the view file, and its faster coming from the application helper.

Note that the .html_safe is necessary because we are using a WYSIWYG (What You See Is What You Get) editor to create the description, so there are potentially a lot of opening and closing of tags.

This all worked in our QA and the clients. The first 9 records were being produced via the each loop, and all the rest with the JSON and HTML template. But, as it so happened, we came across an edge case, and that was my problem to solve.

New problem: Basically, what was happening was that the clients wrote a description and included a link within the first 220 characters, which was cut off by the truncate helper. This meant that there was a tag that wasn’t closing, and it bled the link content onto the first piece of text in the next description card.

What would have been super easy was just to tell the clients to rewrite the article, and move the link. Oh, and in the future, don’t put a link within the first 220 characters, thanks! But, of course, that’s not an option. The solution seems like an obvious one, even to me as a write this, but it actually took a good bit of googling to find it.

New Solution: Ultimately I ended up using the truncate_html gem for the Ruby components and html-truncate gem for the JSON components. The Ruby part can be setup like this:

Add truncate_html gem to Gemfile

Replace truncate with truncate_html in the view

truncate_html(press.description)

It can be as simple as that, but to add some versatility I also created a truncate_html.rb file in my initializers folder:

TruncateHtml.configure do |config|
   config.length        = 220
 end

The length could have been added to the line in the view file, but it’s cleaner to have the initializer file, and allows for growth, should any other parameters be necessary.

As for the JSON and HTML templates, after I installed html-truncate I just had to two variables to the top of the HTML template file:

var truncate = require('html-truncate');

let description = truncate(press.description, 220);

Now both the Ruby gem and JS package will close any open tags before truncating, allowing for there to be links, bold tags, or whatever at any place in the description with no problem at all.

Resources:
http://api.rubyonrails.org/classes/ActionView/Helpers/TextHelper.html#method-i-truncate
https://github.com/hgmnz/truncate_html
https://www.npmjs.com/package/html-truncate