DIY DI in Ruby


    There was already an article on Habr dedicated to Dependency Injection in Ruby, but the emphasis was more on the use of the IoC-container pattern using the dry-container and dry-auto_inject gems . But in order to take advantage of dependency injection, it is completely unnecessary to fence containers or connect libraries. Today I’ll talk about how to quickly implement DI with your own hands .


    Description of Approach


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


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


    The parameter keyboardin the constructor is dependency injection. This approach allows you to test the class Hackerby passing Keyboardmoki instead of the real instance . Isolation, 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 about the example above is an elegant trick with the method .buildin which the keyboard is initialized. In the DI discussions, I saw a lot of tips that suggested initializing dependencies into the calling code, such as controllers. Yeah, and then search the entire project for Hacker occurrences to see which particular class is used for the keyboard, well. Whether business .build: default usecase in a conspicuous place, it is not necessary to search for anything.


    Caller Code Testing


    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 (although this is hidden in the method ExternalService.build), which we try to avoid by introducing DI. How to cope with 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. Replace ExternalService.build. In fact, what DHH was talking about, but there is one important point: .buildwhen replacing , we do not change the behavior of the class instances, only the wrapper. RSpec example:


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

    3. Test controllers using integration tests on a CI server. Pros: production code is tested, increasing the likelihood that potential users will catch the potential bug, not users. Cons: it is more difficult to test exceptional situations ("a third-party service has crashed") and third-party services do not always have a sandbox account on which you can safely run tests.
    4. Use the same IoC containers.

    It seems to me that the combination of the second and third approaches is most effective: using 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.


    conclusions


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


    Links used in the post:



    Also popular now: