Notes to self

How to test static sites with RSpec, Capybara, and Webkit

Automated tests are not only good for dynamic code. You can (and probably should) test your static web sites as well. Here is an example of me testing the Fedora Developer website using Ruby, RSpec and Capybara.

First, let’s briefly stress out why the testing is really needed. Fedora Developer is statically built with Jekyll and the content itself is created by many contributors. As you can imagine, things can break pretty easily. It’s one thing to test the layout and things you are in control of, but another when you want to make sure that people are creating new content as expected.

One of the things we want to make sure of is the correct internal linking of other pages. Not everybody immediately knows how to write a relative Markdown link for the website that is going to be generated later. Even as a reviewer I might be looking it up. Solution? Write tests and run them before the deployment of the new version of the website.

Since Jekyll already implies that Ruby is installed and available, let’s write a Capybara/Webkit specs similarly as if we would test a Rails web application. In the website root we add a new spec directory with the following spec_helper.rb:

# This spec needs rubygem-rack, rubygem-capybara and rubygem-rspec installed.
# Run as `rspec spec/` in the project root directory.

require 'rack'
require 'capybara'
require 'capybara/dsl'
require 'capybara/session'
require 'capybara/rspec'
require_relative './shared_contexts.rb'

class JekyllSite
  attr_reader :root, :server

  def initialize(root)
    @root = root
    @server = Rack::File.new(root)
  end

  def call(env)
    path = env['PATH_INFO']

    # Use index.html for / paths
    if path == '/' && exists?('index.html')
      env['PATH_INFO'] = '/index.html'
    elsif !exists?(path) && exists?(path + '.html')
      env['PATH_INFO'] += '.html'
    end

    server.call(env)
  end

  def exists?(path)
    File.exist?(File.join(root, path))
  end
end

# Setup for Capybara to test Jekyll static files served by Rack
Capybara.app = Rack::Builder.new do
  map '/' do
    use Rack::Lint
    run JekyllSite.new(File.join(File.dirname(__FILE__), '..', '_site'))
  end
end.to_app

Capybara.default_selector =  :css
Capybara.default_driver   =  :rack_test
Capybara.javascript_driver = :webkit

RSpec.configure do |config|
  config.include Capybara::DSL

  # Make sure the static files are generated
  `jekyll build` unless File.directory?('_site')
end

What are we doing here you ask?

  • We are transforming our Jekyll site to be a Rack application (Rack is Ruby HTTP interface).
  • We are using Rack::Builder to create our Capybara application.
  • We are telling Capybara to use :webkit driver.
  • Finally we are configuring RSpec to simply use Capybara DSL (that we use in the actual tests).

You might have noticed two more things.

We are requiring shared context. Our shared context tests the header, search box, and footer that is present on every generated page. Here is the source:

# What every single page should contain
RSpec.shared_examples_for 'Page' do
  it "has top-level menu" do
    expect(page).to have_css("#logo-col a[href~='/']")
    expect(page).to have_link("Start a project", href: '/start.html')
    expect(page).to have_link("Get tools", href: '/tools.html')
    expect(page).to have_link("Languages & databases", href: '/tech.html')
    expect(page).to have_link("Deploy and distribute", href: '/deployment.html')
    expect(page).to have_css("ul.nav li", count: 4)
  end

  it "has footer" do
    expect(page).to have_css(".footer")

    # 4 sections: About, Download, Support, Join
    expect(page).to have_css(".footer h3.widget-title", count: 4)

    # Footer links
    expect(page).to have_link("About Developer Portal", href: '/about.html')
    expect(page).to have_link("Fedora Magazine", href: 'https://fedoramagazine.org')
    expect(page).to have_link("Torrent Downloads", href: 'https://torrents.fedoraproject.org')
    expect(page).to have_link("Forums", href: 'https://fedoraforum.org/')
    expect(page).to have_link("Planet Fedora", href: 'http://fedoraplanet.org')
    expect(page).to have_link("Fedora Community", href: 'https://fedoracommunity.org/')
    expect(page).to have_css(".footer a", count: 27)

    expect(page).to have_css(".footer p.copy", text: /© [0-9]+ Red Hat, Inc. and others./)
  end
end

# Search page does not contain form#search
RSpec.shared_examples_for 'Page with search box' do
  it "has a search box next to the top-level navigation" do
    expect(page).to have_css("form#search")
    expect(page).to have_css("form#search input")
    expect(page).to have_css("form#search button")
  end
end

We are also making sure to run jekyll build to generate the site (we expect and work with standard Jekyll _site directory).

With all of this setup we can write a regular Capybara test:

require 'spec_helper'

# Array of all generated pages
site = File.join(File.dirname(__FILE__), '..', '_site', '**', '*.html')
PAGES = Dir.glob(site).map{ |p| p.gsub(/[^_]+\/_site(.*)/, '\\1') }

PAGES.each do |p|
  describe p do
    it_behaves_like 'Page'
    it_behaves_like 'Page with search box' unless p == '/search.html'

    before :each do
      visit p
    end

    it 'has only valid internal hyperlinks' do
      page.all(:css, 'a').each do |link|
        next if link.text == '' || link[:href].match(/(http|\/\/).*/)
        page.find(:xpath, link.path).click
        expect(page.status_code).to be(200), "expected link '#{link.text}' to work"
        visit p
      end
    end
  end
end

This test will iterate over all our pages, visit them, use our shared context thanks to it_behaves_like call, and finally run any further tests.

In the test example above all the relative links found on the page will be visited by Capybara (we are clicking on them with click()). If such page does not exist, we fail our RSpec test with expectation.

After all of this in place, the idea is to just run rspec spec in the project root directory:

$ rspec spec
............................................................................................................................................................................................................................................................................................F...............F.......F...................

Failures:

  1) /tools/docker/about.html has only valid internal hyperlinks
     Failure/Error: expect(page.status_code).to be(200), "expected link '#{link.text}' to work"
       expected link 'configuring Docker' to work
     # ./spec/pages_spec.rb:20:in `block (4 levels) in <top (required)>'
     # ./spec/pages_spec.rb:17:in `block (3 levels) in <top (required)>'

  2) /tools/docker/compose.html has only valid internal hyperlinks
     Failure/Error: expect(page.status_code).to be(200), "expected link '#{link.text}' to work"
       expected link 'Getting started with Docker on Fedora' to work
     # ./spec/pages_spec.rb:20:in `block (4 levels) in <top (required)>'
     # ./spec/pages_spec.rb:17:in `block (3 levels) in <top (required)>'

  3) /tools/vagrant/about.html has only valid internal hyperlinks
     Failure/Error: expect(page.status_code).to be(200), "expected link '#{link.text}' to work"
       expected link 'Vagrant with libvirt' to work
     # ./spec/pages_spec.rb:20:in `block (4 levels) in <top (required)>'
     # ./spec/pages_spec.rb:17:in `block (3 levels) in <top (required)>'

Finished in 7.8 seconds (files took 0.36521 seconds to load)
328 examples, 3 failures

Oh my, links are broken! Let’s fix them first…

On the Fedora Developer website this call is part of the deploy script:

$ ./deploy.sh
Running specs...
........................................................................................................................................................................................................................................................................................................................................

Finished in 8.17 seconds (files took 0.37417 seconds to load)
328 examples, 0 failures

Checking dependencies...
ruby-2.2.3-44.fc22.x86_64
rubygem-liquid-3.0.1-1.fc22.noarch
rubygem-actionview-4.2.0-2.fc22.noarch
Uploading site from _site/, check that the content is current
about.html                                                            100% 9713     9.5KB/s   00:00
about.md                                                              100%    0     0.0KB/s   00:00
...

Trust me, this feels so much better. Finding the issues before the site is deployed is important both for your visitors and your good night sleeps!

Work with me

I have some availability for contract work. I can be your fractional CTO, a Ruby on Rails engineer, or consultant. Write me at strzibny@strzibny.name.

RSS