Upgrade to Pro — share decks privately, control downloads, hide ads and more …

[Saint P Ruby Conf, August 2017] GemCheck: Writing Better Ruby Gems

[Saint P Ruby Conf, August 2017] GemCheck: Writing Better Ruby Gems

Vladimir Dementyev

August 19, 2017
Tweet

More Decks by Vladimir Dementyev

Other Decks in Programming

Transcript

  1. Simple is… • Less code • Less thinking when writing

    code • Less thinking when reading code
  2. Simple is… • Less code • Less thinking when writing

    code • Less thinking when reading code
  3. Boilerplate uri = URI('http: //example.com/index.json') params = { limit: 10,

    page: 3 } uri.query = URI.encode_ www_form(params) req = Net ::HTTP ::Get.new uri req.basic_auth 'user', 'pass' res = Net ::HTTP.start(uri.host, uri.port) do |http| http.request(req) end if res.is_a?(Net ::HTTPSuccess) puts JSON.parse(res.body) end
  4. Complex Possible HTTParty.get( 'http: //example.com/index.json', { limit: 10, page: 3

    }, basic_auth: { ...}, headers: { ...}, open_timeout: 2, read_timeout: 3 )
  5. Sensible Defaults class Post < ActiveRecord ::Base # in the

    world with no sensible defaults self.table_name = "posts" belongs_to :user, foreign_key: :user_id, class_name: "User", primary_key: :id end
  6. Sensible Config module Web class Application < Hanami ::Application configure

    do root __dir __ routes 'config/routes' end end end “Who ever changed root? Who ever placed routes under another path? We're creating noise for the 99% of the developers who don't need these settings.” –Luca Gaudi https://discourse.hanamirb.org/t/hanami-2-0-ideas/306
  7. Sensible Config • REDIS_URL • Sidekiq • Resque def determine_redis_provider

    ENV[ENV['REDIS_PROVIDER'] || 'REDIS_URL'] end self.redis = Redis.respond_to?(:connect) ? Redis.connect : "localhost:6379" # => No env support(
  8. Sensible Config • DATABASE_URL • ActiveRecord • SequelRails (but not

    Sequel) def establish_connection( spec = ENV["DATABASE_URL"]) … end url = ENV['DATABASE_URL'] config['url'] ||= url if url
  9. What We Learned Reduce boilerplate as much as possible Do

    not sacrifice flexibility Use sensible defaults
  10. Simple is… • Less code • Less thinking when writing

    code • Less thinking when reading code
  11. Least Surprise # Load entity through API Amorail ::Lead.find ANY_NONEXISTENT_ID

    => false # why false? we're looking for an object, # nil makes more sense when nothing is found https://github.com/teachbase/amorail/issues/25
  12. Case: Dry-Types require 'dry-struct' module Types include Dry ::Types.module end

    class User < Dry ::Struct attribute :name, Types ::String.optional attribute :age, Types ::Coercible ::Int end
  13. require 'dry-struct' class User < Dry ::Struct attribute :name, Dry

    ::Types ::String.optional attribute :age, Dry ::Types ::Coercible ::Int end # => NameError: uninitialized constant Dry ::Types ::String Case: Dry-Types
  14. require 'dry-struct' class User < Dry ::Struct attribute :name, Dry

    ::Types["string"].optional attribute :age, Dry ::Types["coercible.int"] end Case: Dry-Types
  15. require 'dry-struct' class User < Dry ::Struct attribute :name, :string,

    optional: true attribute :age, :int, coercible: true end Case: Dry-Types
  16. Meaningful Errors def create_applicant api.applicant.create(params) rescue Onfido ::RequestError => e

    # What kind of error it is? # authentication, validation, whatever? end
  17. Meaningful Errors def create_applicant api.applicant.create(params) rescue Onfido ::RequestError => e

    # What kind of error it is? # authentication, validation, whatever? end
  18. Meaningful Errors def resolve_error(response) if response =~ /Couldn\'t find series/

    raise InfluxDB ::SeriesNotFound, response end raise InfluxDB ::Error, response end
  19. Meaningful Errors # BAD def update_as(type:, **params) raise ArgumentError, "Unknown

    type" unless TYPES.include?(type) ... end # GOOD def update_as(type:, **params) raise ArgumentError,"Unknown type: #{type}" unless TYPES.include?(type) ... end
  20. Dangerous API class MyTest < Minitest ::Test # Encourage users

    to run tests randomly i_suck_and_my_tests_are_order_dependent! end
  21. Warnings if defined?( ::Rails) && !Rails.env.test? puts("******************************") puts("WARNING: Sidekiq testing

    API enabled, but this is not the test environment. Your jobs will not go to Redis.") puts("******************************") end
  22. Refinements class Anyway ::Config using Anyway ::Ext ::DeepDup using Anyway

    ::Ext ::Hash def self.attr_config(*args, **hargs) @defaults = hargs.deep_dup defaults.stringify_keys! @config_attributes = args + defaults.keys attr_accessor(*@config_attributes) end end https://github.com/palkan/anyway_config
  23. Refinements module Anyway ::HashExt refine Hash do def stringify_keys! keys.each

    do |key| val = delete(key) val.stringify_keys! if val.is_a?(Hash) self[key.to_s] = val end end end end https://github.com/palkan/anyway_config
  24. What We Learned Follow the principle of least surprise Provide

    meaningful errors and logging Monkey-patch responsibly
  25. Adapterization # config/application.rb module YourApp class Application < Rails ::Application

    config.active_job.queue_adapter = :sidekiq end end • Active Job
  26. • Action Cable But no server adapters Adapterization # config/cable.yml

    development: adapter: async # config/application.rb config.action_cable.server_adapter = :any_cable Not yet possible( https://github.com/rails/rails/pull/27648
  27. Adapterization # config/environments/development.rb config.sms_backend = SmsSender ::FileBackend.new # config/environments/test.rb config.sms_backend

    = SmsSender ::SimpleBackend.new # config/environments/production.rb config.sms_backend = SmsSender ::TwilioBackend.new
  28. Adapterization # config/environments/development.rb config.sms_backend = SmsSender ::FileBackend.new # config/environments/test.rb config.sms_backend

    = SmsSender ::SimpleBackend.new # config/environments/production.rb config.sms_backend = SmsSender ::TwilioBackend.new Poor Man's DI
  29. Extensibility # config.ru require './my_app' use Rack ::Debug run MyApp.new

    • Rack Shrine.plugin :sequel Shrine.plugin :cached_attachment_data • Shrine
  30. • Custom matchers/assertions Testability # Pundit it "grants access if

    post is published" do expect(subject).to permit(admin, post) end # ActiveModelSerializers test "should render post serializer" do get :index assert_serializer "PostSerializer" end
  31. • Test helpers / mocking Testability # Devise test "should

    be success" do sign_in user get :index assert_equal 200, response.status end # Fog Fog.mock!
  32. • Test adapters Testability # ActiveJob config.active_job.queue_adapter = :test #

    ActionMailer config.action_mailer.delivery_method = :test
  33. • Test mode/configuration Testability CarrierWave.configure do |config| config.enable_processing = false

    end Devise.setup do |config| config.stretches = Rails.env.test? ? 1 : 11 end Sidekiq ::Testing.fake!
  34. Case: EvilClient let(:client) { double('client') } let(:candidates) do double('candidates').tap do

    |stub| allow(client).to receive(:candidates).and_return(stub) end end before do expect(integration).to receive(:client).and_return(client) end it "creates checkr candidate" do expect(candidates).to receive(:create).with( a_hash_including(params).and_return(id: 'checkr-123') expect(subject.checkr_id).to eq 'checkr-123' end Before v1.1.0
  35. Case: EvilClient before do stub_client_operation(CheckrClient, "candidates.create") .with(token: "foo", **params) .to_return(id:

    'checkr-123') end it "creates checkr candidate" do expect(subject.checkr_id).to eq 'checkr-123' end Since v1.1.0
  36. Case: Honeybadger it "notifies HB" do expect do subject Honeybadger.flush

    # !!! end.to change { Honeybadger ::Backend ::Test .notifications[:notices].size }.by(1) expect( Honeybadger ::Backend ::Test .notifications[:notices] .first.error_message ).to eq("Error") end
  37. Case: Honeybadger # Why not? it "notifies HB" do expect

    { subject } .to notify_honeybadger.with( error_message: "Error" ) end
  38. Case: Wrapper module Resolver class << self def resolve(host) return

    "1.2.3.4" if @test == true Resolv.getaddress(host) end def enable_test! @test = true end end end
  39. CI matrix: include: - rvm: ruby-head gemfile: gemfiles/railsmaster.gemfile - rvm:

    2.4.1 gemfile: gemfiles/rails5.gemfile - rvm: 2.2.3 gemfile: gemfiles/rails42.gemfile allow_failures: - rvm: ruby-head gemfile: gemfiles/railsmaster.gemfile