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 capybara
, selenium-webdriver
, database_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.