Easy Internationalization for Your Rails App with BDD
My company has a web application in US format that we have modified for use by our UK affiliate. However our current need to amend it further and add some functions is leading us down the path of a total re-write. But I believe Rails has saved the day. I am going to show you how easy it is to “internationalize” with Rails. This will be the first in a series with further tests upcoming to create, read, use and delete (CRUD).
We will start by creating a Rails application. I like to use Cucumber and factory_girl for testing, so we won’t need test-unit.
$ rails new international --skip-test-unit
...
$ cd international
international $
Open up the folder that has our new app in it and edit the Gemfile. We need to add the testing Gems to it.
group :test do
gem 'cucumber-rails', '1.2.1'
gem 'rspec-rails', '2.8.1'
gem 'database<em>cleaner', '0.7.1'
gem 'factory</em>girl', '2.4.0'
end
Save the file. Now run bundler to install the new gems.
international $ bundle install
Fetching source index for http://rubygems.org/
Using rake (0.9.2.2)
Using multi<em>json (1.0.4)
Using activesupport (3.1.3)
Using builder (3.0.0)
Using i18n (0.6.0)
Using activemodel (3.1.3)
Using erubis (2.7.0)
Using rack (1.3.6)
Using rack-cache (1.1)
Using rack-mount (0.8.3)
Using rack-test (0.6.1)
Using hike (1.2.1)
Using tilt (1.3.3)
Using sprockets (2.0.3)
Using actionpack (3.1.3)
Using mime-types (1.17.2)
Using polyglot (0.3.3)
Using treetop (1.4.10)
Using mail (2.3.0)
Using actionmailer (3.1.3)
Using arel (2.2.1)
Using tzinfo (0.3.31)
Using activerecord (3.1.3)
Using activeresource (3.1.3)
Using bundler (1.0.21)
Installing nokogiri (1.5.0) with native extensions
Installing ffi (1.0.11) with native extensions
Installing childprocess (0.3.0)
Installing rubyzip (0.9.5)
Installing selenium-webdriver (2.17.0)
Installing xpath (0.1.4)
Installing capybara (1.1.2)
Using coffee-script-source (1.2.0)
Using execjs (1.2.13)
Using coffee-script (2.2.0)
Using rack-ssl (1.3.2)
Using json (1.6.5)
Using rdoc (3.12)
Using thor (0.14.6)
Using railties (3.1.3)
Using coffee-rails (3.1.1)
Installing diff-lcs (1.1.3)
Installing gherkin (2.7.3) with native extensions
Installing term-ansicolor (1.0.7)
Installing cucumber (1.1.4)
Installing cucumber-rails (1.2.1)
Installing database</em>cleaner (0.7.1)
Installing factory_girl (2.4.0)
Using jquery-rails (1.0.19)
Using rails (3.1.3)
Installing rspec-core (2.8.0)
Installing rspec-expectations (2.8.0)
Installing rspec-mocks (2.8.0)
Installing rspec (2.8.0)
Installing rspec-rails (2.8.1)
Using sass (3.1.12)
Using sass-rails (3.1.5)
Using sqlite3 (1.3.5)
Using uglifier (1.2.2)
Your bundle is complete! Use <code>bundle show [gemname]</code> to see where a bundled gem is installed.
With the new gems loaded we can install Cucumber.
international $ rails generate cucumber:install
create config/cucumber.yml
create script/cucumber
chmod script/cucumber
create features/step_definitions
create features/support
create features/support/env.rb
exist lib/tasks
create lib/tasks/cucumber.rake
gsub config/database.yml
gsub config/database.yml
force config/database.yml
In order for cucumber to work, we need to create the database, even though we have no models. The following command will do this:
international $ rake db:migrate db:test:prepare
Let’s see if it’s working. Go ahead and run Cucumber:
international $ cucumber
Using the default profile...
0 scenarios
0 steps
0m0.000s
It works.
Now we can create our first test. Create a new file in the features folder called manage_locations.feature
Feature: Manage locations
In order to manage locations
As a user
I want to create and edit my locations.
Scenario: List a location.
Given there is a location named "location 1"
When I am on the locations page
Then I should see "location 1"
Save the file and let’s run the test.
international $ cucumber
Using the default profile...
Feature: Manage locations
In order to manage locations
As a user
I want to create and edit my locations.
Scenario: List a location. # features/manage<em>locations.feature:6
Given there is a location named "location 1" # features/manage</em>locations.feature:7
Undefined step: "there is a location named "location 1"" (Cucumber::Undefined)
features/manage<em>locations.feature:7:in <code>Given there is a location named "location 1"'
When I am on the locations page # features/manage_locations.feature:8
Undefined step: "I am on the locations page" (Cucumber::Undefined)
features/manage_locations.feature:8:in</code>When I am on the locations page'
Then I should see "location 1" # features/manage</em>locations.feature:9
Undefined step: "I should see "location 1"" (Cucumber::Undefined)
features/manage_locations.feature:9:in `Then I should see "location 1"'
1 scenario (1 undefined)
3 steps (3 undefined)
0m1.745s
You can implement step definitions for undefined steps with these snippets:
Given /^there is a location named "([^"]<em>)"$/ do |arg1|
pending # express the regexp above with the code you wish you had
end
When /^I am on the locations page$/ do
pending # express the regexp above with the code you wish you had
end
Then /^I should see "([^"]</em>)"$/ do |arg1|
pending # express the regexp above with the code you wish you had
end
Just like Cucumber says, we’ll implement the steps using the template it gave us.
Create a new file in the /features/step_definitions folder called location_steps.rb Copy and paste those snippets into the file we just created.
Given /^there is a location named "([^"]<em>)"$/ do |arg1|
pending # express the regexp above with the code you wish you had
end
When /^I am on the locations page$/ do
pending # express the regexp above with the code you wish you had
end
Then /^I should see "([^"]</em>)"$/ do |arg1|
pending # express the regexp above with the code you wish you had
end
Let’s save the file and rerun the test.
international $ cucumber
Using the default profile...
Feature: Manage locations
In order to manage locations
As a user
I want to create and edit my locations.
Scenario: List a location. # features/manage<em>locations.feature:6
Given there is a location named "location 1" # features/step</em>definitions/location<em>steps.rb:1
TODO (Cucumber::Pending)
./features/step</em>definitions/location<em>steps.rb:2:in <code>/^there is a location named "([^"]*)"$/'
features/manage_locations.feature:7:in</code>Given there is a location named "location 1"'
When I am on the locations page # features/step</em>definitions/location<em>steps.rb:5
Then I should see "location 1" # features/step</em>definitions/location_steps.rb:9
1 scenario (1 pending)
3 steps (2 skipped, 1 pending)
0m1.249s
As expected it’s not passing, but there’s a bit less noise. We need to create a location model so we can have locations. Using the generator, create one and, for now, it will just have a name attribute.
international $ rails g model location name:string
invoke active<em>record
create db/migrate/20120118214355</em>create_locations.rb
create app/models/location.rb
With the model created, time to run the migration and prep the test database.
international $ rake db:migrate db:test:prepare
== CreateLocations: migrating ================================================
-- create_table(:locations)
-> 0.0015s
== CreateLocations: migrated (0.0016s) =======================================
Now we can create a location.
I like to use factory_girl. It makes it easy to create test data. (Learn more) It takes a bit of time to set up, but I think it’s worth it.
In the /features/support folder create a file called factories.rb and add the following.
require 'factory_girl'
FactoryGirl.define do
factory :location do |f|
f.name 'test location'
end
end
This will create a location with the name “test location” in the database for testing purposes. You can always pass in a name to the factory, so it doesn’t have to be “test’ location.” We’ll see an example of that shortly.
In the location_steps.rb file, modify the first step so it looks like this:
Given /^there is a location named "([^"]*)"$/ do |name|
Factory(:location, :name => name)
end
Save the file and rerun the test
international $ cucumber
Using the default profile...
Feature: Manage locations
In order to manage locations
As a user
I want to create and edit my locations.
Scenario: List a location. # features/manage<em>locations.feature:6
Given there is a location named "location 1" # features/step</em>definitions/location<em>steps.rb:1
When I am on the locations page # features/step</em>definitions/location<em>steps.rb:5
TODO (Cucumber::Pending)
./features/step</em>definitions/location<em>steps.rb:6:in <code>/^I am on the locations page$/'
features/manage_locations.feature:8:in</code>When I am on the locations page'
Then I should see "location 1" # features/step</em>definitions/location_steps.rb:9
1 scenario (1 pending)
3 steps (1 skipped, 1 pending, 1 passed)
0m1.485s
Excellent… the first step is passing.
Next, we’ll go to the locations page but we’ll need to make a route for that.
Open the routes.rb file in the config folder and add
match 'locations/' => 'locations#index', :as => :locations
That will get us to the locations index page.
We can check the route by typing in the command line:
international $ rake routes
locations /locations(.:format) locations#index
in the location_steps.rb file let’s tell is where to go by adding:
When /^I am on the locations page$/ do
visit(locations_path)
end
Save the file and rerun the test.
international $ cucumber
Using the default profile...
Feature: Manage locations
In order to manage locations
As a user
I want to create and edit my locations.
Scenario: List a location. # features/manage<em>locations.feature:6
Given there is a location named "location 1" # features/step</em>definitions/location<em>steps.rb:1
When I am on the locations page # features/step</em>definitions/location<em>steps.rb:5
uninitialized constant LocationController (ActionController::RoutingError)
./features/step</em>definitions/location<em>steps.rb:6:in <code>/^I am on the locations page$/'
features/manage_locations.feature:8:in</code>When I am on the locations page'
Then I should see "location 1" # features/step</em>definitions/location<em>steps.rb:9
Failing Scenarios:
cucumber features/manage</em>locations.feature:6 # Scenario: List a location.
1 scenario (1 failed)
3 steps (1 failed, 1 skipped, 1 passed)
0m1.501s
It tells us there is no LocationsController.
OK, create one and just worry about the index page, for now.
international $ rails g controller locations index
create app/controllers/locations<em>controller.rb
route get "locations/index"
invoke erb
create app/views/locations
create app/views/locations/index.html.erb
invoke helper
create app/helpers/locations</em>helper.rb
invoke assets
invoke coffee
create app/assets/javascripts/locations.js.coffee
invoke scss
create app/assets/stylesheets/locations.css.scss
Let’s go in the routes file (config/routes.rb) and delete the route that was created when we genereated the controller.
Remove this line.
get "locations/index"
Save the file,. Now that we have a controller, let’s rerun the test.
international $ cucumber
Using the default profile...
Feature: Manage locations
In order to manage locations
As a user
I want to create and edit my locations.
Scenario: List a location. # features/manage<em>locations.feature:6
Given there is a location named "location 1" # features/step</em>definitions/location<em>steps.rb:1
When I am on the locations page # features/step</em>definitions/location<em>steps.rb:5
Then I should see "location 1" # features/step</em>definitions/location<em>steps.rb:9
TODO (Cucumber::Pending)
./features/step</em>definitions/location_steps.rb:10:in <code>/^I should see "([^"]*)"$/'
features/manage_locations.feature:9:in</code>Then I should see "location 1"'
1 scenario (1 pending)
3 steps (1 pending, 2 passed)
0m1.858s
Perfect. We just have to display the names of the locations. In /app/views/locations folder open the index.html.erb file. We’ll add this code that will loop through the names:
<ul>
<% @locations.each do |location| %>
<li>
<%= location.name %>
</li>
<% end %>
</ul>
Let’s see what happens if we run the test now.
international $ cucumber
Using the default profile...
Feature: Manage locations
In order to manage locations
As a user
I want to create and edit my locations.
Scenario: List a location. # features/manage<em>locations.feature:6
Given there is a location named "location 1" # features/step</em>definitions/location<em>steps.rb:1
When I am on the locations page # features/step</em>definitions/location<em>steps.rb:5
You have a nil object when you didn't expect it!
You might have expected an instance of Array.
The error occurred while evaluating nil.each (ActionView::Template::Error)
......
features/manage</em>locations.feature:8:in `When I am on the locations page'
Then I should see "location 1" # features/step<em>definitions/location</em>steps.rb:9
Failing Scenarios:
cucumber features/manage_locations.feature:6 # Scenario: List a location.
1 scenario (1 failed)
3 steps (1 failed, 1 skipped, 1 passed)
0m1.599s
Nil object? We didn’t get anything from the database. Let’s fix that by opening up the locations_controller.rb file in /app/controllers Change the index method:
def index
@locations = Location.all
respond_to do |format|
format.html # index.html.erb
format.json { render json: @locations }
end
end
In the location_step.rb we need to change the following:
Then /^I should see "([^"]*)"$/ do |arg1|
pending # express the regexp above with the code you wish you had
end
to:
Then /^(?:|I )should see "([^"]*)"$/ do |text|
if page.respond_to? :should
page.should have_content(text)
else
assert page.has_content?(text)
end
end
Save the files and rerun the test.
international $ cucumber
Using the default profile...
Feature: Manage locations
In order to manage locations
As a user
I want to create and edit my locations.
Scenario: List a location. # features/manage<em>locations.feature:6
Given there is a location named "location 1" # features/step</em>definitions/location<em>steps.rb:1
When I am on the locations page # features/step</em>definitions/location<em>steps.rb:5
Then I should see "location 1" # features/step</em>definitions/location_steps.rb:9
1 scenario (1 passed)
3 steps (3 passed)
0m1.679s
Okay, all green! The first test is passing!
Who needs a break?
¿Habla español?
Let’s get into the internationalization. We need to make an i18n module. In the /config/initializers/ folder create a new file called i18n.rb
In this file we’ll add
#encoding: utf-8
I18n.default_locale = :en
LANGUAGES = [
['English', 'en'],
["Español".html_safe, 'es']
]
This sets the default language to English and creates a list of languages that we will support along with their locales.
The locale will be specified in the URL, telling us which language to use. We’ll scope this in the routes and add a root URL. Don’t forget to remove /public/index.html!
Open the routes.rb file and add these lines:
scope '(:locale)' do
match 'locations/' => 'locations#index', :as => :locations
root :to => 'locations#index'
end
We have nested our routes inside the scope of :locale, and since it’s in parentheses, it’s optional.
http://localhost:3000/es will use the default locale, English. http://localhost:3000/en and http://localhost:3000/es will use the same controller and method, but will use the specified locale in the URL
Now we need to set the locale based on the param in the URL, if it’s provided.
We’ll do this in the Application Controller with a before filter.Open the application_colbtroller.rb file in the /app/controllers folder.
Add this line:
class ApplicationController < ActionController::Base
before_filter :set</em>i18n<em>locale_from_params
protect_from_forgery
protected
def set_i18n_locale_from_params
if params[:locale]
if I18n.available_locales.include?(params[:locale].to_sym)
I18n.locale = params[:locale]
else
flash.now[:notice] = "#{params[:locale]} translation not available"
logger.error
flash.now[:notice]
end
end
end
def default_url_options
{ locale: I18n.locale }
end
end
So here it is checking for a locale parameter. If there is one, it checks to see if that locale is in our list of languages back in the /config/initializers/i18n.rb file. If it is in the list, we set the locale to the param. If it is not in the list, we will show a message saying that locale is not available.
The default_url_options sets the default locale when one is not provided. Since we’ve made a lot of changes at this point, let’s save all of the files and rerun the test.
international $ cucumber
Using the default profile...
Feature: Manage locations
In order to manage locations
As a user
I want to create and edit my locations.
Scenario: List a location. # features/manage<em>locations.feature:6
Given there is a location named "location 1" # features/step</em>definitions/location<em>steps.rb:1
When I am on the locations page # features/step</em>definitions/location<em>steps.rb:5
Then I should see "location 1" # features/step</em>definitions/location_steps.rb:9
1 scenario (1 passed)
3 steps (3 passed)
0m1.583s
Still all green. Whew.
Let’s get this i18n cooking.
On our locations index page, add a “Locations” heading
Open the managing_locations.feature file and add
Scenario: List a location.
Given there is a location named "location 1"
When I am on the locations page
Then I should see "Locations"
And I should see "location 1"
Save the files and rerun the test.
international $ cucumber
Using the default profile...
Feature: Manage locations
In order to manage locations
As a user
I want to create and edit my locations.
Scenario: List a location. # features/manage<em>locations.feature:6
Given there is a location named "location 1" # features/step</em>definitions/location<em>steps.rb:1
When I am on the locations page # features/step</em>definitions/location<em>steps.rb:5
Then I should see "Locations" # features/step</em>definitions/location<em>steps.rb:9
expected there to be content "Locations" in "Internationalnnnn location 1n n" (RSpec::Expectations::ExpectationNotMetError)
./features/step</em>definitions/location<em>steps.rb:11:in <code>/^(?:|I )should see "([^"]*)"$/'
features/manage_locations.feature:9:in</code>Then I should see "Locations"'
And I should see "location 1" # features/step</em>definitions/location<em>steps.rb:9
Failing Scenarios:
cucumber features/manage</em>locations.feature:6 # Scenario: List a location.
1 scenario (1 failed)
4 steps (1 failed, 1 skipped, 2 passed)
0m1.593s
Fails as expected.
Open the index.html.ern file in /app/views/locations and add
<h1>Locations</h1>
Save the file and rerun the test.
international $ cucumber
Using the default profile...
Feature: Manage locations
In order to manage locations
As a user
I want to create and edit my locations.
Scenario: List a location. # features/manage<em>locations.feature:6
Given there is a location named "location 1" # features/step</em>definitions/location<em>steps.rb:1
When I am on the locations page # features/step</em>definitions/location<em>steps.rb:5
Then I should see "Locations" # features/step</em>definitions/location<em>steps.rb:9
And I should see "location 1" # features/step</em>definitions/location_steps.rb:9
1 scenario (1 passed)
4 steps (4 passed)
0m1.604s
We’re back to green.
Now copy the same test but instead of “Location” in the heading we should see “Locaciones”
Change your managing_locations.feature to:
Feature: Manage locations
In order to manage locations
As a user
I want to create and edit my locations.
Scenario: List a location.
Given there is a location named "location 1"
When I am on the locations page
Then I should see "Locations"
And I should see "location 1"
Scenario: List a location.
Given there is a location named "location 1"
When I am on the locations page
Then I should see "Locaciones"
And I should see "location 1"
Save the file and rerun the test
international $ cucumber
Using the default profile...
Feature: Manage locations
In order to manage locations
As a user
I want to create and edit my locations.
Scenario: List a location. # features/manage<em>locations.feature:6
Given there is a location named "location 1" # features/step</em>definitions/location<em>steps.rb:1
When I am on the locations page # features/step</em>definitions/location<em>steps.rb:5
Then I should see "Locations" # features/step</em>definitions/location<em>steps.rb:9
And I should see "location 1" # features/step</em>definitions/location<em>steps.rb:9
Scenario: List a location. # features/manage</em>locations.feature:12
Given there is a location named "location 1" # features/step<em>definitions/location</em>steps.rb:1
When I am on the locations page # features/step<em>definitions/location</em>steps.rb:5
Then I should see "Locaciones" # features/step<em>definitions/location</em>steps.rb:9
expected there to be content "Locaciones" in "InternationalnnLocationsnnn location 1n n" (RSpec::Expectations::ExpectationNotMetError)
./features/step<em>definitions/location</em>steps.rb:11:in <code>/^(?:|I )should see "([^"]*)"$/'
features/manage_locations.feature:15:in</code>Then I should see "Locaciones"'
And I should see "location 1" # features/step<em>definitions/location</em>steps.rb:9
Failing Scenarios:
cucumber features/manage_locations.feature:12 # Scenario: List a location.
2 scenarios (1 failed, 1 passed)
8 steps (1 failed, 1 skipped, 6 passed)
0m1.643s
It failed where we expected.
Here’s where all the coding we did earlier pays off. It’s time to translate. For the words we need to translate, we will call I18n.translate which has an alias I18n.t. There is also a helper provided named t.
The parameter to the translate function is a unique dot-qualified name. We can choose any name you like, but if we use the t helper function provided, names that start with a dot will first be expanded using the name of the template. Let’s do that.
In the index.html.erb, change as follows
<h1>Locations</h1>
to
<h1><%= t('.title_html') %></h1>
Save the file.
We need to create a en.yml file in /config/locales folder.
en:
locations:
index:
title_html: "Locations
We also need to create a es.yml file in /config/locales folder
es:
locations:
index:
title_html: "Locaciones"
These files hold the translations. Also notice the structure of the YAML resembles the file structure? (/locations/index). This will be handy down the road.
Save the files and rerun the test…again.
international $ cucumber
Using the default profile...
Feature: Manage locations
In order to manage locations
As a user
I want to create and edit my locations.
Scenario: List a location. # features/manage<em>locations.feature:6
Given there is a location named "location 1" # features/step</em>definitions/location<em>steps.rb:1
When I am on the locations page # features/step</em>definitions/location<em>steps.rb:5
Then I should see "Locations" # features/step</em>definitions/location<em>steps.rb:9
And I should see "location 1" # features/step</em>definitions/location<em>steps.rb:9
Scenario: List a location. # features/manage</em>locations.feature:12
Given there is a location named "location 1" # features/step<em>definitions/location</em>steps.rb:1
When I am on the locations page # features/step<em>definitions/location</em>steps.rb:5
Then I should see "Locaciones" # features/step<em>definitions/location</em>steps.rb:9
expected there to be content "Locaciones" in "InternationalnnLocationsnnn location 1n n" (RSpec::Expectations::ExpectationNotMetError)
./features/step<em>definitions/location</em>steps.rb:11:in <code>/^(?:|I )should see "([^"]*)"$/'
features/manage_locations.feature:15:in</code>Then I should see "Locaciones"'
And I should see "location 1" # features/step<em>definitions/location</em>steps.rb:9
Failing Scenarios:
cucumber features/manage_locations.feature:12 # Scenario: List a location.
2 scenarios (1 failed, 1 passed)
8 steps (1 failed, 1 skipped, 6 passed)
0m1.643s
WHAT? Red?
It seems that in the second test we never said to use es as the locale so it is defaulting to en.
Let’s fix that as follows:
Scenario: List a location.
Given there is a location named "location 1"
And I am on the es site
When I am on the locations page
Then I should see "Locaciones"
And I should see "location 1"
Save the files and rerun the test.
international $ cucumber
Using the default profile...
Feature: Manage locations
In order to manage locations
As a user
I want to create and edit my locations.
Scenario: List a location. # features/manage<em>locations.feature:6
Given there is a location named "location 1" # features/step</em>definitions/location<em>steps.rb:1
When I am on the locations page # features/step</em>definitions/location<em>steps.rb:5
Then I should see "Locations" # features/step</em>definitions/location<em>steps.rb:9
And I should see "location 1" # features/step</em>definitions/location<em>steps.rb:9
Scenario: List a location. # features/manage</em>locations.feature:12
Given there is a location named "location 1" # features/step<em>definitions/location</em>steps.rb:1
And I am on the es site # features/manage<em>locations.feature:14
Undefined step: "I am on the es site" (Cucumber::Undefined)
features/manage</em>locations.feature:14:in `And I am on the es site'
When I am on the locations page # features/step<em>definitions/location</em>steps.rb:5
Then I should see "Locaciones" # features/step<em>definitions/location</em>steps.rb:9
And I should see "location 1" # features/step<em>definitions/location</em>steps.rb:9
2 scenarios (1 undefined, 1 passed)
9 steps (3 skipped, 1 undefined, 5 passed)
0m1.723s
You can implement step definitions for undefined steps with these snippets:
Given /^I am on the es site$/ do
pending # express the regexp above with the code you wish you had
end
Alright, then! It tells us what to do.
Open the location_steps.rb and add this to it.
Given /^I am on the (.+) site$/ do |language|
I18n.locale = language
end
Save the file and rerun the test.
international $ cucumber
Using the default profile...
Feature: Manage locations
In order to manage locations
As a user
I want to create and edit my locations.
Scenario: List a location. # features/manage<em>locations.feature:6
Given there is a location named "location 1" # features/step</em>definitions/location<em>steps.rb:1
When I am on the locations page # features/step</em>definitions/location<em>steps.rb:5
Then I should see "Locations" # features/step</em>definitions/location<em>steps.rb:9
And I should see "location 1" # features/step</em>definitions/location<em>steps.rb:9
Scenario: List a location. # features/manage</em>locations.feature:12
Given there is a location named "location 1" # features/step<em>definitions/location</em>steps.rb:1
And I am on the es site # features/step<em>definitions/location</em>steps.rb:17
When I am on the locations page # features/step<em>definitions/location</em>steps.rb:5
Then I should see "Locaciones" # features/step<em>definitions/location</em>steps.rb:9
And I should see "location 1" # features/step<em>definitions/location</em>steps.rb:9
2 scenarios (2 passed)
9 steps (9 passed)
0m1.636s
SCORE! You now have a very simple multilingual site. I hope you found useful for using BDD to drive internationalization of your Rails app.