How to Use RSpec to Test an Angular/Rails Single-Page Application

I struggled for some time deciding how to “properly” put together an integration test for an Angular/Rails single-page application. First I tried to drive Rails with Protractor but that felt weird. Then it dawned on me that the fact that the SPA-ness of my Rails app is just a detail. This is a Rails app that happens to be a single-page application. I can still use RSpec to test my SPA just like I would for a non-SPA.

But because my Rails API layer is (rightfully) unaware of the client layer under normal circumstances, it’s not possible to simply act as if the SPA is a non-SPA. RSpec doesn’t know anything about the UI. My solution to this problem is to simulate a deployment before my test suite runs. “Simulate a deployment” is my fancy way of saying “run a grunt build“. It’s actually super simple.

Before you implement anything described below, you’ll probably want to configure Grunt to run Rails and get HTML5 pushState working. You may also like to read my post on how to deploy an Angular/Rails single-page app, because part of what I do here overlaps with that, and might not seem to make complete sense without understanding how I do a deployment.

The Feature We’re Testing

The “feature” I’ll demonstrate testing is a really simple one: a static list of groups. This feature is unrealistically simple but we cover enough ground by testing it that it will (I think) be obvious when we’re done how you’d test something more complex.

Here’s the Angular controller I’m using:

// client/app/scripts/controllers/groups.js

'use strict';

/**
 * @ngdoc function
 * @name fakeLunchHubApp.controller:GroupsCtrl
 * @description
 * # GroupsCtrl
 * Controller of the fakeLunchHubApp
 */
angular.module('fakeLunchHubApp')
  .controller('GroupsCtrl', ['$scope', function ($scope) {
    $scope.groups = ['Group One', 'Group Two'];
  }]);

Here’s my view for it:

<!-- client/app/views/groups.html -->

Groups:

<ul ng-repeat="group in groups">
  <li>{{group}}</li>
</ul>

And here’s my route:

// client/app/scripts/app.js

.when('/groups', {
  templateUrl: 'views/groups.html',
  controller: 'GroupsCtrl'
})

Configuring Grunt’s Build Correctly

First, remove Rails’ public directory. Our public directory from now on will be replaced by the output of grunt build. (If you haven’t yet, I’d recommend reading How to Deploy an Angular/Rails Single-Page Application to Heroku.)

$ rm -rf public

In our Gruntfile we’ll change the grunt build output directory from dist to ../public – the same public we just deleted. Change this:

// client/Gruntfile

var appConfig = {
  app: require('./bower.json').appPath || 'app',
  dist: 'dist'
};

To this:

// client/Gruntfile

var appConfig = {
  app: require('./bower.json').appPath || 'app',
  dist: '../public'
};

Now if you run rails server and navigate to http://localhost:3000/, you should see your single-page app there, behaving exactly as it does when served by grunt serve.

Configuring RSpec

We’ll need to tell RSpec to run a grunt build before each integration test, and afterward we’ll want to kill the public directory we created. Add the following to spec/spec_helper.rb.

# spec/spec_helper.rb

config.before(:all, type: :feature) do
  system("grunt build --gruntfile #{Rails.configuration.gruntfile_location}")
end

config.after(:all, type: :feature) do
  FileUtils.rm_rf(Rails.root.join("public"))
end

Here’s my full spec/spec_helper.rb for reference:

# This file is copied to spec/ when you run 'rails generate rspec:install'
ENV["RAILS_ENV"] ||= 'test'
require File.expand_path("../../config/environment", __FILE__)
require 'rspec/rails'
require 'rspec/autorun'

# Requires supporting ruby files with custom matchers and macros, etc,
# in spec/support/ and its subdirectories.
Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f }

# Checks for pending migrations before tests are run.
# If you are not using ActiveRecord, you can remove this line.
ActiveRecord::Migration.check_pending! if defined?(ActiveRecord::Migration)

Capybara.javascript_driver = :selenium

# Includes rack-rewrite configuration so HTML5 pushState can function properly.
Capybara.app = Rack::Builder.new do
  eval File.read(Rails.root.join('config.ru'))
end 

RSpec.configure do |config|
  # ## Mock Framework
  #
  # If you prefer to use mocha, flexmock or RR, uncomment the appropriate line:
  #
  # config.mock_with :mocha
  # config.mock_with :flexmock
  # config.mock_with :rr

  # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
  config.fixture_path = "#{::Rails.root}/spec/fixtures"

  # If you're not using ActiveRecord, or you'd prefer not to run each of your
  # examples within a transaction, remove the following line or assign false
  # instead of true.
  config.use_transactional_fixtures = false

  # If true, the base class of anonymous controllers will be inferred
  # automatically. This will be the default behavior in future versions of
  # rspec-rails.
  config.infer_base_class_for_anonymous_controllers = false

  # Run specs in random order to surface order dependencies. If you find an
  # order dependency and want to debug it, you can fix the order by providing
  # the seed, which is printed after each run.
  #     --seed 1234
  config.order = "random"

  config.before(:suite) do
    DatabaseCleaner.strategy = :truncation
    DatabaseCleaner.clean_with(:truncation)
  end

  config.before(:all, type: :feature) do
    system("grunt build --gruntfile #{Rails.configuration.gruntfile_location}")
  end

  config.after(:all, type: :feature) do
    FileUtils.rm_rf(Rails.root.join("public"))
  end

  config.before(:each) do
    DatabaseCleaner.start
  end

  config.after(:each) do
    DatabaseCleaner.clean
  end
end

Notice the Rails.configuration.gruntfile_location in spec_helper.rb. This configuration setting doesn’t exist yet. We’ll need to define it. (By the way, this is just an arbitrary configuration setting I came up with. It seemed more appropriate to me to define the Gruntfile location as a config setting rather than hard-coding it in this spec_helper.rb.)

To define gruntfile_location, add this line to config/environments/test.rb:

config.gruntfile_location = "client/Gruntfile.js"

You’ll also need to install a few certain gems in order for all this to work, including capybaraselenium-webdriverdatabase_cleaner and compass. (database_cleaner is actually not strictly necessary for what we’re doing but my sample code includes it so your tests won’t run without it if you’re copying and pasting my code.)

source 'https://rubygems.org'

gem 'rails', '4.1.6'
gem 'rails-api'
gem 'spring', :group => :development

# Use PostgreSQL as the RDBMS.
gem 'pg'

# Use devise_token_auth for authentication.
gem 'devise_token_auth'

# Use rack-rewrite to allow use of HTML5 pushState.
gem 'rack-rewrite'

group :test do
  gem 'rspec-rails'
  gem 'capybara'
  gem 'selenium-webdriver'
  gem 'database_cleaner'
  gem 'compass'
end

Now you’ll need to do a bundle install, of course.

The Spec Itself

Here’s the spec I wrote to verify that the group list contains “Group One”.

# spec/features/group_spec.rb

require 'spec_helper'

feature 'Groups', js: true do
  scenario 'view' do
    visit '/groups'
    expect(page).to have_content('Group One')
  end
end

If you run rspec spec/features/group_spec.rb, it should pass.