Blog
User blogs
| Romain GEORGES |
Tag cloud
POST #56 : RSpec : Follow the Mock Turtle
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