Blog

POST #56 : RSpec : Follow the Mock Turtle

Added by Romain GEORGES over 1 year ago

de la bonne pratique du test et du design logiciel

Imaginons un Composant logiciel qui doit executer des #action, un objet. Imaginons que comme il se doit, il loggue ses actions.
Allons-y de plus en plus élégamment (donc la première version c'est comme l'Australie en kangourou, il faut pas le faire !):

dans ./composant.rb :

 1 class Composant 
 2 
 3   def initialize
 4     # nothing 
 5   end
 6 
 7  def action 
 8    # a process
 9    puts 'action made' 
10  end
11 
12 end

Voila la c'est bien sale !
si on veut changer de méthode de logging, on modifie la classe Composant, methode :action ! c'est mal !

Déja, reprennons sur de bonne base :
TDD, même BDD => Rspec !! Behaviour driven Dev..

$ sudo gem install rspec 

On va donc écrire des Spec dans ./composant.spec:

1 require 'composant.rb'
2 describe Composant do
3   specify { described_class.class.should be_a_kind_of(Class) }
4   specify { described_class.should equal(Composant) }
5   subject { Composant::new }
6   specify { subject.should respond_to :action }
7 end

A priori notre Composant devrai faire l'affaire

 1 #rspec -fn composant.spec
 2 Composant
 3   should be a kind of Class
 4   should equal Composant
 5   should respond to #action
 6 
 7 Finished in 0.00138 seconds
 8 3 examples, 0 failures
 9 #

L'idéal est de créer une classe à part pour logguer, pour bien séparer les activités,
on va specifier une class de log Logutils dans ./logutils.spec

1 require 'logutils.rb'
2 describe Logutils do
3   specify { described_class.class.should be_a_kind_of(Class) }
4   specify { described_class.should equal(Logutils) }
5   subject { Logutils::new }
6   specify { subject.should respond_to :echo }
7 end

on lance rspec :

 1 #rspec -fn logutils.spec                                              
 2 /usr/local/lib/site_ruby/1.8/rubygems/custom_require.rb:36:in `gem_original_require': no such file to load -- logutils.rb (LoadError)
 3     from /usr/local/lib/site_ruby/1.8/rubygems/custom_require.rb:36:in `require'
 4     from /home/lecid/logutils.spec:1
 5     from /usr/lib/ruby/gems/1.8/gems/rspec-core-2.8.0/lib/rspec/core/configuration.rb:698:in `load'
 6     from /usr/lib/ruby/gems/1.8/gems/rspec-core-2.8.0/lib/rspec/core/configuration.rb:698:in `load_spec_files'
 7     from /usr/lib/ruby/gems/1.8/gems/rspec-core-2.8.0/lib/rspec/core/configuration.rb:698:in `map'
 8     from /usr/lib/ruby/gems/1.8/gems/rspec-core-2.8.0/lib/rspec/core/configuration.rb:698:in `load_spec_files'
 9     from /usr/lib/ruby/gems/1.8/gems/rspec-core-2.8.0/lib/rspec/core/command_line.rb:22:in `run'
10     from /usr/lib/ruby/gems/1.8/gems/rspec-core-2.8.0/lib/rspec/core/runner.rb:80:in `run_in_process'
11     from /usr/lib/ruby/gems/1.8/gems/rspec-core-2.8.0/lib/rspec/core/runner.rb:69:in `run'
12     from /usr/lib/ruby/gems/1.8/gems/rspec-core-2.8.0/lib/rspec/core/runner.rb:10
13     from /usr/bin/rspec:19
14 #

Normal, on va donc implémenter Logutils dans le fichier ./logutils.rb :

1 class Logutils
2   def initialize
3   end
4   def echo(msg) 
5     return true
6   end
7 end

on lance rspec :

 1 #rspec -fn logutils.spec                                              
 2 Logutils
 3   should be a kind of Class
 4   should equal Logutils
 5   should respond to #echo
 6 
 7 Finished in 0.00134 seconds
 8 3 examples, 0 failures
 9 #

Bon début.
Mais revenons à notre premier cas de conscience, utiliser un logguer séparé dans notre Composant, on spécifie dans ./composant.spec.

 1 require 'composant.rb'
 2 describe Composant do
 3   specify { described_class.class.should be_a_kind_of(Class) }
 4   specify { described_class.should equal(Composant) }
 5   subject { Composant::new }
 6   specify { subject.should respond_to :action }
 7   context "attribut @logger" do
 8     specify { subject.instance_variable_get(:@logger).should be_an_instance_of Logutils }
 9   end
10 end

on relance rspec

 1 Composant
 2   should be a kind of Class
 3   should equal Composant
 4   should respond to #action
 5   attribut @logger
 6      (FAILED - 1)
 7 
 8 Failures:
 9 
10   1) Composant attribut @logger 
11      Failure/Error: specify { subject.instance_variable_get(:@logger).should be_an_instance_of Logutils }
12      NameError:
13        uninitialized constant Logutils
14      # ./composant.spec:8
15 
16 Finished in 0.00194 seconds
17 4 examples, 1 failure
18 
19 Failed examples:
20 
21 rspec ./composant.spec:8 # Composant attribut @logger

Allons donc coder ça dans ./composant.rb

 1 require 'logutils.rb'
 2 class Composant 
 3 
 4   def initialize
 5     @logger = Logutils::new 
 6   end
 7 
 8  def action 
 9    # a process
10    @logger.echo 'action made' 
11  end
12 
13 end

on relance rspec :

 1 Composant
 2   should be a kind of Class
 3   should equal Composant
 4   should respond to #action
 5   attribut @logger
 6     should be an instance of Logutils
 7     should be true
 8 
 9 Finished in 0.00165 seconds
10 4 examples, 0 failures

Bien !

Par contre @logger est obligratoirement un Logutils...
si on veut pour tester Composant sans alternative de code dans les deux classe, une seule solution le Mock Object
Ici, une bonne pratique s'impose, le découplage !!
il faut faire en sorte qu'un composant reçoive en paramètre d'initialisation un logger, encore mieux serait de faire une IOC ( inversion de contrôle), depuis une factory ou un gestionnaire de ressource (server d'application)

j'en viens directement au cas le plus élégant (avec détails et fioritures) :

d'abord les specs : ./logutils.spec (oui, elles sont précises !!)

 1 require './logutils.rb'
 2 describe Logutils do
 3   before :all do
 4     @logger = Logutils::new
 5     @agent_logger = Logutils::new('Agent')
 6   end
 7 
 8   specify { described_class.class.should be_a_kind_of(Class) }
 9   specify { described_class.should equal(Logutils) }
10   context "#initialize" do
11     context "Exceptions" do
12       it "should not raise ArgumentError if running without any arguments" do
13         lambda { Logutils::new }.should_not raise_error ArgumentError
14       end
15       it "should raise TypeError if running with one only non String argument" do
16         lambda { Logutils::new(10)}.should raise_error TypeError
17       end
18       it "should not raise ArgumentError if running with one String argument" do
19         lambda { Logutils::new('Agent')}.should_not raise_error ArgumentError
20       end
21       it "should raise ArgumentError if running with more than 1 argument" do
22         lambda { Logutils::new('Agent','arg')}.should raise_error ArgumentError
23       end
24     end
25     specify { @logger.should respond_to :echo }
26     context "when running #new" do
27       context "attribut @caller" do
28         specify { @logger.instance_variable_get(:@caller).should eq('unknown') }
29       end
30       context "attribut @last_message" do
31         specify { @logger.instance_variable_get(:@last_message).should eq('') }
32       end
33       context "attribut @last_args" do
34         specify { @logger.instance_variable_get(:@last_args).should eq([]) }
35       end
36     end
37     context "when running #new('Agent')"do
38       context "attribut @caller" do
39         specify { @agent_logger.instance_variable_get(:@caller).should eq('Agent') }
40       end
41       context "attribut @last_message" do
42         specify { @agent_logger.instance_variable_get(:@last_message).should eq('') }
43       end
44       context "attribut @last_args" do
45         specify { @agent_logger.instance_variable_get(:@last_args).should eq([]) }
46       end
47     end
48 
49   end
50 
51   context "#echo" do
52     context "Exceptions" do
53       subject { Logutils::new }
54       it "should raise ArgumentError if running without any arguments" do
55         lambda { subject.echo}.should raise_error ArgumentError 
56       end
57       it "should raise ArgumentError if running with one only non String argument" do
58         lambda { subject.echo(10)}.should raise_error ArgumentError 
59       end
60       it "should raise ArgumentError if running with many arguments but not all Strings" do
61         lambda { subject.echo('toto',10)}.should raise_error ArgumentError
62       end
63       it "should not raise ArgumentError if running with one String argument" do
64         lambda { subject.echo('message')}.should_not raise_error ArgumentError
65       end
66       it "should not raise ArgumentError if running with many String only arguments" do
67         lambda { subject.echo('message','arg')}.should_not raise_error ArgumentError
68       end
69     end
70     context "when running #echo('message')" do
71       specify { @logger.echo('message').should be_true }
72       context "attribut @last_message" do 
73         specify { @logger.instance_variable_get(:@last_message).should eq('message') }
74       end
75       context "attribut @last_args" do 
76         specify { @logger.instance_variable_get(:@last_args).should eq([]) }
77       end
78     end
79     context "when running #echo('message','arg1','arg2')" do
80       specify { @logger.echo('message','arg1','arg2').should be_true }
81       context "attribut @last_message" do 
82         specify { @logger.instance_variable_get(:@last_message).should eq('message') }
83       end
84       context "attribut @last_args" do 
85         specify { @logger.instance_variable_get(:@last_args).should eq(['arg1','arg2']) }
86       end
87     end
88   end
89 end

pour le code qui match ces specs, dans ./logutils.rb

 1 class Logutils
 2 
 3   def initialize (caller = nil)
 4     raise TypeError::new('Caller is not a String') unless caller.class == String or caller.nil?
 5     @caller = (caller.nil?)? 'unknown' : caller
 6     @last_message = String::new
 7     @last_args = Array::new
 8   end
 9 
10   def echo (message,*args)
11     raise ArgumentError::new('Message is not a String') unless message.class == String
12     args.each do |item|
13       raise ArgumentError::new("Arguments contains non String") unless item.class == String
14     end
15     @last_message = message
16     @last_args = args
17     puts "from #{@caller} send : #{message} with args : #{args.join(',')}" 
18     return true
19   end
20 end

Attention à l'execution de Rspec, cette class fait des 'puts', donc regardez bien la commande :

 1 #rspec -fn -c test.spec -o test.txt; cat test.txt
 2 from unknown send : message with args : 
 3 from unknown send : message with args : arg
 4 from unknown send : message with args : 
 5 from unknown send : message with args : arg1,arg2
 6 
 7 Logutils
 8   should be a kind of Class
 9   should equal Logutils
10   #initialize
11     should respond to #echo
12     Exceptions
13       should not raise ArgumentError if running without any arguments
14       should raise TypeError if running with one only non String argument
15       should not raise ArgumentError if running with one String argument
16       should raise ArgumentError if running with more than 1 argument
17     when running #new
18       attribut @caller
19         should eq "unknown" 
20       attribut @last_message
21         should eq "" 
22       attribut @last_args
23         should eq []
24     when running #new('Agent')
25       attribut @caller
26         should eq "Agent" 
27       attribut @last_message
28         should eq "" 
29       attribut @last_args
30         should eq []
31   #echo
32     Exceptions
33       should raise ArgumentError if running without any arguments
34       should raise ArgumentError if running with one only non String argument
35       should raise ArgumentError if running with many arguments but not all Strings
36       should not raise ArgumentError if running with one String argument
37       should not raise ArgumentError if running with many String only arguments
38     when running #echo('message')
39       should be true
40       attribut @last_message
41         should eq "message" 
42       attribut @last_args
43         should eq []
44     when running #echo('message','arg1','arg2')
45       should be true
46       attribut @last_message
47         should eq "message" 
48       attribut @last_args
49         should eq ["arg1", "arg2"]
50 
51 Finished in 0.0533 seconds
52 24 examples, 0 failures

Ensuite, et c'est la que ça devient vraiment intéressant !!!
Les specs du squelette du Composant et l'usage du 'Mock', il s'agit d'un squelette de Composant, cette spec n'est exhaustive, il manque notamment les Exceptions

./composant.spec

 1 describe Composant do
 2   before :all do
 3     @comp = Composant::new
 4     @comp_dummy = Composant::new(Logutils::new('Dummy'))
 5    end
 6   specify { described_class.class.should be_a_kind_of(Class) }
 7   specify { described_class.should equal(Composant) }
 8   subject { Composant::new }
 9   context "initialization" do
10     context "when running #new" do
11       context "attribut @logger" do
12         specify { @comp.instance_variable_get(:@logger).should be_an_instance_of Logutils }
13       end
14     end
15     context "when running #new(Logutils::new('Dummy'))" do
16       context "attribut @logger" do
17         specify { @comp_dummy.instance_variable_get(:@logger).should be_an_instance_of Logutils }
18       end
19     end
20   end
21   specify { subject.should respond_to :action }
22   context "#action" do
23     context "Exceptions" do
24 
25     end
26     context "when running" do
27       it "@logger should receive message :echo with arguments  'action',['arg1','arg2'] and return true" do
28         @aFakeLogutils = mock(Logutils)
29         @test = Composant::new(@aFakeLogutils)
30         testexpectation = @aFakeLogutils.should_receive(:echo).with('action',['arg1','arg2']).and_return(true)
31         @test.action('arg1','arg2')
32       end
33     end
34   end
35 end

Remarque : il ne semble pas possible de faire le mock dans le before :all, je ne sais pas pourquoi, je trouve ça dommage.

passons donc au code :

./composant.rb

 1 class Composant
 2 
 3   def initialize (alogger = Logutils::new(self.class.to_s))
 4     @logger = alogger
 5   end
 6 
 7   def action(*args)
 8     args.each do |item|
 9       raise ArgumentError::new("Arguments contains non String") unless item.class == String
10     end
11     return @logger.send(:echo,__method__.to_s,args) unless @logger.nil?
12     # do process #                                                                                                                                                          
13   end
14 
15 end

Les mesquins ne manquerons pas de noter la débauche d'énergie pour ça ! mais cette exemple doit-être situé dans un contexte plus large, la démarche doit être généralisée et cette fois-ci, le travail et le formalisme, fait une bonne fois pour toute, n'est plus a faire.

si on execute Rspec :

#rspec -fn -c test.spec -o test.txt; cat test.txt
Composant
  should be a kind of Class
  should equal Composant
  should respond to #action
  initialization
    when running #new
      attribut @logger
        should be an instance of Logutils
    when running #new(Logutils::new('Dummy'))
      attribut @logger
        should be an instance of Logutils
  #action
    when running
      @logger should receive message :echo with arguments  'action',['arg1','arg2'] and return true

Finished in 0.01601 seconds
6 examples, 0 failures

And "Voila" !!

Also available in: Atom