In the previous part of this series we learned the basics of creating a Ruby gem. We created an initial directory structure, defined a gemspec, installed dependencies, and started to write the actual code.
Today, we are going to continue creating the Ruby gem and, specifically, we will take care of the testing suite essential for software internationalization. You will learn how to setup RSpec, create a Rails dummy application, define and test generator tasks, and how to test Rake tasks.
By integrating the localization process into the testing phase, you ensure that your gem not only works seamlessly in different environments but is also ready to support multiple languages from the start, boosting global accessibility. We will also integrate your code with Travis CI and Codecov services. Sound good? Then let’s get started!
First part of the series: lokalise.com/blog/create-a-ruby-gem-basics
Third part of the series: lokalise.com/blog/how-to-create-a-ruby-gem-publishing
Setting up the testing suite
Adding development dependencies
So, first of all let’s add some more development dependencies into the gemspec file, like so:
Gem::Specification.new do |spec| spec.add_development_dependency 'codecov', '~> 0.1' spec.add_development_dependency 'dotenv', '~> 2.5' spec.add_development_dependency 'rails', '~> 6.0.3' spec.add_development_dependency 'rake', '~> 13.0' spec.add_development_dependency 'rspec', '~> 3.6' spec.add_development_dependency 'rspec-rails', '~> 4.0' # rubocop dependencies... spec.add_development_dependency 'simplecov', '~> 0.16' spec.add_development_dependency 'vcr', '~> 6.0' end
- Codecov is an integration for the Codecov.io service which displays test coverage results in a fancy way. We will return to it later.
- Dotenv allows us to set environment variables (
ENV
) using the values inside a separate file. In our case these variables will contain the Lokalise API token and project ID. - We will employ Rails to run a dummy application.
- Rake can be used to run certain tasks.
- RSpec is our TDD framework. Of course, you can stick to Minitest, but I really love RSpec which seems more expressive to me.
- Simplecov will allow us to measure code coverage for the testing suite.
- VCR is a tool to record interactions with third-party services within special files (called cassettes) and replay them during subsequent test runs. This allows us to use pre-recorded fixture data instead of sending real requests every time.
Don’t forget to run:
bundle i
before proceeding to the next section.
Creating a Rails dummy application
We will require a special “dummy” Rails app that will be used to test various features of our gem. This dummy will reside inside the spec/
folder. It should load only the bare minimum as we will not need features like ActionCable, ActiveJob, Spring, etc. Actually, we won’t even require ActiveRecord. Here are the commands to generate the app:
mkdir spec cd spec rails new dummy --skip-spring --skip-listen --skip-bootsnap --skip-action-text --skip-active-storage --skip-action-cable --skip-action-mailer --skip-action-mailbox --skip-test --skip-system-test --skip-active-job --skip-active-record --skip-javascript
Once the above command succeeds, open the spec/dummy/config/application.rb
file and remove the following line:
config.load_defaults 6.0
We don’t need it because later I would like to run the testing suite using both Rails 5 and 6.
Remove the Gemfile
and Gemfile.lock
from the spec/dummy
folder because we will employ the Gemfile
which resides in the project root. Open the spec/dummy/config/boot.rb
file and replace its contents with the following:
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../Gemfile', __dir__) require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) $LOAD_PATH.unshift File.expand_path('../../../lib', __dir__)
This way we are pointing the Rails app toward the proper Gemfile
.
Let’s also add a tzinfo-data
gem that should be loaded for Windows and JRuby users. Add the following to the Gemfile
:
# ... group :test do gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] end
Setting up RSpec
Next, create a newspec/spec_helper.rb
file with the following contents:
require 'dotenv/load' # <============= 1 require 'simplecov' SimpleCov.start 'rails' do # <============= 2 add_filter 'spec/' add_filter '.github/' add_filter 'lib/generators/templates/' add_filter 'lib/lokalise_rails/version' end if ENV['CI'] == 'true' # <============= 3 require 'codecov' SimpleCov.formatter = SimpleCov::Formatter::Codecov end Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].sort.each { |f| require f } # <============= 4 ENV['RAILS_ENV'] = 'test' # <============= 5 require_relative '../spec/dummy/config/environment' # <============= 6 ENV['RAILS_ROOT'] ||= "#{File.dirname(__FILE__)}../../../spec/dummy" # <============= 7
- Load ENV variables using Dotenv. The contents for these variables will be hosted inside the
.env
file (we’ll create it a bit later). - Start to measure code coverage for our testing suite. I’ve also provided some folders to ignore.
- If the testing suite runs on a continuous integration service (like Travis CI), the coverage data should then be sent to Codecov.io.
- Load support files from the
spec/support
directory. We’ll also create this directory later. - We are loading the Rails dummy app using “test” environment.
- Load the
environment.rb
file to boot the dummy app. - Set the Rails root.
Now let’s also create a .rspec
file inside the project root (outside of the spec/
folder). This file contains the RSpec options:
--color --require spec_helper --order rand --format doc
- Test results should be printed in color.
- The
spec_helper.rb
file loads automatically. - The order of the tests is random.
- The output formatter is
doc
.
Setting ENV variables
The next step is to provide values for the ENV variables. These variables, as you already know, are set by the Dotenv gem. So, create an .env
file inside the project root:
LOKALISE_API_TOKEN=123abc LOKALISE_PROJECT_ID=456.def
Replace these sample values with the proper ones.
Make sure to add this file to .gitignore
. We have already done so in the previous part, but I’ll provide the .gitignore
contents again just in case:
*.gem coverage/* Gemfile.lock *~ .bundle .rvmrc log/* measurement/* pkg/* .DS_Store .env spec/dummy/tmp/* spec/dummy/log/*.log
Remember that the .env
file must not be tracked by Git because otherwise your API token will be exposed!
Finally, create the .env.sample
file which will provide sample contents:
LOKALISE_API_TOKEN=123abc LOKALISE_PROJECT_ID=456.def
Developers will then copy-paste this file as .env
on their local machines and replace the sample contents with the real values.
Adding Rake tasks
Let’s also add RSpec-related Rake tasks to the Rakefile
. Here’s the new version of this file:
require 'rake' begin require 'bundler/setup' Bundler::GemHelper.install_tasks rescue LoadError puts 'although not required, bundler is recommended for running the tests' end task default: :spec require 'rspec/core/rake_task' RSpec::Core::RakeTask.new(:spec) require 'rubocop/rake_task' RuboCop::RakeTask.new do |task| task.requires << 'rubocop-performance' task.requires << 'rubocop-rspec' end
Configuration options: Tests
Testing config method
Okay, so at this point we are ready to write some tests! In the previous part we provided some config options for our gem (api_token
, locales_path
, and others). Therefore, let’s make sure that it is possible to adjust these options. Create a new spec/lib/lokalise_rails_spec.rb
file:
describe LokaliseRails do it 'is possible to provide config options' do described_class.config do |c| expect(c).to eq(described_class) end end end
This test makes sure that when the config
method is run, the LokaliseRails
module is passed properly. This allows us to provide config in the following way:
LokaliseRails.config do |c| c.api_token = '123' end
Now change directory to the root of your project and run:
rspec .
This command will run all the tests inside the spec/
directory.
Testing mandatory options
Next, I would like to add a couple of tests for the mandatory options, namely the project_id
and api_token
. Specifically, we need to make sure that these options can be provided using the corresponding writers. However, I would like these tests to be isolated — in other words, they should not change the real config which will be loaded by the test dummy Rails app later. Therefore, let’s take advantage of a class double and create a fake class:
describe LokaliseRails do # ... describe 'parameters' do let(:fake_class) { class_double('LokaliseRails') } end end
Now provide the test case, like so:
describe LokaliseRails do # ... describe 'parameters' do let(:fake_class) { class_double('LokaliseRails') } it 'is possible to set project_id' do expect(fake_class).to receive(:project_id=).with('123.abc') fake_class.project_id = '123.abc' end end end
So, we are expecting the fake class to receive a writer method with the proper argument. Then we are actually calling this method. This test will pass if the project_id=
writer method was actually provided for the LokaliseRails
module.
To avoid false positives, let’s start by making this test fail (the “red” phase, as some devs would say). Open the lib/lokalise_rails.rb
file and remove the project_id
accessor:
module LokaliseRails class << self attr_accessor :api_token # remove project_id # ... other code... end end
Now run rspec .
and you’ll see the following output:
Randomized with seed 7575 LokaliseRails is possible to provide config options parameters is possible to set project_id (FAILED - 1) Failures: 1) LokaliseRails parameters is possible to set project_id Failure/Error: expect(fake_class).to receive(:project_id=).with('123.abc') the LokaliseRails class does not implement the class method: project_id= # ./spec/lib/lokalise_rails_spec.rb:12:in `block (3 levels) in <top (required)>' Finished in 0.058 seconds (files took 6.06 seconds to load) 2 examples, 1 failure Failed examples: rspec ./spec/lib/lokalise_rails_spec.rb:11 # LokaliseRails parameters is possible to set project_id
Great! This means our test actually does its job. Now add the project_id
to the list of accessors to make the test pass (“green” phase):
module LokaliseRails class << self attr_accessor :api_token, :project_id end end
Run rspec .
again. Here’s the result:
Randomized with seed 3205 LokaliseRails is possible to provide config options parameters is possible to set project_id Finished in 0.055 seconds (files took 6.07 seconds to load) 2 examples, 0 failures Randomized with seed 3205 Coverage report generated for RSpec to f:/rails/lokalise/lokalise_rails/coverage. 12 / 18 LOC (66.67%) covered.
Note that we also can see the test coverage percentage. To learn more about the test coverage, open the coverage/index.html
file in your favorite browser. This provides detailed information for each monitored file line by line.
Now, using the same approach we can add the test for the api_token=
method:
describe LokaliseRails do # ... describe 'parameters' do let(:fake_class) { class_double('LokaliseRails') } # ... it 'is possible to set api_token' do expect(fake_class).to receive(:api_token=).with('abc') fake_class.api_token = 'abc' end end end
While we could also test the reader methods, it is not really necessary: they will be tested implicitly in other specs. Therefore, let’s take care of other methods.
Testing other options
Use the below approach to test other options:
describe LokaliseRails do describe 'parameters' do # ... it 'is possible to set file_ext_regexp' do expect(fake_class).to receive(:file_ext_regexp=).with(Regexp.new('.*')) fake_class.file_ext_regexp = Regexp.new('.*') end it 'is possible to set import_opts' do expect(fake_class).to receive(:import_opts=).with(duck_type(:each)) fake_class.import_opts = { format: 'json', indentation: '8sp' } end it 'is possible to set export_opts' do expect(fake_class).to receive(:export_opts=).with(duck_type(:each)) fake_class.export_opts = { convert_placeholders: true, detect_icu_plurals: true } end it 'is possible to set import_safe_mode' do expect(fake_class).to receive(:import_safe_mode=).with(true) fake_class.import_safe_mode = true end it 'is possible to override locales_path' do expect(fake_class).to receive(:locales_path=).with('/demo/path') fake_class.locales_path = '/demo/path' end it 'is possible to set skip_file_export' do cond = ->(f) { f.nil? } expect(fake_class).to receive(:skip_file_export=).with(cond) fake_class.skip_file_export = cond end end end
Nice!
Installation task (Rails generator)
So, we have created basic unit tests for the gem options. To adjust these options, the user should create a config/lokalise_rails.rb
with the following content:
LokaliseRails.config do |c| c.api_token = '123' c.project_id = '345.abc' # ... other options end
While we may instruct the user to create this file manually, it’s not very convenient. What I would like to do is introduce a command like rails g lokalise_rails:install
that creates the configuration file containing the list of all supported options and usage instructions. To achieve this, we need to define a special generator to run the installation task.
Creating the generator
Our generator will live under the lib
folder, therefore create the following path: lib/generators/lokalise_rails/install_generator.rb
. This new file should contain the following code:
require 'rails/generators' module LokaliseRails module Generators class InstallGenerator < Rails::Generators::Base source_root File.expand_path('../templates', __dir__) desc 'Creates a LokaliseRails config file.' def copy_config template 'lokalise_rails_config.rb', "#{Rails.root}/config/lokalise_rails.rb" end end end end
This code will simply take the template lokalise_rails_config.rb
from the ..templates/
directory and copy-paste it to the config/
folder of the Rails app. The target file will be named lokalise_rails.rb
. You can find additional explanations for these methods in the official documentation.
Next we’ll need a template to copy, thus create a new path: lib/generators/templates/lokalise_rails_config.rb
. This is the file that will be copied in the user’s application:
require 'lokalise_rails' LokaliseRails.config do |c| # These are mandatory options that you must set before running rake tasks: # c.api_token = ENV['LOKALISE_API_TOKEN'] # c.project_id = ENV['LOKALISE_PROJECT_ID'] # Provide a custom path to the directory with your translation files: # c.locales_path = "#{Rails.root}/config/locales" # Import options have the following defaults: # c.import_opts = { # format: 'yaml', # placeholder_format: :icu, # yaml_include_root: true, # original_filenames: true, # directory_prefix: '', # indentation: '2sp' # } # Safe mode for imports is disabled by default: # c.import_safe_mode = false # Additional export options (only filename, contents, and lang_iso params are provided by default) # c.export_opts = {} # Provide additional file exclusion criteria for exports (by default, any file with the proper extension will be exported) # c.skip_file_export = ->(file) { file.split[1].to_s.include?('fr') } # Regular expression to use when choosing the files to extract from the downloaded archive and upload to Lokalise # c.file_ext_regexp = /\.ya?ml\z/i end
We provide explanations for each option, which makes the lives of our users a bit easier. Now let’s add some tests for our new installation task.
Testing the installation task
Create a new spec/lib/generators/lokalise_rails/install_generator_spec.rb
file. Our test case will be very simple because we only need to make sure that running the given task results in creating the proper file. To run the task programmatically, you can call the start
method:
require 'generators/lokalise_rails/install_generator' describe LokaliseRails::Generators::InstallGenerator do it 'installs config file properly' do described_class.start expect(File.file?(config_file)).to be true end end
config_file
is a helper method that is going to return path to the lokalise_rails.rb
configuration file. Such methods are usually defined in the support
folder. Therefore, create a new spec/support/file_manager.rb
 file:
module FileManager def config_file "#{Rails.root}/config/lokalise_rails.rb" end end
We also need to include this module in our tests, so adjust the spec/spec_helper.rb
file in the following way:
# ... other code ... RSpec.configure do |config| config.include FileManager end
Now you can run the rspec .
command and observe test results. The lokalise_rails.rb
file should be created inside the config/
folder of your dummy Rails app.
The problem, however, is that we are not doing any cleanup after running this test. In other words, the created config file is not being removed afterwards. Also, it might be a good idea to try and remove the config before running the test to make it isolated. This problem can be easily solved with before and after hooks:
require 'generators/lokalise_rails/install_generator' describe LokaliseRails::Generators::InstallGenerator do before :all do remove_config end after :all do remove_config end # your test... end
remove_config
is also a helper method that we can define inside the support/file_manager.rb
file:
require 'fileutils' module FileManager def remove_config FileUtils.remove_file config_file if File.file?(config_file) end end
Note that I’m utilizing the remove_file
method provided by the FileUtils
module. This method allows us to delete a file by providing full path to it. Don’t forget to import the FileUtils
module before using it.
And that’s it: our installation task is done!
Rake tasks
Creating and registering a Rails task with railtie
The next step is to define and register two Rake tasks that the user will run to export or import translation files. These tasks will reside inside the lib/tasks/lokalise_rails_tasks.rake
, for instance:
require 'rake' require "#{Rails.root}/config/lokalise_rails" # <======= 1 namespace :lokalise_rails do task :import do # <======= 2 LokaliseRails::TaskDefinition::Importer.import! end task :export do # <======= 3 LokaliseRails::TaskDefinition::Exporter.export! end end
- Before running the tasks, we are trying to load the config file. This file is created by the installation task we added a minute ago.
- To call this task, one should write
rails lokalise_rails:import
. The task delegates all work to theimport!
method which we are going to create later. - Use the
rails lokalise_rails:export
command to run this task.
We must register these tasks before using them. To register a task, you need to create a railtie — a block of code that extends Rails core functionality. We are going to place our railtie inside the lib/lokalise_rails/railtie.rb
file:
module LokaliseRails class Railtie < Rails::Railtie rake_tasks do load 'tasks/lokalise_rails_tasks.rake' end end end
We also need to load this railtie inside the lib/lokalise_rails.rb
file and make sure that the Rails framework is loaded:
require 'lokalise_rails/railtie' if defined?(Rails) module LokaliseRails # ... options here ... end
Rake task definitions
So, we’ve created the tasks, but all the heavy lifting will be done by the import!
and export!
methods, therefore let’s draft them now. Start by creating a new lib/lokalise_rails/task_definition/base.rb
file. This file will host a base class. The import and export task will inherit from it:
module LokaliseRails module TaskDefinition class Base end end end
Now create a lib/lokalise_rails/task_definition/importer.rb
file:
module LokaliseRails module TaskDefinition class Importer < Base class << self def import! $stdout.print 'Task complete!' true end end end end end
We’ll take care of the actual implementation later. Note that I’m explicitly using the $stdout
variable to print the success message. This is required for our testing purposes.
Finally, create a lib/lokalise_rails/task_definition/exporter.rb
file:
module LokaliseRails module TaskDefinition class Exporter < Base class << self def export! $stdout.print 'Task complete!' true end end end end end
It’s not much, but I’d like to create a minimal setup required to make the tests pass.
Load these three files inside the lib/lokalise_rails.rb
file:
require 'lokalise_rails/task_definition/base' require 'lokalise_rails/task_definition/importer' require 'lokalise_rails/task_definition/exporter' require 'lokalise_rails/railtie' if defined?(Rails) module LokaliseRails # ... options ... end
Now we can proceed to testing our Rake tasks.
Testing Rake tasks
First and foremost, we need to load the available Rake tasks in the spec/spec_helper.rb
before running them programmatically. Add the following line to the bottom of the spec_helper.rb
file:
# ... other code Rails.application.load_tasks
Create a new spec/lib/tasks/import_task_spec.rb
file. What should we test for? Well, the import task should output the success message to STDOUT
if is everything is okay. This can be tested using the following code snippet:
expect(import_task_call_here).to output(/complete!/).to_stdout
Thing is, this expectation requires a callable object (lambda or procedure). To run a Rake task programmatically, you can use the following approach:
Rake::Task['lokalise_rails:import'].execute
Which means that the expectation transforms to the this:
expect(-> { Rake::Task['lokalise_rails:import'].execute }).to output(/complete!/).to_stdout
This construct is pretty complex, so let’s create a new support file: spec/support/rake_utils.rb
with the following content:
module RakeUtils def import_executor -> { Rake::Task['lokalise_rails:import'].execute } end def export_executor -> { Rake::Task['lokalise_rails:export'].execute } end end
These methods return callable objects to run import and export tasks. Include this module inside the spec/spec_helper.rb
:
# ... other code RSpec.configure do |config| config.include FileManager config.include RakeUtils # <======= end Rails.application.load_tasks
Now, create a new test case inside the import_task_spec.rb
file:
RSpec.describe LokaliseRails do it 'import rake task is callable' do expect(import_executor).to output(/complete!/).to_stdout end end
Also, add another spec/lib/tasks/export_task_spec.rb
file:
RSpec.describe LokaliseRails do it 'runs export rake task properly' do expect(export_executor).to output(/complete!/).to_stdout end end
The tests are ready but there’s one problem. Our Rake tasks try to load the config/lokalise_rails.rb
file containing the config options. This means that this file has to be created before running all the tests. Let’s take care of this issue now.
Creating a sample config file
Open the spec/support/file_manager.rb
file and add a new method:
require 'fileutils' module FileManager def add_config data = <<~DATA require 'lokalise_rails' LokaliseRails.config do |c| c.api_token = ENV['LOKALISE_API_TOKEN'] c.project_id = ENV['LOKALISE_PROJECT_ID'] end DATA File.open(config_file, 'w+:UTF-8') do |f| f.write data end end # ... other methods... end
This method will create a new config file with the required options. I’m using heredoc to provide the file contents.
Now tweak the spec/spec_helper.rb
in the following way:
# ... other code include FileManager add_config Rails.application.load_tasks
We are creating our configuration file before any test is run. Problem is, the test for the installation task removes the config inside the after hook. Therefore, let’s re-create the config by tweaking the spec/lib/generators/lokalise_rails/install_generator_spec.rb
, for instance:
require 'generators/lokalise_rails/install_generator' describe LokaliseRails::Generators::InstallGenerator do before :all do remove_config end after :all do remove_config add_config # <========== end # ... end
Now you may run the rspec .
command and observe the test results!
Integrating with Travis CI and Codecov
Before wrapping up this article, I also wanted to show how to integrate your plugin with Travis CI and Codecov services. Travis CI is a continuous integration service that specifically allows you to run your test suite using different versions of Ruby and Rails. It also enables us to deploy the code automatically to the target server. Codecov, in turn, monitors test coverage and displays results in a beautiful way.
Setting up Travis CI
To get started, create a new repository on GitHub (Bitbucket or GitLab). Next, open travis-ci.org and sign up using your GitHub account (it’s free for open source projects). Then click on your avatar in the top right corner and open ‘Settings’. Click ‘Sync account’ to load the available repositories. After the sync is done, find your new repository in the list and enable it by toggling the switch next to its name. Click ‘Settings’ next to your repository and find the ‘Environment variables’ section. Create LOKALISE_API_TOKEN
and LOKALISE_PROJECT_ID
variables with the proper values. The values won’t be shown in the logs so you’re safe.
Now we need to create a new .travis.yml
file in the root of your project:
language: ruby rvm: - 2.5.8 - 2.6.6 - 2.7.1 install: bundle install --retry=3 before_install: - gem update bundler env: - 'TEST_RAILS_VERSION="~> 5.1.6"' - 'TEST_RAILS_VERSION="~> 5.2.3"' - 'TEST_RAILS_VERSION="~> 6.0.3"'
We are instructing it to use Ruby language and run the testing suite with Ruby 2.5.8, 2.6.6, and 2.7.1. Also, I would like to use different versions of Rails for testing. To properly install these Rails versions, update the gemspec, like so:
if ENV['TEST_RAILS_VERSION'].nil? spec.add_development_dependency 'rails', '~> 6.0.3' else spec.add_development_dependency 'rails', ENV['TEST_RAILS_VERSION'].to_s end
So, on Travis CI we’ll use three different Rails versions to run the tests. Locally you’ll utilize Rails 6.0.x only.
Setting up Codecov
Navigate to codecov.io and sign up with your GitHub account (this service is free for open source projects). After signing up, click on your account name and search for your new repository. Basically, this is it: no additional configuration is needed as we’ve already added Codecov-related code to the spec_helper.rb
file.
At this point we are ready to get rolling. Commit your changes and push them to GitHub (make sure that the spec/dummy
folder does not have its own .git
directory!). Return to Travis CI and open the ‘Current’ tab for your repository. You will see something like this:
So, the tests are being run for every Ruby and Rails version we’ve specified. To get more information on any job, click on the specific build. After all tests have passed, return to Codecov and update the page. You’ll see the code coverage results:
You can find detailed information by clicking on the lib
folder and proceeding to individual files. Codecov provides more insights (especially when you have added more commits), so make sure to browse other tabs.
Badges
We can also add fancy-looking badges to the README of our gem. These badges will display test coverage and the results of the most recent test run. To do that, add the following contents to the README.md
:
[![Build Status](https://travis-ci.org/USERNAME/REPO_NAME.svg?branch=master)](https://travis-ci.org/USERNAME/REPO_NAME) [![Test Coverage](https://codecov.io/gh/USERNAME/REPO_NAME/graph/badge.svg)](https://codecov.io/gh/USERNAME/REPO_NAME)
Make sure to replace USERNAME
with your name (in my case that’s bodrovis
) and REPO_NAME with the name of your repository (lokalise_rails
in my case). Commit the changes and push to GitHub:
git add . git commit -am "added badges [skip ci]" git push
Note the [skip ci]
part of the commit message. It makes sure that Travis CI does not re-run all the tests again because we have not modified any codebase.
Return to your GitHub repo and check out your cool new badges:
Conclusion
That’s it for today, guys. In this article we have seen how to create a Ruby gem and introduce a testing functionality. We have created a new generator, two Rake tasks, added tests for them, and integrated our repository with Travis CI and Codecov. Not bad, eh? In the next part of this series we will flesh out the gem and see how to test third-party APIs and work with ZIP files. See you really soon!