Improving software delivery in every organisation

Feature testing with RSpec

We love writing tests at Made HQ. They are part of the foundation on which we work to provide our clients with stable deliveries. We work fast and deploy daily so we test vital paths of our applications using feature tests. We also unit test, albeit less, when we need to cover a range of edge cases.

In this post I will be discussing:

  • Briefly, what we use for our feature and unit tests
  • How our tests are structured in our rails apps
  • What a feature test looks like
  • Explaining why we do things this way

RSpec for both Unit and Feature testing

For both feature and unit tests we use RSpec. In the past we used Cucumber for feature tests and RSpec for unit tests but recently we have questioned the need for two separate testing tools.

The reasons we originally used Cucumber were primarily because its gherkin syntax gave us a concise way of describing the features of our application. We moved away because we found a way to do this in RSpec. No, we didn't decide to go with Spinach. We read this blog post.

We ended up dropping Cucumber because we could do the same thing in RSpec with half the configuration. Why duplicate configuration for DB cleaning? Why maintain helper files, VCR, code coverage, factories in two places? Another big benefit to moving to RSpec was the fact we could contain our features and the code that test the features in the same file. They are still readable by non-technical people and sure, they can't be written by them, but we've never asked them to anyway.

Test structure in a Rails app

We setup RSpec in our Rails applications the same way for every product. Here is an example of our directory structure for an ecommerce store:

spec/
  factories/
    order_factory.rb
    user_factory.rb
  fixtures/
    cassettes/
  features/
    shop/
      checkout/
        1_address_spec.rb
        2_delivery_spec.rb
        3_payment_spec.rb
        4_confirmation_spec.rb
      cart_spec.rb
    users/
      registrations_spec.rb
      sessions_spec.rb
  support/
    capybara.rb
    code_coverage.rb
    factories.rb
    rails.rb
    vcr.rb
  unit/
    controllers/
      locale_redirect_spec.rb
    models/
      order_spec.rb
      user_spec.rb
  spec_helper.rb

I've purposely listed out all the factories and support files too. This should give you a good overview of how we separate tests and configuration out.

We use subfolders inside features/ to categorise groups of features. It makes it easy to navigate our features as they grow, and also allows us to easily test a subject without using RSpec tags:

rspec spec/features/shop/checkout/

This command would test out our entire checkout process, for example.

Although out of the scope of this blog post, I'll briefly mention the fact we split out our unit tests into types, e.g. controllers, models, lib. We then, as required, use folders under these types to indicate namespacing. This is because of the nature of unit tests closely testing a particular class rather than a larger feature.

What our feature specs look like

So what do our feature specs look like, I hear you asking?

feature 'Checkout Step 2: Delivery' do
  scenario 'Standard delivery' do
    given_a_customer_has_reached_the_delivery_step
    when_they_choose_standard_delivery
    then_they_should_be_shown_the_standard_delivery_cost
    and_they_should_be_redirected_to_the_payment_step
  end

  scenario 'Nominate delivery date' do
    given_a_customer_has_reached_the_delivery_step
    when_they_choose_to_nominate_a_delivery_date
    then_they_should_be_shown_the_nominated_day_delivery_cost
    and_they_should_be_redirected_to_the_payment_step
  end

  def given_a_customer_has_reached_the_delivery_step
    create_order_for_logged_in_user(:delivery)
    visit spree.checkout_state_path(:delivery)
  end

  def when_they_choose_standard_delivery
    choose :standard_delivery_method
    click_button(I18n.t('shop.checkout.steps.delivery.submit'))
  end

  def then_they_should_be_shown_the_standard_delivery_cost
    expect(page).to have_content(standard_delivery_cost)
  end

  def and_they_should_be_redirected_to_the_payment_step
    expect(current_url).to eq(spree.checkout_state_path(:payment))
  end

  # second scenario steps excluded for brevity

  private
  def create_order_for_logged_in_user(step)
    order = OrderWalkthrough.up_to(step)
    login_as order.user
  end

  def standard_delivery_cost
    Rails.application.config[:shop][:delivery_method][:standard][:cost]
  end
end

That is a toned down version of a delivery step feature. As you can see it's pretty much the same style as that seen in FutureLearn's blog post.

Why write feature tests this way?

After we decided to move to using RSpec for "all the tests" we already knew we wanted to structure them similarly to FutureLearn, it was one of the main reasons we swapped to RSpec.

The original blog post gave it's reason for not reusing code between features as being primarily for readability.

We also discourage reuse of code between features and limit reuse within features to helper methods to reduce the fragility of our tests. By decoupling dependencies between tests we reduce the risk of changes to one scenario or feature affecting others. The readability is a great reason too!

Instead of reusing steps, we tend to lean on helper methods still defined within the confines of the feature scope but we differentiate these from steps by declaring them private. There is no functionality gained from making them private, it simply helps us visually separate steps and helpers.

As you can see there are 2 scenarios in this feature. We try to limit the number of scenarios to keep test times down. This means we have to focus on critical paths through the app. In this example there are actually 4 different delivery types but since standard delivery works in basically the same way as next day and same day delivery we only tested one of those options. The nominate delivery day works differently enough to require a scenario.

Choosing which scenarios to cover can often be a hard judgement to make and will definitely be the subject of a future blog post. We do not often test form validation at feature level, although we occasionally test failure cases. For example we wouldn't test what happens when a visitor forgets to enter their password when logging in, but we would test a card declined scenario in checkout.

So that's it, now you know what our feature testing looks like! That said, it is bound to have changed by the time you read this post.

How effective is your business at software delivery?

Answer these 20 questions and find out where the principal software delivery challenges lie within your organisation.

Get started now