Automated cross-browser testing with BrowserStack and CircleCI

Automated cross-browser testing with BrowserStack and CircleCI


Robot testing an application

By now, automated testing of code has hopefully become an industry standard. Ideally, you write your tests first and make them a runnable specification of what your code should do. When done right, test-driven development can improve code design, not mentioning you have a regression test suite to stop you from accidentally breaking things in the future. 

However, unit testing does just what it says on the tin: tests the code units (modules, classes, functions) in isolation. To know the whole application or system works, you need to test the integration of those modules.

That's nothing new either. At least in the web application world, which this post is about, we've had tools like Cucumber (which lets you write user scenarios in an almost human language) for years. You can then run these tests on a continuous integration server (we use the amazing CircleCI) and get a green light for every commit you push.

But when it comes to testing how things work in different web browsers, the situation is not that ideal. Or rather it wasn't. 

Automated testing in a real browser

The golden standard of automated testing against a real browser is Selenium, the browser automation tool that can drive many different browsers using a common API. In the ruby world, there are tools on top of Selenium providing a nice DSL for driving the browsers using domain specific commands like page.click 'Login' and expectations like page.has_content?('something').

Selenium will open a browser and run through your scripted scenario and check that everything you expected to happen did actually happen. This should still be an old story to you. You can improve on the default setup by using a faster headless browser (like PhantomJS), although watching your test complete a payment flow on PayPal is kinda cool. There is still a big limitation though.

When you need to test your application on multiple browsers, versions, operating systems and devices, you first need to have all that hardware and software and second, you need to run your test suite on all of them.

So far, we've mostly solved this by having human testers. But making humans test applications is a human rights violation and a time of a good tester is much better spent creatively trying to break things in an unexpected way. For some projects, there even isn't enough budget for a dedicated tester.

This is where cloud services, once again, come to the rescue. And the one we'll use is called BrowserStack.

BrowserStack

BrowserStack allows you to test your web applications in almost every combination of browser and OS/Device you can think of, all from your web browser. It spins up the right VM for you and gives you a remote screen to play around. That solves the first part of our problem, we no longer need to have all those devices and browsers. You can try it yourself at http://www.browserstack.com/.

Amazingly, BrowserStack solves even the second part of the problem by offering the automate feature: it can act as a Selenium server, to which you can connect your test suite by using Selenium remote driver and automate the testing. It even offers up to ten parallel testing sessions!

Testing an existing website

To begin with, let's configure a Cucumber test suite to run against a staging deployment of your application. That has it's limitations - you can only do things to the application that a real user could, so forget mocking and stubbing for now (but keep on reading).

We'll demonstrate the setup with a rails application, using cucumber and Capybara and assume you already have some scenario to run.

First, you need to tell Capybara what hostname to use instead of localhost

# features/support/capybara.rb

Capybara.app_host = 'http://staging.my-app.com'

Next, loosely following the BrowserStack documentation we'll configure the remote driver. Start with building the browser stack URL using environment variables to set the username and API authorization key.

# features/support/cross_browser.rb

url = "http://#{ENV['BS_USERNAME']}:#{ENV['BS_AUTHKEY']}@hub.browserstack.com/wd/hub"

then we need to set the desired capabilities of the remote browser. Let's ask for Chrome 33 on OS X Mavericks.

capabilities = Selenium::WebDriver::Remote::Capabilities.new
capabilities["os"] = "OS X"
capabilities["os_version"] = "Mavericks"
capabilities["browser"] = "chrome"
capabilities["browser_version"] = "33.0"
capabilities['browserstack.debug'] = true
capabilities['takesScreenshot'] = true

capabilities['project'] = "My project"
if ENV['BUILD_NUM']
  capabilities['build'] = ENV['BUILD_NUM']
end

Next step is to register a driver with these capabilities with Capybara 

# register a driver
driver_name = "chrome_33.0_Mavericks"
Capybara.register_driver(driver_name) do |app|
  Capybara::Selenium::Driver.new(app, :browser => :remote, :url => url, :desired_capabilities => capabilities)
end

and use it

# use the registered driver
Capybara.default_driver = driver_name
Capybara.javascript_driver = driver_name

If you run cucumber now, it should connect to BrowserStack and run your scenario. You can even watch it happen live in the Automate section!

Ok, that was a cool experiment, but we wanted multiple browsers and the ability to run on BrowserStack only when needed would be good as well.

Multiple different browsers

What we want then, is to be able to run a simple command to run cross-browser tests in one browser or a whole set of them. Something like

rake cross_browser

and

rake cross_browser:chrome

In fact, let's do exactly that. First of all, list all the browsers you want in a browsers.json in the root of your project 

Each of those browser configurations is stored under a short key we'll use throughout the configuration to make things simple.

The rake task will look something like the following


{
   "chrome": {
      "human": "Chrome 33 on OS X Mavericks",
      "browser_version":"33.0",
      "browser":"chrome",
      "os":"OS X",
      "os_version":"Mavericks"
   },
   "firefox": {
      "human": "Firefox 27 on OS X Mavericks",
      "browser_version":"27.0",
      "browser":"Firefox",
      "os":"OS X",
      "os_version":"Mavericks"
   },
   "ie_9": {
      "human": "IE 9 on Windows 7",
      "browser_version":"9.0",
      "browser":"IE",
      "os":"Windows",
      "os_version":"7"
   },
   "ie_10": {
      "human": "IE 10 on Windows 7",
      "browser_version":"10.0",
      "browser":"IE",
      "os":"Windows",
      "os_version":"7"
   }
}

First we load the JSON file and store it in a constant. Then we define a task that goes through the list and for each browser executes a browser specific task. The browser tasks are under a cross_browser namespace.

To pass the browser configuration to Capybara when Cucumber gets executed we'll use an environment variable. Instead of passing the whole configuration we can just pass the browser key and load the rest in the configuration itself. To be able to pass the environment variable based on the task name, we need to wrap the actual cucumber task in another task.

The inner task then extends the Cucumber::Rake::Task and provides some configuration for cucumber. Notice especially the --tags option, which means you can specifically tag Cucumber scenarios for cross-browser execution, only running the necessary subset to keep the time down (your daily time running BrowserStack sessions is likely limited after all).

The cross_browser.rb changes to the following:

# features/support/cross_browser.rb

def remote_browser?
  ENV.has_key? 'BROWSER_TASK_NAME'
end

if remote_browser?
  raise 'Please ensure you supply ENV variables BS_USERNAME and BS_AUTHKEY from browser stack' if !ENV['BS_USERNAME'] || !ENV['BS_AUTHKEY']

  url = "http://#{ENV['BS_USERNAME']}:#{ENV['BS_AUTHKEY']}@hub.browserstack.com/wd/hub"

  # load browser configuration
  browser_data = JSON.load(open('.browsers.json'))
  browser_name = ENV['BROWSER_TASK_NAME']

  browser = browser_data[browser_name]

  puts "Testing in #{browser['human']} (#{ENV['BROWSER_TASK_NAME']})..."

  # translate into Selenium Capabilities
  capabilities = Selenium::WebDriver::Remote::Capabilities.new
  capabilities["os"] = browser["os"]
  capabilities["os_version"] = browser["os_version"]
  capabilities["browser"] = browser["browser"]
  capabilities["browser_version"] = browser["browser_version"]
  capabilities['browserstack.debug'] = true
  capabilities['takesScreenshot'] = true

  capabilities['project'] = "My project"
  if ENV['BUILD_NUM']
    capabilities['build'] = ENV['BUILD_NUM']
  end

  # register a driver
  driver_name = "#{browser['browser']}_#{browser['browser_version']}_#{browser['os']}_#{browser['os_version']}"
  Capybara.register_driver(driver_name) do |app|
    Capybara::Selenium::Driver.new(app, :browser => :remote, :url => url, :desired_capabilities => capabilities)
  end

  # use the registered driver
  Capybara.default_driver = driver_name
  Capybara.javascript_driver = driver_name
end


That should now let you run

rake cross_browser

and watch the four browsers fly through your your scenarios one after another.

We've used this setup with a few modifications for a while. It has a serious limitation however. Because the remote browsers is accessing a real site, it can only do as much as a real user can do. The initial state setup and repeatability is difficult. Not mentioning it isn't the fastest solution. We really need to run the application locally.

Local testing

Running your application locally and letting Capybara start your server enables you to do everything you are used to in your automated tests - load fixtures, create data with factories, mock and stub pieces of your infrastructure, etc. But how can a browser running in a cloud access your local machine? You will need to dig a tunnel.

BrowserStack provides a set of binaries able to open a tunnel to the remote VM and connect to any hostname and port from the local one. The remote browser can then connect to that hostname as if it could itself access it. You can read all about it in the documentation.

After you downloaded a BrowserStack tunnel binary for your platform, you'll need to change the configuration again. The app_host is localhost once again and we also need Capybara to start a local server for us.

# features/support/capybara.rb

Capybara.run_server = true
Capybara.server_port = 3001
Capybara.app_host = 'http://127.0.0.1:3001'

We also need to tell BrowserStack we want to use the tunnel. Just add

capabilities['browserstack.local'] = true

to the list of capabilities. Start the tunnel and run the specs again

./BrowserStackLocal -skipCheck $BS_AUTHKEY 127.0.0.1,3001 &
rake cross_browser

This time everything should go a bit faster. You can also test more complex systems that need external APIs or direct access to your data store because you can now mock those.

This is great! I want that to run for every single build before it's deployed like my unit tests. Testing everything as much as possible is what CI servers are for after all.

Running on CircleCI

We really like CircleCI for it's reliability, great UI and especially it's ease of configuration and libraries and services support.

On top of that, their online chat support deserves a praise in a separate paragraph. Someone is in the chat room all the time, responds almost immediately and they are always very helpful. They even fix an occasional bug in near real time.

To run our cross browser tests on CircleCI we will need a circle.yml file and a few changes to the configuration. The circle.yml will contain the following

dependencies:
  cache_directories:
    - browserstack
test:
  override:
    - RAILS_ENV=test bundle exec rake spec
    - RAILS_ENV=test bundle exec cucumber
    - script/ci/browserstack_tunnel.sh
    - browserstack/BrowserStackLocal -skipCheck $BS_AUTHKEY 127.0.0.1,3001
        background: true
    - RAILS_ENV=test bundle exec rake cross_browser
    - script/ci/browserstack_tunnel.sh stop:

We run unit tests, then cucumber specs normally, then open the tunnel and run our rake task. When it's done, we can close the tunnel again. To download and eventually stop the tunnel we wrote a little shell script

#!/bin/bash

if [[ "$1" == "stop" ]]
then
  echo "Stopping browserstack tunnel..."
  killall BrowserStackLocal
  exit
fi

if [[ ! -e browserstack ]]
then
  echo "Downloading browserstack tunnel..."
  mkdir -p browserstack
  curl https://www.browserstack.com/browserstack-local/BrowserStackLocal-linux-x64.zip > browserstack/BrowserStackLocal-linux-x64.zip
  cd browserstack && unzip BrowserStackLocal-linux-x64.zip && chmod a+x BrowserStackLocal
  cd ..
fi

echo "Browserstack tunnel ready..."

It downloads the 64-bit linux browserstack binary and unpacks it into a browserstack directory (which is cached by CircleCI). When passed a stop parameter, it will kill all the browserstack tunnels running. (We will eventually make the script start the tunnel as well, but we had problems with backgrounding the process so it's done as an explicit step for now).

Finally, we can update the configuration to use the project name and build number supplied by Circle to name the builds for BrowserStack

capabilities['project'] = ENV['CIRCLE_PROJECT_REPONAME'] if ENV['CIRCLE_PROJECT_REPONAME']
capabilities['build'] = ENV['CIRCLE_BUILD_NUM'] if ENV['CIRCLE_BUILD_NUM']

That setup should work, but it will take a while going through all the browsers. That is a problem when you work in multiple branches in parallel, because the testing becomes a race for resources. We can use another brilliant feature of CircleCI to limit the impact of this issue: we can run the tests in parallel.

The holy grail

Marking any task in circle.yml with parallel: true will make it run in multiple containers at the same time. You can than scale your build up to as many containers you want (and are willing to pay for). We are limited by the concurrency BrowserStack offers us and on top of that we're using just 4 browsers anyway, so let's start with four, but plan for more devices.

First, we need to spread the individual browser jobs across the containers. We can use the environment variables provided by CircleCI to see which container we're running on. Our final rake task will look like this

desc 'Run all cross browser tests in parallel (with ENV["nodes"] parallel limit)'
task :cross_browser => [:environment] do
  parallel_limit = ENV["nodes"] || 2
  parallel_limit = parallel_limit.to_i

  sliced = BROWSERS.keys.each_slice(parallel_limit).to_a
  browser_groups = sliced.first.zip(*sliced[1..-1])

  browser_groups.each_with_index do |browser_group, group_index|
    browser_group.compact.each do |browser_name|
      if ENV['CIRCLE_NODE_INDEX'].to_i == group_index
        puts "Cross browser testing against #{browser_name}."

        Rake::Task["reset"].execute
        Rake::Task["cross_browser:#{browser_name}"].execute
      end
    end
  end

Reading the nodes environment variable we check the concurrency limit and spread the browsers across the same number of buckets. For each bucket, we'll only run the actual test if the CIRCLE_NODE_INDEX is the same as the order of the bucket.

Because we're now opening multiple tunnels to BrowserStack, we need to name them. Add

capabilities['browserstack.localIdentifier'] = "#{ENV['CIRCLE_PROJECT_REPONAME']}-node-#{ENV['CIRCLE_NODE_INDEX']}"

to the capabilities configuration in cross_browser.rb. The final file looks like this

def remote_browser?
  ENV.has_key? 'BROWSER_TASK_NAME'
end

if remote_browser?
  raise 'Please ensure you supply ENV variables BS_USERNAME and BS_AUTHKEY from browser stack' if !ENV['BS_USERNAME'] || !ENV['BS_AUTHKEY']

  url = "http://#{ENV['BS_USERNAME']}:#{ENV['BS_AUTHKEY']}@hub.browserstack.com/wd/hub"

  # load browser configuration
  browser_data = JSON.load(open('.browsers.json'))
  browser_name = ENV['BROWSER_TASK_NAME']

  browser = browser_data[browser_name]

  puts "Testing in #{browser['human']} (#{ENV['BROWSER_TASK_NAME']})..."

  # translate into Selenium Capabilities
  capabilities = Selenium::WebDriver::Remote::Capabilities.new
  capabilities["os"] = browser["os"]
  capabilities["os_version"] = browser["os_version"]
  capabilities["browser"] = browser["browser"]
  capabilities["browser_version"] = browser["browser_version"]
  capabilities['browserstack.debug'] = true
  capabilities['browserstack.local'] = true
  capabilities['browserstack.localIdentifier'] = "#{ENV['CIRCLE_PROJECT_REPONAME']}-node-#{ENV['CIRCLE_NODE_INDEX']}"
  capabilities['takesScreenshot'] = true

  capabilities['project'] = ENV['CIRCLE_PROJECT_REPONAME'] if ENV['CIRCLE_PROJECT_REPONAME']
  capabilities['build'] = ENV['CIRCLE_BUILD_NUM'] if ENV['CIRCLE_BUILD_NUM']

  # register a driver
  driver_name = "#{browser['browser']}_#{browser['browser_version']}_#{browser['os']}_#{browser['os_version']}"
  Capybara.register_driver(driver_name) do |app|
    Capybara::Selenium::Driver.new(app, :browser => :remote, :url => url, :desired_capabilities => capabilities)
  end

  # use the registered driver
  Capybara.default_driver = driver_name
  Capybara.javascript_driver = driver_name

  # Resize browser window to be consistant when testing cross browser
  Before do
    page.driver.browser.manage.window.resize_to(1280, 800)
  end
end

We need to supply the same identifier when openning the tunnel from circle.yml. We also need to run all the cross-browser related commands in parallel. Final circle.yml will look like the following (notice the added nodes=4 when running the tests)

dependencies:
  cache_directories:
    - browserstack
test:
  override:
    - RAILS_ENV=test bundle exec rake spec
    - RAILS_ENV=test bundle exec cucumber
    - script/ci/browserstack_tunnel.sh:
        parallel: true
    - browserstack/BrowserStackLocal -localIdentifier "$CIRCLE_PROJECT_REPONAME-node-$CIRCLE_NODE_INDEX" -skipCheck $BS_AUTHKEY 127.0.0.1,3001:
        parallel: true
        background: true
    - RAILS_ENV=test bundle exec rake cross_browser nodes=4:
        parallel: true
    - script/ci/browserstack_tunnel.sh stop:
        parallel: true

And that's it. You can now scale your build out to four containers and run the tests in paralel. For us this gets the build time down to about 12 minutes on a complex app and 5 minutes on a very simple one.

Conclusions

We are really happy with this setup. It's really stable, fast, individual test runs are completely isolated and we don't need to deploy anything anywhere. It has just one drawback compared to the previous setup which first deployed the application to a staging environment and then ran cross-browsers tests against it. It doesn't test the app in it's real runtime environment (Heroku in our case). Otherwise it's a complete win on all fronts.

We plan to solve that remaining problem by writing a separate test suite testing our whole system (consisting from multiple services consuming each other's APIs) cleanly from the outside. It won't go into as much detail as the normal tests since it is only there to confirm that the different pieces fit together and users can complete the most important journes. Coupled with Heroku's slug promotion feature, we will actually test the exact thing that will end up in production in the exact same environment. And you can look forward to another blogpost about that soon.

Similar posts

Are you looking to build a digital capability?