Обсудим паттерн «конфигурирования модуля» а так же его характерные черты, приемы реализации и тест-кейсы (требования).

Для начала придумаем наш сферический модуль в ваккууме, какой делает что-нибудь полезное.

В учебном примере не важно, что модуль делает конкретно. Мы не будем писать его функционал, а применим прием (паттерн) конфигурирования модуля (или гема).

Он существует в несколько разных вариантах и реализациях, но это нормально для Ruby с его свободой самовыражения.

Назовем наш модуль MomentalPush, допустим он каким-то образом посылает сообщения пользователям в соцсети.

Конечно, хорошо бы иметь возможность его красиво настроить.

Допустим, у модуля есть настройки: в какие соцсети посылать сообщения, а так же какой email использовать для отправки сообщений по почте, помимо соцсетей.

Стандарт де-факто в конфигурировании гемов и отдельных модулей это применение блока.

Вот так:

MomentalPush.configure do |config|
  config.social_networks = [:twitter, :vk, :facebook] 
  config.sender_email = 'iamservice@gmail.com'
end

Безусловно, количество таких настроек зависит от размера и задач Вашего модуля. Он может требовать две-три, но может и гораздо больше, например 10 настроечных параметров.

Итак, за дело. Я буду использовать тест-инструмент Rspec.

Выработаем самые простые требования к модулю:

Он должен:

  1. Существовать вообще
  2. Иметь метод configure
  3. При присвоении настроечных переменных хранить их и при обращении к конфигурации — возвращать
  4. Хорошо бы иметь настройки по умолчанию если пользователь ввел их не все, или пропустил обязательные
  5. Если программист ошибся при настройке и ввел параметр какой в нашем модуле не предусмотрен, нужно чтобы он немедленно получил леща исключение. Это очень важный момент! Многие упускают его и их решения не гибки — можно вводить в блоке конфигурации какую угодно белиберду и она «молча срабатывает» что потом вызывает трудности при отладке, ловле ошибок если вместо name ошиблись и ввели настройку с названием names например.

Создадим тест на базе этих требований:

require_relative '../lib/momental_push'

describe MomentalPush do    
  describe '#configure' do  

    let(:networks) { MomentalPush.config.social_networks }            
    let(:sender_email) { MomentalPush.config.sender_email }

    shared_examples "a true params types" do           
      it { expect(networks).to be_a(Array) }
      it { expect(sender_email).to be_a(String) }
    end  

    context "when true set and get config vars" do
      before :each do
        MomentalPush.configure do |config|
          config.social_networks = [:twitter, :vk, :facebook]
          config.sender_email = 'iamservice@gmail.com'            
        end
      end      

      it_behaves_like "a true params types"

      it "must return preconfigured values" do                        
        expect(networks).to eq(%i{twitter vk facebook})        
        expect(sender_email).to eq('iamservice@gmail.com')
      end
    end        

    context "when not configured module" do        
      it "must return default values" do        
        MomentalPush.reset            
        expect(networks).to eq(%i{telegram})
      end
      it_behaves_like "a true params types"                      
    end

    context "when user get unknown attr" do
      it "must be exception" do
        MomentalPush.reset
        expect { MomentalPush.configure { |config| config.my_unknown_param = "yahoo!" } }.to raise_error(NoMethodError)
      end
    end    
  end  
end

Тест конечно можно еще отрефакторить, нет предела совершенству. Но он достаточно неплох.

Обратите внимание — тестируются типы данных с помощью shared_examples, устранено повторение кода путем общих let-деклараций. Для удобства тестирования (это не обязательно!) введена возможность в решение ресета конфигурации MomentalPush.reset Так же, проверяется случай когда программист вводит какую-нибудь неизвестную модулю переменную.

А теперь код самого решения, собственно сам паттерн «конфигурация модуля»:

module MomentalPush
  class << self
    attr_accessor :config
  end

  def self.configure
    self.config ||= Config.new
    yield(config)
  end

  def self.reset
    self.config = Config.new
  end

  class Config
    attr_accessor :social_networks, :sender_email

    def initialize
      @social_networks = [:telegram]
      @sender_email = 'noreply@myservice.com'
    end
  end
end

Что тут нужно пояснить? Во-первых трюк с class << self просто сделан из-за того что модуль не может иметь наследников, но может быть «сам по себе» синглетом. То есть к модулю можно обращаться. Но если бы мы объявили аксесор без раскрытия модуля «на себя» как синглета, то аксесор имели бы классы и обьекты какие примешиваются к модулю, а не он сам статично. По этому используется трюк «раскрытие синглета на себя».

Далее — статик методы конфигурации лениво возвращают конфигурацию (экземпляр встроенного в модуль класса Config к какому с помощью трюка дан доступ извне для обращений) и вызывают yield выброс назад конфигурации в контекст блока конфигурирования.

Так как класс Config имеет четко известные поля и их значения по умолчанию, то модуль соответственно выдает как и нужно в тестовых случаях, NoMethodError исключение при неизвестном параметре так как неизвестный параметр «за кулисами» это обращение к несуществующему методу класса Config. Что нам и нужно.

Есть и альтернативные способы реализации, например без трюка на раскрытие:

module MyModule
  DefaultConfig = Struct.new(:name, :per_page) do
    def initialize
      self.name = 'test'
      self.per_page = 10      
    end
  end

  def self.configure
    @config = DefaultConfig.new
    yield(@config) if block_given?
    @config
  end

  def self.config
    @config || configure
  end
end

Хоть код более читаем и понятен, а вместо аксесора и трюка используется статик переменная модуля, мне больше нравится первый вариант. Хотя это на усмотрение разработчика!

Ruby создан для самовыражения, по этому даже паттерн или прием могут реализоваться по разному.