First of all, let's make clear what is ment by an instance level
implementation and a class level
implementation.
Here is an example of an instance level
implementation:
# driver1.rb
class Driver
cattr_accessor :drunk_driver
attr_reader :blood_alc_level
def initialize
@blood_alc_level = 0
end
def drink!
@blood_alc_level += 1
end
self.drunk_driver = new.tap do |driver|
3.times { driver.drink! }
end
end
And here is an example of a class level
implementation:
# driver2.rb
clas DrunkDriver
class << self
attr_accessor :blood_alc_level
def drink!
@blood_alc_level ||= 0
@blood_alc_level += 1
end
# Test purpose only.
def reset_blood_alc_level!
@blood_alc_level = nil # Reset blood alcohol level.
3.times { drink! } # Encrease the level to the default one.
end
self.reset_blood_alc_level!
end
end
To state it clearly: it's NOT about class methods vs. instance methods. It's about putting an implementation (with some behavior AND a state) into a class or module vs. putting it into an instance.
Now to the question: Why is the first one to prefere over the last one? Let's analyze both implementations...
Basically they do the same. The first one creates an object, which is an instance of a class, it has some state and it behaves somehow. The last one also (implicitly) creates an object, this object also has a state and it also behaves in some particular way. Both then create a singleton object with a particular state.
So the last implementation does almost the same as the first one (and it is, by the way, shorter). But here is the trap: the main difference is it's globality.
Let's now look at the tests. Here is a test for the first implementation.
# driver1_spec.rb
describe Driver do
its(:blood_alc_level) { should eq(0) }
describe '#drink!' do
it 'increases the blood alcohol level by one' do
expect { subject.drink! }.to change { subject.blood_alc_level }.by(1)
end
end
describe '.drunk_driver' do
subject { Driver.drunk_driver }
it { should be_a(Driver) }
its(:blood_alc_level) { should eq(3) }
end
end
And a test for the second one.
# driver2_spec.rb
describe DrunkDriver do
subject { DrunkDriver }
before { DrunkDriver.reset_blood_alc_level! }
its(:blood_alc_level) { should eq(3) }
describe '.drink!' do
it 'increases blood alcohol level' do
expect { subject.drink! }.to change { subject.blood_alc_level }.by(1)
end
end
end
The last implementation's state is global. So it's behavior is tightly coupled to it's global state (the system level state). Whether in the first implementation the behavior is completely independent from the global state of the whole system.
Let's look what happens if we expand the functionality.
# driver1.rb
class Driver
cattr_accessor :drunk_driver
attr_reader :blood_alc_level
def initialize
@blood_alc_level = 0
end
def drink!
@blood_alc_level += 1
end
def sleep!
@blood_alc_level -= 1 if @blood_alc_level > 0
end
self.drunk_driver = new.tap do |driver|
3.times { driver.drink! }
end
end
# driver1_spec.rb
describe Driver do
its(:blood_alc_level) { should eq(0) }
describe '#drink!' do
it 'increases the blood alcohol level by one' do
expect { subject.drink! }.to change { subject.blood_alc_level }.by(1)
end
end
describe '#sleep!' do
context 'when drunk' do
it 'decreases the blood alcohol level by one' do
subject.drink!
expect { subject.sleep! }.to change { subject.blood_alc_level }.by(-1)
end
end
context 'when sobber' do
it 'does nothing' do
expect { subject.sleep! }.to_not change { subject.blood_alc_level }
end
end
end
describe '.drunk_driver' do
subject { Driver.drunk_driver }
it { should be_a(Driver) }
its(:blood_alc_level) { should eq(3) }
end
end
And the class level implementation.
# driver2.rb
clas DrunkDriver
class << self
attr_accessor :blood_alc_level
def drink!
@blood_alc_level ||= 0
@blood_alc_level += 1
end
def sleep!
if @blood_alc_level && @blood_alc_level > 0
@blood_alc_level -= 1
end
end
# Test purpose only.
def reset_blood_alc_level!
@blood_alc_level = nil # Reset blood alcohol level.
3.times { drink! } # Encrease the level to the default one.
end
self.reset_blood_alc_level!
end
end
# driver2_spec.rb
describe DrunkDriver do
subject { DrunkDriver }
before { DrunkDriver.reset_blood_alc_level! }
its(:blood_alc_level) { should eq(3) }
describe '.drink!' do
it 'increases blood alcohol level' do
expect { subject.drink! }.to change { subject.blood_alc_level }.by(1)
end
end
describe '.sleep!' do
context 'when drunk' do
it 'decreases blood alcohol level' do
subject.drink!
expect { subject.sleep! }.to change { subject.blood_alc_level }.by(-1)
end
end
context 'when sobber' do
it 'does nothing' do
# Ensure driver is sobber.
subject.sleep! until subject.blood_alc_level == 0
expect { subject.sleep! }.to_not change { subject.blood_alc_level }
end
end
end
end
We clearly see, that in the first implementation we have a lot more code to write. But on the other hand, we don't have to think about pollution of the global state. We just use a new instance every time and then just through it away. Whether in the second implementation we have to carry about the global state and we reset that state before every test case.
Now assume our code system will become more complex (which systems with budgets are tending to) and our objects will get more complex states, with lot of instance variables and lot more methods to change them.
With the last implementation we will have to carry about resetting the whole environment for each test case. Our before
block will have to handle complex functionalities and share knowledge with the implementation. We can also implement a reset
class method, but it doesn't make it much better.
So what is the core problem? The core problem is that the last implementation mixes different abstraction levels: the behavior at the "component" (or "unit") level and the system level configuration (the global state).
And it gets really awkward when it comes to the tests, because we're doing BDD and BDD is basically unit testing (which is, yes - the "unit level testing"). By mixing and tightly coupling unit level functionality with system level functionality in the implementation, we're creating a system, where our unit tests have to handle aspects from a higher abstraction level. But unit tests are NOT concepted for this purpose. And so they are quickly getting very complex, hard to understand, they pollute the global state and affect each other. They are soon getting slow because every test has to reset the global state and so on...
At the end of they day you have to decide, whether you prefere:
- an implementation with clearly separated levels of abstraction (and somehow longer implementations).
- or a less verbose implementation with a complex and fragile test suite.
Personally, I would always prefere the first one, because the maintainment costs tend to grow exponentially in a system, where you have no clear separation of concerns and abstraction levels. But it's my personal opinion...