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!
Get Test Driving Rails while it's in prerelease.