Complex rspec specs can intermittently fail in continuous integration

We use an external service to perform our full suite of unit and integration rspec tests on every push of changes to our revision control repository.

We’ve found that tests that pass locally end up failing or, even worse, intermittently failing (flap) when run by the Continuous Integration service.

This is often due to dependencies that are available locally but are not part of our project or correctly prepared for in our tests.

Another common reason is complex test code that becomes fragile in the context of the third party CI environment.

So, here are some things I look to clean up when I see a ‘flapping’ spec:

  • Use factories. Rather than update models after creating them change the factory to accept a transient attribute and modify or add dependent models in an after build or create block. In other words try not to persist to the database in code in the spec itself particularly if it is a feature spec.
  • Assign variables using let blocks rather than within tests or before blocks.
  • code>mkdir_p before writing files to tmp directories to ensure the directory will exist when you need it.
  • Create straightforward assertions. Have one expectation per test unless the runtime savings of combining a group into a single test clearly outweighs the complexity and potential fragility you’re introducing.

Most of these are best practice for rspec tests. As with many things in software, following a recommended pattern avoids unpleasant surprises because smart people have solved problems before you can stumble into them.

Before cleanup

[ruby]require 'spec_helper'

describe 'ProductFeedGenerator' do

  it 'should generate an entry with no parent book, delete flag set to 0, and is in stock with US rights' do
    author = FactoryGirl.create(:author)
    cover_image = FactoryGirl.create(:tagged_asset, :cover_image, :image)
    sub_category1 = FactoryGirl.create(:sub_category, list_type: SubCategory::ListTypes::BISAC)
    sub_category2 = FactoryGirl.create(:sub_category, list_type: SubCategory::ListTypes::BISAC)
    sub_category3 = FactoryGirl.create(:sub_category, list_type: SubCategory::ListTypes::AUS)
    work = FactoryGirl.create(:work, sub_categories: [sub_category1, sub_category2, sub_category3])
    book = FactoryGirl.create(:book, :us, :available_for_sale, author: author, tagged_assets: [cover_image], work: work)

    description = BookShortDescription.create(book: book, country: 'us', short_description: 'A short description')

    product_number = book.isbn13
    product_name = 'Under the Dome'
    product_desc = description.short_description
    product_url = "http://foo.org/book/3",
    image_url = 'http://wtnadgfadhguy.cloudfront.net/assets/9366_1666839.jpg'
    parent_product_number = nil
    parent_product_name = nil
    kit_number = nil
    brand_id = author.key 
    brand_name = author.name
    msrp = 35.00
    list_price = 35.00
    sale_price = nil
    sale_condition = 'new'
    availability = 'in stock'
    available_qty = 0
    product_category_number = "#{sub_category1.code},#{sub_category2.code}"
    date_created = nil
    date_modified = nil
    delete_flag = 0

    product_attributes_fields = (1..10).map { nil }

    output_row = [product_number,
                  product_name,
                  product_desc,
                  product_url,
                  image_url,
                  parent_product_number,
                  parent_product_name,
                  kit_number,
                  brand_id,
                  brand_name,
                  msrp,
                  list_price,
                  sale_price,
                  sale_condition,
                  availability,
                  available_qty,
                  product_category_number,
                  date_created,
                  date_modified,
                  delete_flag]

    output_row += product_attributes_fields

    output_row = output_row.join("\t") + "\r\n"

    expect(Agilone::ProductFeedGenerator.generate_row(book.isbn13, 'US')).to eq output_row
  end

  it 'should generate an entry with a parent book, delete flag set to 1, and is osi not with US rights' do
    author = FactoryGirl.create(:author)
    cover_image = FactoryGirl.create(:tagged_asset, :cover_image, :image)
    sub_category1 = FactoryGirl.create(:sub_category, list_type: SubCategory::ListTypes::BISAC)
    sub_category2 = FactoryGirl.create(:sub_category, list_type: SubCategory::ListTypes::BISAC)
    work = FactoryGirl.create(:work, sub_categories: [sub_category1, sub_category2])
    book = FactoryGirl.create(:book, :us, :not_available_for_sale, author: author, tagged_assets: [cover_image], work: work, item_format: Book::Format::TRADE_PAPERBACK)
    other_book = FactoryGirl.create(:book, :us, :available_for_sale, author: author, tagged_assets: [cover_image], work: work, title: 'Another title')

    other_book.key_dates = [BookKeyDate.new(book: other_book, country: 'us', date: Date.yesterday, date_type: 'on_sale_date')]
    other_book.save!

    description = BookShortDescription.create(book: book, country: 'us', short_description: 'A short description')

    product_number = book.isbn13
    product_name = 'Under the Dome'
    product_desc = description.short_description
    product_url = "http://books.simonandschuster.com/Under-the-Dome/Stephen-King/#{book.isbn13}"
    image_url = 'http://d1jqlel0jyvr6e.cloudfront.net/tagged_assets/9366_1666839.jpg'
    parent_product_number = other_book.isbn13
    parent_product_name = other_book.title
    kit_number = nil
    brand_id = author.key 
    brand_name = author.name
    msrp = 35.00
    list_price = 35.00
    sale_price = nil
    sale_condition = 'new'
    availability = 'out of stock'
    available_qty = 0
    product_category_number = "#{sub_category1.code},#{sub_category2.code}"
    date_created = nil
    date_modified = nil
    delete_flag = 1

    product_attributes_fields = (1..10).map { nil }

    output_row = [product_number,
                  product_name,
                  product_desc,
                  product_url,
                  image_url,
                  parent_product_number,
                  parent_product_name,
                  kit_number,
                  brand_id,
                  brand_name,
                  msrp,
                  list_price,
                  sale_price,
                  sale_condition,
                  availability,
                  available_qty,
                  product_category_number,
                  date_created,
                  date_modified,
                  delete_flag]

    output_row += product_attributes_fields

    output_row = output_row.join("\t") + "\r\n"

    expect(Agilone::ProductFeedGenerator.generate_row(book.isbn13, 'US')).to eq output_row
  end
end[/ruby]

After cleanup

[ruby]require 'spec_helper'

describe 'ProductFeedGenerator' do

  let(:author) { FactoryGirl.create(:author) }
  let(:cover_image) { FactoryGirl.create(:tagged_asset, :cover_image, :image) }
  let(:sub_category1) { FactoryGirl.create(:sub_category, list_type: SubCategory::ListTypes::BISAC) }
  let(:sub_category2) { FactoryGirl.create(:sub_category, list_type: SubCategory::ListTypes::BISAC) }
  let(:sub_category3) { nil }
  let(:work) { FactoryGirl.create(:work, sub_categories: [sub_category1, sub_category2, sub_category3].compact) }
  let(:description) { BookShortDescription.create(book: book, country: 'us', short_description: 'A short description') }
  
  let!(:output_row) {
    ([book.isbn13,
      book.title,
      description.short_description,
      "http://foo.org/book/#{book.id}",
      "http://#{s3.cloudfront_host}/#{book.image}",
      parent_product_number,
      parent_product_name,
      nil,
      author.key,
      author.name,
      35.00,
      35.00,
      nil,
      'new',
      availability,
      0,
      "#{sub_category1.code},#{sub_category2.code}",
      nil,
      nil,
      delete_flag] + (1..10).map { nil }).join("\t") + "\r\n"
  }
  
  subject { Agilone::ProductFeedGenerator.generate_row(book.isbn13, 'US') }
  
  context 'should generate an entry with no parent book, delete flag set to 0, and is in stock with US rights' do
    let(:sub_category3) { FactoryGirl.create(:sub_category, list_type: SubCategory::ListTypes::AUS) }
    let(:book) { FactoryGirl.create(:book, :us, :available_for_sale, author: author, tagged_assets: [cover_image], work: work) }
    let(:parent_product_number) { nil }
    let(:parent_product_name) { nil }
    let(:availability) { 'in stock' }
    let(:delete_flag) { 0 }
    

    it { is_expected.to eq output_row }
  end

  context 'should generate an entry with a parent book, delete flag set to 1, and is osi not with US rights' do
    let(:book) { FactoryGirl.create(:book, :us, :not_available_for_sale, author: author, tagged_assets: [cover_image], work: work, item_format: Book::Format::TRADE_PAPERBACK) }
    let(:other_book) { FactoryGirl.create(:book, :us, :available_for_sale, author: author, tagged_assets: [cover_image], work: work, title: 'Another title', on_sale_date: Date.yesterday) }
    let(:parent_product_number) { other_book.isbn13 }
    let(:parent_product_name) { other_book.title }
    let(:availability) { 'out of stock' }
    let(:delete_flag) { 1 }

    it { is_expected.to include(other_book.isbn13) }
    it { is_expected.to include(other_book.title) }
    it { is_expected.to include('out of stock') }
    it { is_expected.to match(/1\t+\r\n$/)}
  end
end[/ruby]

Force bundler to rebuild your Ruby on Rails project gemset

I got myself into a bad place after migrating a Rails project to a newer version of Ruby where my gemset was built with the wrong native libraries (rbenv rehash?).

Resulting error when running rspec:

dyld: lazy symbol binding failed: Symbol not found: _rb_funcall2

I had to delete and rebuild my gems but bundler itself doesn’t offer a pristine option.

The easiest way I found was to temporarily remove all gems from my Gemfile so that my Gemfile looked like.

source 'https://rubygems.org'

ruby '2.1.2'

Then:

bundle clean –force

Then undo and re-save my complete Gemfile and:

bundle install

Good to go.

Unintelligent design – the abuse of ‘inspect and adapt’ in Agile practice

Are you Agile if you don’t ____?

I just read an unpublished experience report. The report observes a team with over a year’s experience together within an organization that has several other Agile teams. Each team has a adopted different set of practices.

Specifically, this team followed Scrum with the following notable exceptions: 1) they didn’t contribute to the product vision because it is not the corporate culture 2) the team didn’t retrospect because they didn’t believe they had time.

The author then went on to conclude:

Customizing practices has allowed them to apply Agile in an effective way.

A conclusion I characterize as:

a) since the team has altered or omitted some Agile practices, and b) they consider themselves successful therefore c) the compromises were necessary and effective.

I will also note that all the adaptations described were compromises to the team’s Agile practice itself and the rationale for those adaptations was to fit into the existing corporate culture.

While the case study observes the team with some rigor, it leaps to three conclusions: that the team is effective, that the specific adaptations they note contribute to the team’s effectiveness, and that the examples are evidence of a successful Agile adoption.

I believe this is a pretty common take on Agile adoption, if not in words, in execution. And it is a fundamental misreading of the goals of continuous improvement in Agile practice.

Adaptation is both a tool for survival and optimization

building-blocks-iStock_000000102434Medium

When we strive after Agile software development, we encounter obstacles from outside our team, from team members and within ourselves. In the face of an obstacle we ask ourselves, “Can I remove it or do I move around it?” The pragmatic and necessary answer, may be to adjust our tactics, compromise our practices and move on.

Then, as our team becomes more adept some of the ceremonies may prove unnecessary as the values which those ceremonies re-enforce become ingrained as habits. And so, again, we relax our embrace of certain practices.

As we adapt our practices, we must regularly and skeptically inspect the assumptions behind those adaptations.

We must reflect on any claim that “we have to do ______” or “we don’t need to do ______”.

So, specific to the team the doesn’t hold retros, I have two lines of enquiry:

If they don’t have time to hold retrospectives but they believe they’re important then how are they going to make time?

If they don’t believe retrospectives are valuable enough for the time they take then what about their attempts are ineffective? Do they understand why retrospection is valuable? Do they know how to facilitate one? More profoundly, is something missing in their organization: low trust among the team, no authority to act on anything that arises, or lack of trust by management? These obstacles are worse than a simple lack of time management and will cripple the team’s collective ownership.

Even a necessary, pragmatic adaptation of the moment, if pursued un-reflectively will at some point hold a team back because it either protects and carries forward a dysfunction or encourages new ones to arise around it.

When reviewing your team’s practices ask yourself: have circumstances or people changed? Has the team earned enough trust, achieved enough success to take on a previously intractable obstacle? Is it the same team? Maybe it’s time to re-adopt or refresh? Have we gained wisdom or humility? Sometimes, we cannot grasp the value we unlock if we pursue a practice until it becomes a habit. It is our job to learn, to have courage, and to make that case both to our leaders and to our peers.

As Agile practitioners, we have to understand that adaptation requires contemplation and a larger perspective. We have to challenge both the immediate obstacle and the premise behind our own decision to avoid it. We have to revisit decisions over time. And we have to embrace a vision of what we are trying to achieve that is larger than the immediate problem.

My next post will go more into the last of those concerns. A vision of what Agile adoption is meant to achieve and what I see as the great danger of naive, unreflective adaptation of its practices — surrender to the cynical misuse of the development team…