Let's suppose your project needs a new feature, which API will be a class A
(i.e. the rest of the system uses the feature using A
).
Because of the SRP (Single Responsibility Principle), you will likely implement the feature using multiple classes. For example A -> B -> C -> ExternalAPI
where ->
represents dependencies (typically encapsulation). So to test A
, you have to mock ExternalAPI
at some point.
I'm wondering if there are there good practices in terms of how the dependencies should be provided. Two main requirements are:
- easy to test
- easy to build (can be important when using a console in Rails)
Let me give some options I could find.
Options 1
Each class builds what it needs when it needs, e.g.
class A
...
private
def compute...
B.new.run
end
end
How to test A
? With this setting, running tests on A
will run B#run
code and eventually make a call to the ExternalAPI
. So to test A
you have to mock ExternalAPI
using allow_any_instance_of(ExternalAPI)
which the RSpec
documentation does not recommend. Or you could stub private methods of A
. What is nice is that A
interface is simple to use (no need to inject anything).
Option 2
Each class takes its dependencies in the constructor, and a builder is responsible for assembling.
class Builder
def build
c = buildC(buildExternalAPIClient)
b = buildB(c)
buildA(b)
end
private
def buildC(api)
...
end
This approach looks quite clean regarding the dependency graph it generates. Also it's easy to test each component independently if needed, since you can inject the dependencies in every class.
But it adds a verbose and abstruse class (Builder
) and a file. And instantiating an A
requires to write Builder.new.build
instead of A.new
.
Also, testing A
with a mocked ExternalAPI
requires to build C
and B
and A
manually. Your tests now depend on implementation details: They know that B
needs a C
. You must build the C
(which itself depends on ExternalAPI
) and so on. Also if there is a console (such as in a Rails project) and you need to build a B
, you have to build the dependencies yourself.
Option 3
Each class knows how to build its dependency. For example:
class A
attr_accessor :b
def initialize
@b = default_b
end
private
def default_b
B.new
end
end
class B
attr_accessor :c
def initialize
@c = default_c
end
...
end
class C
attr_accessor :api
def initialize
@api = ExternalAPIClient.new
end
end
This is easy to build in a console and it is as well easy to test each class if necessary.
But same as before: to test A
you have to inject the mock of ExternalAPI
so you have to build every components by yourself in the test.
Also it creates an explicit reference to components (e.g. B
knows about C
) and it adds accessors
necessary to inject the dependencies in the test (could be done in initialize
but it would be similar). Also I find it creates a bit of confusion because one does not know for a component if the genericity is due to the component being generic (e.g. B
could work with different variants of C
) or if the genericity has been introduced only to make it easy to test (which is the case in this post).
Option 4
External dependencies of the component is passed all the way from class to class, from A
to C
:
class A
attr_accessor :external_api_client
def initialize
@external_api_client = default_client
end
...
...
B.new(external_api_client).run
...
private
def default_client
ExternalAPIClient.new
end
end
class B
def initialize(external_api_client)
end
...
C.new(external_api_client)
end
Easy to test, easy to build. Looks quite alright!
Option 5
Each class is configured through a cattr_accessor
:
class C
cattr_accessor :external_api
end
in the test:
C.external_api_client = build_double
...
This also looks easy to test and to build.
Conclusion
To me, options 4 and 5 seem the best. In the end, it boils down to the following principle: dependencies you have to mock (often external dependencies) should be made configurable or injectable. Other dependencies (such as the dependency of A
to B
) do not need any special treatment. Do you share this opinion? Do you agree options 4 and 5 are the most appropriate? Which to choose?
Bonus question: I've been surprised not to find articles treating about this kind of topics. I probably simply could not find them due to not using the proper keywords. Are there some keywords referring to this kind of questions? Does the question make sense or is it kind of ill defined? Are there other options? Are there good practices?
Aucun commentaire:
Enregistrer un commentaire