samedi 20 août 2016

How to design a component's classes for testability and usability (Ruby)

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