Refactoring towards testable JavaScript, part 1

This article is one in a 3-part series. The full series is:

As someone who does a lot of pure-JavaScript projects, I’ve settled into a pattern for organizing my code and its tests in a way I’m comfortable with. At Songkick, despite being obsessed with testing our Ruby code we’ve traditionally done a patchy job of testing our JavaScript. Some recent refactoring is giving us a chance to review our practises and I wanted to use this chance to see how easily we can test JavaScript within applications. I’m pleased to report that the tools available today make this an absolute breeze, and it’s quite easy to do a thorough job of putting together a sustainable testing strategy.

This is the first in a series of articles walking through a demo I presented internally at Songkick, showing various ways we can test our JavaScript and how we can refactor to keep these tests running quickly. I’ll be going through changes to a project and linking to Git commits as appropriate. We’ll cover a range of testing styles using tools written be me and others, all of which make JavaScript testing easy.

Let’s start off with version 0: we decide we want a new software product, and promptly decide to write a spec for it. We decide there should be a sign-up form, and there should be rules about what data is acceptable.

Feature: Signing up
  In order to show everyone what a badass I am
  As a developer
  I want to make my users sit through some JavaScript validation
  
  Background:
    Given I visit the sign-up form
  
  Scenario: Entering the wrong name
    When I enter an invalid name
    Then I should see "Your name is invalid"
  
  Scenario: Entering the wrong email address
    When I enter an invalid email address
    Then I should see "Your email is invalid"
  
  Scenario: Having an invalid argument
    When I use an invalid argument
    Then I should see "Your argument is invalid"
  
  Scenario: Entering valid data
    When I enter valid sign-up data
    Then I should see "You are a wizard, Harry!"

Great! Some detailed full-stack tests are a good starting point for for making sure we build the right thing. Full of enthusiasm, we crack on and write some step definitions and an application that passes the tests. Here’s our little Sinatra application:

require 'sinatra'

get '/signup' do
  erb :signup
end

post '/accounts/create' do
  if params[:username] == 'Wizard'
    'Your argument is invalid'
  elsif params[:username] != 'Harry'
    'Your name is invalid'
  elsif params[:email] !~ /@/
    'Your email is invalid'
  else
    'You are a wizard, Harry!'
  end
end

And the view containing the sign-up form:

<form method="post" action="/accounts/create">
  <label for="username">Username</label>
  <input type="text" id="username" name="username">
  
  <label for="email">Email</label>
  <input type="text" id="email" name="email">
  
  <input type="submit" value="Sign up">
</form>

We run cucumber features/ and all is good:

$ cucumber features/
Feature: Signing up
  In order to show everyone what a badass I am
  As a developer
  I want to make my users sit through some JavaScript validation

  Background:                      # features/signup.feature:6
    Given I visit the sign-up form # features/step_definitions/app_steps.rb:1

  Scenario: Entering the wrong name          # features/signup.feature:9
    When I enter an invalid name             # features/step_definitions/app_steps.rb:5
    Then I should see "Your name is invalid" # features/step_definitions/app_steps.rb:27

  Scenario: Entering the wrong email address  # features/signup.feature:13
    When I enter an invalid email address     # features/step_definitions/app_steps.rb:10
    Then I should see "Your email is invalid" # features/step_definitions/app_steps.rb:27

  Scenario: Having an invalid argument           # features/signup.feature:17
    When I use an invalid argument               # features/step_definitions/app_steps.rb:16
    Then I should see "Your argument is invalid" # features/step_definitions/app_steps.rb:27

  Scenario: Entering valid data                  # features/signup.feature:21
    When I enter valid sign-up data              # features/step_definitions/app_steps.rb:21
    Then I should see "You are a wizard, Harry!" # features/step_definitions/app_steps.rb:27

4 scenarios (4 passed)
12 steps (12 passed)
0m0.582s

These tests are fast because we’re currently using Rack::Test, which talks directly to our Rack application in Ruby without needing to boot a server or go over the wire. That is specified by this in our features/support/env.rb:

Capybara.current_driver = :rack_test
Capybara.app = Sinatra::Application

So next we decide that we want to put the validation on the client side, rather than the server (not a good idea in general, but I needed an example everyone would be familiar with). We hollow out our application and move the validation into a script tag after the form:

post '/accounts/create' do
  'You are a wizard, Harry!'
end
<form method="post" action="/accounts/create">
  <label for="username">Username</label>
  <input type="text" id="username" name="username">
  
  <label for="email">Email</label>
  <input type="text" id="email" name="email">
  
  <div class="error"></div>
  <input type="submit" value="Sign up">
</form>

<script type="text/javascript">
  $('form').bind('submit', function() {
    if ($('#username').val() === 'Wizad') {
      $('.error').html('Your argument is invalid');
      return false;
    }
    else if ($('#username').val() !== 'Harry') {
      $('.error').html('Your name is invalid');
      return false;
    }
    else if (!/@/.test($('#email').val())) {
      $('.error').html('Your email is invalid');
      return false;
    }
  });
</script>

Now Rack::Test won’t run JavaScript, but not to worry – Capybara just lets us set Capybara.current_driver = :selenium and suddenly our tests are all executed in Firefox. But there’s one problem:

$ cucumber features/
# ...
4 scenarios (4 passed)
12 steps (12 passed)
0m9.891s

Our tests have gone from taking 0.5 seconds to nearly 10 seconds: that’s 20 times slower. Multiplied over a whole application test suite you’ll soon be wanting to throw all your tests away. We need to move more of this logic into unit tests if we want a sustainable testing strategy.

The first step is to get that JavaScript out of the view and into an external file that we can share between pages, and then just instantiate a copy of our new class where we need it.

function FormValidator(form) {
  var username = form.find('#username'),
      email    = form.find('#email'),
      error    = form.find('.error');
  
  form.bind('submit', function() {
    if (username.val() === 'Wizard') {
      error.html('Your argument is invalid');
      return false;
    }
    else if (username.val() !== 'Harry') {
      error.html('Your name is invalid');
      return false;
    }
    else if (!/@/.test(email.val())) {
      error.html('Your email is invalid');
      return false;
    }
  });
};
<form method="post" action="/accounts/create">
  <!-- ... -->
</form>

<script type="text/javascript">
  new FormValidator($('form'));
</script>

We can then test this class in isolation by creating a test page using the JS.Test framework (full spec page source on GitHub). This spec replicates what our Cucumber tests do, except that instead of loading the whole sign-up page every time, they just add a form to the page, add the validator to it then run one of the validation examples.

FORM_HTML = '\
    <form method="post" action="/accounts/create">\
      <label for="username">Username</label>\
      <input type="text" id="username" name="username">\
      \
      <label for="email">Email</label>\
      <input type="text" id="email" name="email">\
      \
      <div class="error"></div>\
      <input type="submit" value="Sign up">\
    </form>'

JS.require('JS.Test', function() {
  
  JS.Test.describe("FormValidator", function() { with(this) {
    before(function() {
      $("#fixture").html(FORM_HTML)
      new FormValidator($("form"))
      
      this.submit = $("form input[type=submit]")
      this.error  = $("form .error")
    })
    
    describe("with an invalid name", function() { with(this) {
      before(function() { with(this) {
        $("#username").val("Hagrid")
        submit.click()
      }})
      
      it("displays an error message", function() { with(this) {
        assertEqual( "Your name is invalid", error.html() )
      }})
    }})
    
    // ...
  }})
  
  JS.Test.autorun()
})

We open our test page spec/browser.html up in a web browser and JS.Test confirms that all our JavaScript logic works.

This is a great place to stop for now: we’ve turned what were some full-stack tests that required booting our entire application into some unit tests that we can run quickly. In the next article we’ll get into how we can refactor this further to decouple our business logic from the DOM and get test we can run from the command line.