📜 ⬆️ ⬇️

DIY DI in Ruby


Habré already had an article on Dependency Injection in Ruby, but it focused more on using the IoC-container pattern using dry-container and dry-auto_inject gems . But to take advantage of dependency injection it is not necessary to fence containers or connect libraries. Today I will talk about how to quickly implement DI with your own hands .


Description of the approach


What do people use DI for? Usually in order to change the behavior of the code during the tests, avoiding calls to external services or just to test the object in isolation from the environment. Of course, DHH says that we can stop Time.now , and enjoy the green points of the tests without much fuss, but you shouldn’t blindly believe everything DHH says. Personally, I like the point of view of Piotr Solnica, presented in this post . He gives this example:


 class Hacker def self.build(layout = 'us') new(Keyboard.new(layout: layout)) end def initialize(keyboard) @keyboard = keyboard end # stuff end 


The keyboard parameter in the constructor is dependency injection. This approach allows you to test the class Hacker , passing moki instead of the real Keyboard instance. Insulation, all things:


 describe Hacker do let(:keyboard) { mock('keyboard') } it 'writes awesome ruby code' do hacker = Hacker.new(keyboard) # some expectations end end 

But what I like in the example above is an elegant trick with the .build method in which the keyboard is initialized. In DI discussions, I have seen quite a few tips that suggested initializing dependencies into the calling code, for example, into controllers. Yeah, and then search for the whole Hacker entry project to see which class is specifically used for the keyboard, well, well. Whether business. .build : default usecase in a prominent place, you need not look for anything.


Testing the calling code


Consider the following example:


 class ExternalService def self.build options = Config.connector_options new(ExternalServiceConnector.new(options)) end def initialize(connector) @connector = connector end def accounts @connector.do_some_api_call end end class SomeController def index authorize! ExternalService.build.accounts end end 

It can be seen that the controller creates the ExternalService using real objects (even though this is hidden in the ExternalService.build method), which we try to avoid by implementing DI. How to handle this situation?


  1. Do not test the calling code at all. So-so option, I decided to write it down to complete the picture.
  2. Substitute ExternalService.build . Actually, what DHH was talking about, but there is one important point: replacing .build , we do not change the behavior of the class instances, only the wrapper. Example on RSpec:


     connector = instance_double(ExternalServiceConnector, do_some_api_call: []) allow(ExternalService).to receive(:build) { ExternalService.new(connector) } 

  3. Test controllers with integration tests on the CI server. Pros: a production code is being tested, increasing the likelihood that a potential bug will be caught by tests, and not by users. Disadvantages: it is more difficult to test exceptional situations (“third-party service has fallen”) and third-party services do not always have a sandbox account, where you can safely run tests.
  4. Use the same IoC containers.

It seems to me that the most effective is the combination of the second and third approaches: with the help of the second we test exceptional situations, with the help of the third we make sure that there are no errors in the code that instantiates the objects.


findings


Despite what was written above, I am not against the use of IoC containers in general; it is just useful to remember that there are alternatives.


Links used in the post:



')

Source: https://habr.com/ru/post/308188/


All Articles