How to test a Ruby on Rails application with Ruby JMeter

In this post we highlight some of the common areas which need to be catered for when load testing a typical Rails application. This is a start to things like keeping track of sessions, CSRF token handling and submitting forms.

For more information about the DSL refer to our github repository.

Sessions

Most applications need to keep track of certain state of a particular user. This could be the contents of a shopping basket or the user id of the currently logged in user. Without the idea of sessions, the user would have to identify, and probably authenticate, on every request. Rails will create a new session automatically if a new user accesses the application. It will load an existing session if the user has already used the application.

Typical rails apps keeps track of sessions via cookies. You can keep track of cookies on a per-user basis with the sessions ruby-jmeter method.

Cross Site Request Forgery (CSRF)

First, as is required by the W3C, rails apps will use GET and POST appropriately. Secondly, a security token in non-GET requests will protect the app from CSRF. A typical rails app will embed a csrf-token into response bodies of requests. Look for code of a similar pattern in the head or body of the response:

<meta content="authenticity_token" name="csrf-param" />  
<meta content="m4vGNR2Oj2ZhYDg8AgTq1+0EgZi3NQKI89rxxsGUIU4=" name="csrf-token" />  

You can keep track of these tokens on a per-user basis with the extract ruby-jmeter method:

extract name:  'authenticity_token',  
        regex: 'meta content="(.+?)" name="csrf-token"'

This will automatically extract the CSRF token whenever it occurs and keep track of it via a JMeter variable ${authenticity_token}

Rails Forms

The rails framework includes form helpers (form_tag) which will create a form tag which, when submitted, will POST to the current page. For instance, assuming the current page is /home/index, the generated HTML will look like this (some line breaks added for readability):

<form accept-charset="UTF-8" action="/home/index" method="post">  
  <div style="margin:0;padding:0">
    <input type="text" name="<span class=" />utf8" type="hidden" value="✓" />
    <input name="authenticity_token" type="hidden"
           value="f755bb0ed134b76c432144748a6d4b7a7ddf2b71" />
    <input id="q" name="q" type="text" />
  </div>
  Form contents
</form>  

You’ll notice that the HTML contains something extra: a div element with two hidden input elements inside. This div is important, because the form cannot be successfully submitted without it. The first input element with name utf8 enforces browsers to properly respect your form’s character encoding and is generated for all forms whether their actions are “GET” or “POST”.

The second input element with name authenticity_token is a security feature of Rails called cross-site request forgery protection, and form helpers generate it for every non-GET form (provided that this security feature is enabled).

You can submit forms with the submit ruby-jmeter method passing in parameters to the form with fill_in:

submit '/store/register/${cart_id}', {  
  fill_in: {
    'utf8'                            => '✓',
    'authenticity_token'              => '${authenticity_token}',
    'q'                               => 'Some Dynamic Query'
  }
}

This will submit the form via a HTTP POST with the correct parameters for character encoding, csrf-token and any other form field parameters.

A Complete Example

Let’s look at an end to end example for a trivial rails app. In this example, we create a test plan which has HTTP request defaultspointing to the domain my.site.com

We are using the cache method to keep track of a per user simulated browser cache with clear_each_iteration set to true to simulate an empty browser cache at the start of each iteration.

We are using the cookies method to keep track of per user cookie sessions handled by rails.

We have a threads group with 50 users and a ramp up time of 60 seconds and a test duration of 120 seconds.

We are using the random_timer method to space out user requests on a varying 1 – 3 second interval (specified in milliseconds).

We are using the extract method to automatically parse out any csrf-token found in the response bodies to a request. Notice we don't have to scope this to individual requests, we keep it at the top of the thread group so it applies to all request response bodies.

Our first transaction is a simple visit via a HTTP GET to the home page. The transaction method provides a simple way to label a group of samples. We are also using the assert method to check that the home page response body contains some expected text.

Our second transaction uses submit to HTTP POST a form to our login page, with fill_in parameters. The parameters submitted are the character encoding, ${authenticity_token} which was automatically populated by the previous extract method, a user name and password as well as the commit parameter associated with this form.

Following is the completed end to end example described above.

require 'ruby-jmeter'

test do

  defaults domain: 'my.site.com'

  cache clear_each_iteration: true

  cookies

  threads 50, {ramp_time: 60, duration: 120, continue_forever: true} do

    random_timer 1000, 3000

    extract name: 'authenticity_token',
            regex: 'meta content="(.+?)" name="csrf-token"'

    transaction '01_my_site_visit_home_page' do
      visit '/' do
        assert contains: 'Welcome to my site'
      end
    end

    transaction '02_my_site_login' do
      submit '/login', {
        fill_in: {
          'utf8'                            => '%E2%9C%93',
          'authenticity_token'              => '${authenticity_token}',
          'user[name]'                      => 'Timmy',
          'user[password]'                  => 'Tables'
          'commit'                          => 'Sign In',
        }
      }
    end

  end

end  

Running the Test

If you are a paid user of Flood IO you can run tests from the ruby-jmeter DSL automatically using the .flood method. This method requires your Flood IO API key. We recommend you set an environment variable similar to the following:

test do  
  threads 50 do
    ...
  end
end.flood ENV['API_TOKEN'], {  
  region: 'us-west-1',
  name: 'Demo'
}