Une stack de tests pour Rails 3.2

Publié le 14 février 2013 par Nicolas Zermati | back

Cet article est publié sous licence CC BY-NC-SA

Lorsqu’on commence à réaliser une application Rails, on est vite tenté de ne pas faire de tests. Mettre en place une stack de test peut prendre du temps. La diversité des tests de Rails peut effrayer. On peut juger que pour la taille du projet, passer par l’étape des tests est inutile. Enfin, les raisons ne manquent pas pour faire l’impasse sur cette partie du développement.

J’avais déjà présenté Capybara dans un précédent article. Aujourd’hui, je voudrais non pas débattre sur les bénéfices du tests mais simplement partager une stack de tests avec vous.

Rails

La première brique c’est bien entendu Rails. Rails définit un tas de conventions concernant les tests. Par défaut Rails utilise un système de fixtures, la librairie test/unit, fournit des générateurs, etc. Tout ceci est très bien expliqué dans le guide du test avec Rails (en).

Pour utiliser cela, il n’y a donc pas de dépendances à ajouter a votre Gemfile. Il vous faudra par contre configurer votre environnement de test et votre base de données de test.

Configuration de la base de données

En ce qui me concerne, j’utilise Postgresql et Sequel par défaut.

J’ai donc les lignes suivantes dans mon Gemfile :

# Gemfile

# Replace sqlite3 by postgresql
#gem 'sqlite3'
gem 'pg'
gem 'sequel'
gem 'talentbox-sequel-rails', require: 'sequel-rails'

La dernière ligne : sequel-rails permet d’intégrer Sequel dans Rails. La gem adapte les générateurs pour les migrations et les modèles, les taches rakes, la connexion à la base, etc. Pensez à faire un bundle install.

Afin de faire entrer Sequel dans Rails, il faut se débarrasser de tout ce qui concerne ActiveRecord dans les fichiers de configuration. Voyez le site de sequel-rails pour les détails d’installation.

Voici mon fichier config/database.yml :

# config/database.yml

development: &dev
  adapter: postgresql
  username: myapp_user
  database: myapp_development

test:
  <<: *dev
  database: myapp_test

Sécurité de la base de donnée

L’utilisateur myapp_user ne dispose pas des droits nécessaires à la création/suppression de bases de données. En conséquence, il me faut créer les deux bases à la main puis donner les droits a myapp_user sur celles ci.

Les taches db:create ou db:test:prepare sont également inadaptées. L’environnement de test s’en trouve perturbé mais il me semble que la raison justifie le dérangement. Dans le fichier lib/tasks/db.rake, je garde une tache qui me permet de supprimer une a une les tables de la base puis d’y charger le fichier db/schema.rb :

# lib/tasks/db.rake

namespace :db do
  namespace :test do
    desc "Clean all the table in the database and load the schema.rb file"
    task recreate: :environment do
      if Rails.env.test?
        Sequel::DATABASES.each do |db|
          puts 'Drop tables:'
          db.tables.each do |table|
            db.drop_table?(table, cascade: true)
            puts "  #{table}"
          end
        end
        Rake::Task['db:schema:load'].invoke
      else
        abort "This is only for test env."
      end
    end
  end
end

Après chaque nouvelle migration, je lance donc la commande RAILS_ENV=test bundle exec rake db:test:recreate.

RSpec

Plutôt que d’utiliser le framework par défaut, j’utilise RSpec. Pour moi l’avantage majeur est qu’il très bien documenté, ne ratez surtout pas la documentation de RSpec.

Rspec se divise en 4 parties :

  • RSpec Core - un DSL pour l’écriture de spécifications,
  • RSpec Expectations - un DSL pour l’écriture d’assertions,
  • RSpec Mocks - un DSL pour l’écriture de bouchons logiciels et
  • RSpec Rails - l’intégration dans une architecture Rails des éléments ci-dessus.

Pour ajouter RSpec à son projet, commencez par ajouter un groupe test à votre Gemfile :

# Gemfile

group :development, :test do
  # Use RSpec for testing
  gem 'rspec-rails'
end

Cette section du gemfile va grossir au fur et à mesure que l’on ajoutera des éléments à notre stack de tests.

Ensuite exécutez les commandes suivante pour compléter l’installation et lancer la suite de tests :

bundle install
bundle exec rails generate rspec:install
bundle exec rake spec

Si vous suivez ce billet en ayant un pied dans votre console, la dernière ligne de commande vous lancera un No examples matching ./spec{,/*/**}/*_spec.rb could be found. Si vous n’avez rien en base c’est normal.

Fabrication

Vous avez pu remarquez que ma solution de supprimer les tables lors du db:test:recreate est brutale. Vous devez recharger d’éventuelles données après chaque migrations. En ce qui me concerne, je pars toujours d’une base vierge. J’ai beaucoup trop de mal à maintenir un ensemble de fixtures cohérent.

Pour m’aider dans la génération du contexte de mes tests, j’utilise une factory pour mes objets. Je confie ce travail à Fabrication, qui est simple et compatible avec beaucoup d’ORMs.

Pour l’installer, on modifie la section test de notre Gemfile qui devient :

group :development, :test do
  # Use RSpec for testing
  gem 'rspec-rails'

  # Factory for sequel models
  gem 'fabrication'
  gem 'faker'
end

J’ai également ajouté la gem Faker qui me semble maintenant indispensable pour générer de fausses données de test.

Pour compléter l’installation de Fabrication, on insère dans le fichier config/application.rb les lignes suivantes :

config.generators do |g|
  g.test_framework      :rspec, fixture: true
  g.fixture_replacement :fabrication
end

DatabaseCleaner

Les tests réalisés doivent être entièrement indépendants. RSpec sait mettre à profit les transactions SQL uniquement lorsque l’on utilise ActiveRecord. DatabaseCleaner fournit plusieurs moyens de nettoyage de la base de donnée pour les ORMs les plus populaires. On modifie la section test de notre Gemfile qui devient :

group :development, :test do
  # Use RSpec for testing
  gem 'rspec-rails'

  # Factory for sequel models
  gem 'fabrication'
  gem 'faker'

  # Use database cleanner to clean data after each tests
  gem 'database_cleaner'
end

Une fois installé, on configure le nettoyage dans le fichier spec/spec_helper.rb généré lors de l’installation de RSpec :

require 'database_cleaner'

RSpec.configure do |config|

  # Do not use ActiveRecord's transactionnal fixtures
  #config.use_transactional_fixtures = true

  # Wrap each test in a transaction
  DatabaseCleaner.strategy = :transaction
  config.around(:each) do |spec|
    DatabaseCleaner.start
    spec.run
    DatabaseCleaner.clean
  end

end

Guard & Spork

Pour relancer automatiquement la suite de tests lorsqu’un fichier est modifié, on va utiliser Guard et particulièrement son plugin Guard RSpec. Et, afin de recharger le minimum d’élements on va utiliser Spork et le plugin associé Guard Spork.

On commence par ajouter guard à notre section de test :

group :development, :test do
  # Guard
  gem 'rb-inotify', '~> 0.8.8', require: (RUBY_PLATFORM.downcase.include?('linux') && 'rb-inotify')
  gem 'rb-fsevent', require: (RUBY_PLATFORM.downcase.include?('darwin') && 'rb-fsevent')
  gem 'guard'

  # Use RSpec for testing
  gem 'rspec-rails'

  # Factory for sequel models
  gem 'fabrication'
  gem 'faker'

  # Use database cleanner to clean data after each tests
  gem 'database_cleaner'
end

Puis on exécute :

bundle install
bundle exec guard init

Ensuite on ajoute guard-rspec, spork et guard-spork :

group :development, :test do
  # Guard
  gem 'rb-inotify', '~> 0.8.8', require: (RUBY_PLATFORM.downcase.include?('linux') && 'rb-inotify')
  gem 'rb-fsevent', require: (RUBY_PLATFORM.downcase.include?('darwin') && 'rb-fsevent')
  gem 'guard'

  # Use RSpec for testing
  gem 'rspec-rails'
  gem 'guard-rspec'

  # Spork test server to avoid restarting the full Rails stack
  gem 'spork'
  gem 'guard-spork'

  # Factory for sequel models
  gem 'fabrication'
  gem 'faker'

  # Use database cleanner to clean data after each tests
  gem 'database_cleaner'
end

Pour finaliser l’installation, outre le classique bundle install, on doit initialiser Spork :

bundle install
bundle exec spork rspec --bootstrap

Cette dernière commande a pour effet de préparer le fichier spec/spec_helper.rb a être séparé en deux. En effet, on sépare en deux ce fichier en fonction de ce qu’il faut charger au démarrage (prefork) et ce qu’il ne faut recharger qu’à chaque test (each_run).

require 'rubygems'
require 'spork'

Spork.prefork do
  ENV["RAILS_ENV"] ||= 'test'
  require File.expand_path("../../config/environment", __FILE__)
  require 'rspec/rails'
  require 'rspec/autorun'
  require 'database_cleaner'
end

Spork.each_run do
  Dir[Rails.root.join("spec/support/**/*.rb")].each {|f| require f}

  RSpec.configure do |config|
    config.fixture_path = "#{::Rails.root}/spec/fixtures"
    config.infer_base_class_for_anonymous_controllers = false
    config.order = "random"

    # Wrap each test in a transaction
    DatabaseCleaner.strategy = :transaction
    config.around(:each) do |spec|
      DatabaseCleaner.start
      spec.run
      DatabaseCleaner.clean
    end
  end
end

En plus de ce fichier, on doit modifier le fichier Guardfile afin de prendre en compte RSpec et Spork. Voici un exemple :

# Guardfile

guard 'spork', rspec_env: { 'RAILS_ENV' => 'test' }, test_unit: false, cucumber: false do
  watch('config/application.rb')
  watch('config/environment.rb')
  watch('config/environments/test.rb')
  watch(%r{^config/initializers/.+\.rb$})
  watch('Gemfile')
  watch('Gemfile.lock')
  watch(%r{^lib/(.+)\.rb$})
  watch('spec/spec_helper.rb') { :rspec }
  watch('test/test_helper.rb') { :test_unit }
  watch(%r{features/support/}) { :cucumber }
end

guard 'rspec' do
  watch(%r{^spec/.+_spec\.rb$})
  watch(%r{^lib/(.+)\.rb$})                           { |m| "spec/lib/#{m[1]}_spec.rb" }
  watch(%r{^spec/support/(.+)\.rb$})                  { "spec" }
  watch('spec/spec_helper.rb')                        { "spec" }

  watch(%r{^app/(.+)\.rb$})                           { |m| "spec/#{m[1]}_spec.rb" }
  watch(%r{^app/(.*)(\.erb|\.haml)$})                 { |m| "spec/#{m[1]}#{m[2]}_spec.rb" }
  watch(%r{^app/controllers/(.+)_(controller)\.rb$})  { |m| ["spec/#{m[2]}s/#{m[1]}_#{m[2]}_spec.rb"] }
  watch(%r{^app/views/(.+)/.*\.(erb|haml)$})          { |m| "spec/features/#{m[1]}_spec.rb" }
  watch('app/controllers/application_controller.rb')  { "spec/controllers" }
end

Une fois tous ces fichiers en place on peut lancer la commande bundle exec guard afin de surveiller les fichiers listés dans le Guardfile et de lancer les actions appropriées.

Capybara

Parmi la grande variété de tests que recommande Rails, il y a les tests d’intégrations. Avec RSpec ils sont générés dans le répartoire spec/features. Ces tests nécessitent l’utilisation de Capybara dans sa version 2.0.0 ou plus. On l’ajoute donc à notre section test qui devient :

group :development, :test do
  # Guard with the GNU/Linux inotify library to get file changes instead of polling
  gem 'rb-inotify', '~> 0.8.8', require: (RUBY_PLATFORM.downcase.include?('linux') && 'rb-inotify')
  gem 'rb-fsevent', require: (RUBY_PLATFORM.downcase.include?('darwin') && 'rb-fsevent')
  gem 'guard'

  # Use RSpec for testing
  gem 'rspec-rails'
  gem 'guard-rspec'

  # Use Capybara's DSL to test features
  gem 'capybara'

  # Spork test server to avoid restarting the full Rails stack
  gem 'spork'
  gem 'guard-spork'

  # Factory for sequel models
  gem 'fabrication'
  gem 'faker'

  # Use database cleanner instead of transaction for advanced JS tests
  gem 'database_cleaner'
end

Il faut également ajouter à notre fichier spec/spec_helper.rb, dans la section prefork, la ligne suivante :

require 'capybara/rails'

Par défaut, Capybara va utiliser un driver basé sur Rack-test pour exécuter les tests d’intégration. Ce driver est rapide puisqu’il ne charge pas de navigateur mais communique directement avec Rack.

Poltergeist

Dans beaucoup de situations le javascript fait partie de la fonctionnalité à tester. Dans ces cas là, Rack-test ne suffit pas. Pour palier à ce problème, j’utilise Poltergeist : un driver pour Capybara qui utilise PhantomJS pour jouer les tests. Dans mon exemple j’utilise la version de développement de Poltergeist qui est compatible avec Capybara 2, ce qui n’est pas encore le cas pour la version stable.

group :development, :test do
  # Guard with the GNU/Linux inotify library to get file changes instead of polling
  gem 'rb-inotify', '~> 0.8.8', require: (RUBY_PLATFORM.downcase.include?('linux') && 'rb-inotify')
  gem 'rb-fsevent', require: (RUBY_PLATFORM.downcase.include?('darwin') && 'rb-fsevent')
  gem 'guard'

  # Use RSpec for testing
  gem 'rspec-rails'
  gem 'guard-rspec'

  # Use Capybara's DSL to test features and poltergeist drivers for advanced tests
  gem 'capybara'
  gem 'poltergeist', github: 'jonleighton/poltergeist', branch: 'master'

  # Spork test server to avoid restarting the full Rails stack
  gem 'spork'
  gem 'guard-spork'

  # Factory for sequel models
  gem 'fabrication'
  gem 'faker'

  # Use database cleanner instead of transaction for advanced JS tests
  gem 'database_cleaner'
end

Bien sûr, vous devez avoir installé PhantomJS pour utiliser ce driver.

Lorsqu’on utilise un driver différent de Rack-test, Capybara utilise un thread séparé pour jouer le rôle de serveur. Dans ce cas, jouer chaque test dans une transaction ne fonctionne plus. En effet, la mise en place du test se fait dans une transaction et les requêtes se font hors de cette transaction. Pour utiliser le driver uniquement lorsque c’est nécessaire et pour utiliser une autre stratégie de nettoyage de la base de données, modifier le fichier spec/spec_helper.rb comme ceci :

# Wrap each test in a transaction
config.around(:each) do |spec|
  if example.options[:phantom]
    Capybara.current_driver = :poltergeist
    DatabaseCleaner.strategy = :truncation
  else
    Capybara.current_driver = :rack_test
    DatabaseCleaner.strategy = :transaction
  end

  DatabaseCleaner.start
  spec.run
  DatabaseCleaner.clean
end

Ensuite, pour utiliser ponctuellement phantomjs, on pourra ajouter l’option :phantom au test d’intégration.

scenario 'some extra feature', phantom: true do
  ...
end

Simplecov

Lorsque l’on teste son code, on veut être sûr de ne rien oublier. Simplecov fournit des rapport de couverture assez simples. On l’ajoute à notre section test qui devient :

group :development, :test do
  # Guard with the GNU/Linux inotify library to get file changes instead of polling
  gem 'rb-inotify', '~> 0.8.8', require: (RUBY_PLATFORM.downcase.include?('linux') && 'rb-inotify')
  gem 'rb-fsevent', require: (RUBY_PLATFORM.downcase.include?('darwin') && 'rb-fsevent')
  gem 'guard'

  # Use RSpec for testing
  gem 'rspec-rails'
  gem 'guard-rspec'

  # Use Capybara's DSL to test features and poltergeist drivers for advanced tests
  gem 'capybara'
  gem 'poltergeist', github: 'jonleighton/poltergeist', branch: 'master'

  # Spork test server to avoid restarting the full Rails stack
  gem 'spork'
  gem 'guard-spork'

  # Factory for sequel models
  gem 'fabrication'
  gem 'faker'

  # Use database cleanner instead of transaction for advanced JS tests
  gem 'database_cleaner'

  # Use simplecov to get test covering metrics
  gem 'simplecov', :require => false
end

Puis on ajoute en tête de notre bloc prefork les lignes suivantes :

Spork.prefork do
  unless ENV['DRB']
    require 'simplecov'
    SimpleCov.start 'rails'
  end
  ...
end

Après l’exécution de vos tests par bundle exec rake spec, vous trouverez un rapport complet dans ce fichier HTML : coverage/index.html.

Divers

Voici, en vrac, divers fonctionnalitées ou hacks bons à connaitre.

Envoyer des fichiers

Pour un upload de fichier dans un test d’intégration, capybara permet de faire ceci :

attach_file 'resource[file]', "#{Rails.root}/spec/fixtures/files/1.js"

Lors du test d’un controlleur, on passera par cette écriture :

post :action, f: Rack::Test::UploadedFile.new("#{Rails.root}/spec/fixtures/files/1.js")

Selon votre répertoire d’upload, pensez a nettoyer les fichiers uploadés après chaque suite de tests exécutée. Pour cela, ajoutez cette ligne dans le fichier spec/spec_helper.rb, dans la configuration de RSpec :

# Remove all the uploaded files
config.after(:all) { FileUtils.rm_rf(Rails.root.join('uploads', 'test')) }

Définir le nom d’hôte

Si vous avez des contraintes d’hôte dans vos routes, vous devez les simuler lors de vos test d’intégration.

Avec Rack-test, pas de problème, un simple Capybara.app_host = 'http://test.host' et le tour est joué.

Avec Poltergeist, il va falloir ajouter la ligne suivante dans votre fichier /etc/hosts :

127.0.0.1 test.host

et, spécifier le port sur lequel tourne le serveur de capybara :

Capybara.app_host = "http://test.host:#{Capybara.current_session.server.port}"

Lors du test des controlleurs, vous avez directement la possibilité de modifier la requête : request.host = 'test.host'.

Manipuler les cookies

Pour ajouter ou supprimer des cookies dans mes tests d’intégration, j’utilise deux méthodes définies dans un helper RSpec :

def get_cookie(name)
  case Capybara.current_driver
  when :rack_test
    sess = Capybara.current_session.driver.browser.current_session
    sess.instance_variable_get(:@rack_mock_session).cookie_jar[name]
  when :poltergeist
    Capybara.current_session.driver.cookies[name].try(:value)
  else
    raise "Unknown driver used"
  end
end

def set_cookie(name, value)
  case Capybara.current_driver
  when :rack_test
    sess = Capybara.current_session.driver.browser.current_session
    sess.instance_variable_get(:@rack_mock_session).cookie_jar[name]=value
  when :poltergeist
    if Capybara.app_host
      h = URI.parse(Capybara.app_host).host
      b = Capybara.current_session.driver.browser
      b.set_cookie(name: name, value: value, domain: h)
    else
      Capybara.current_session.driver.set_cookie(name, value)
    end
  else
    raise "Unknown driver used"
  end
end

Ce sont deux hacks, tant pour l’accès au cookie_jar du driver Rack-test que pour la définition de cookies avec Poltergeist lorsque Capybara.app_host commence par http://.

Lors du test des controlleurs, vous avez directement accès aux cookies avec : response.cookies['name'] ou request.cookies['name']=value.

Modifier son IP

Si vous faites du controle d’accès via l’IP directement dans votre application Rails, vous serez amené à changer l’IP du faux client lors de tests d’intégration.

Toujours dans un module d’helpers j’utilise le hack suivant :

def customize_ip(cip)
  @_custom_ip = ENV['FAKE_TEST_IP'] = cip
end

def custom_ip
  @_custom_ip || '127.0.0.1'
end

module ::ActionDispatch
  class Request < Rack::Request
    def remote_ip
      @remote_ip ||= (ENV.delete('FAKE_TEST_IP') || @env["action_dispatch.remote_ip"] || ip).to_s
    end
  end
end

Cette technique ne fonctionne que pour la prochaine requête reçu par le serveur mais ouvre la voie vers d’autres solutions.

Conclusion

Voila en ce qui concerne ma stack actuelle, si vous avez des suggestions sur ce qu’il y a en trop, sur ce qu’il manque ou sur d’autres hacks utiles alors n’hésitez pas à me le faire savoir. Avec ces infos vous devriez pouvoir mettre en place une stack de tests en un rien de temps, avant même d’avoir le moindre modèle ou la moindre migration.

L’équipe Synbioz.

Libres d’être ensemble.