Verbalize

Build Status Coverage Status

Usage

class Add
  include Verbalize::Action

  input :a, :b

  def call
    a + b
  end
end

result = Add.call(a: 35, b: 7)
result.outcome # => :ok
result.value # => 42
result.succeeded? # => true
result.success? # alias for succeeded? => true
result.failed? # => false
result.failure? # alias for failed? => false

outcome, value = Add.call(a: 35, b: 7)
outcome # => :ok
value # => 42
class Add
  include Verbalize::Action

  input optional: [:a, :b]

  def call
    a + b
  end

  private

  def a
    @a ||= 35
  end

  def b
    @b ||= 7
  end
end

Add.call # => [:ok, 42]
Add.call(a: 660, b: 6) # => [:ok, 666]

Default Values

# You can define defaults via key/value pairs, as so:
class Add
  include Verbalize::Action
  # note that these values are evaluated at load-time as they are not wrapped
  # in lambdas.
  input optional: [a: 35, b: 7]
  def call; a + b; end
end

# default values can be lazily loaded by passing in a lambda, e.g.:

class Tomorrow
  include Verbalize::Action
  input optional: [as_of: -> { Time.now }]
  def call
    as_of + 1
  end
end

start_time = Tomorrow.call!
sleep(1)
end_time = Tomorrow.call!
end_time - start_time # ~1s; the default is executed each call.
class Divide
  include Verbalize::Action

  input :a, :b

  def call
    fail! 'You can’t divide by 0' if b == 0
    a / b
  end
end

result = Divide.call(a: 1, b: 0) # => [:error, 'You can’t divide by 0']
result.failed? # => true

Reflection

class Add
  include Verbalize::Action

  input :a, :b, optional: [:c, :d]

  def call
    a + b + c + d
  end

  private

  def c
    @c ||= 0
  end

  def d
    @d ||= 0
  end
end

Add.required_inputs # [:a, :b]
Add.optional_inputs # [:c, :d]
Add.inputs # [:a, :b, :c, :d]

Validation

class FloatAdd
  include Verbalize::Action

  input :a, :b
  validate(:a) { |a| a.is_a?(Float) }
  validate(:b) { |b| b.is_a?(Float) && b > 10.0 }

  def call
    a + b
  end
end

FloatAdd.call!(a: 1, b: 1) # fails with  Input "a" failed validation!
FloatAdd.call!(a: 1.0, b: 1.0) # fails with Input "b" failed validation!
FloatAdd.call!(a: 1.0, b: 12.0) # 13.0

Comparison/Benchmark

require 'verbalize'
require 'actionizer'
require 'interactor'
require 'benchmark/ips'

class RubyAdd
  def self.call(a:, b:)
    new(a: a, b: b).call
  end

  def initialize(a:, b:)
    @a = a
    @b = b
  end

  def call
    a + b
  end

  private

  attr_reader :a, :b
end

class VerbalizeAdd
  include Verbalize::Action

  input :a, :b

  def call
    a + b
  end
end

class ActionizerAdd
  include Actionizer

  def call
    output.sum = input.a + input.b
  end
end

class InteractorAdd
  include Interactor

  def call
    context.sum = context.a + context.b
  end
end

Benchmark.ips do |x|
  x.report('Ruby')       { RubyAdd.call(a: 1, b: 2) }
  x.report('Verbalize')  { VerbalizeAdd.call(a: 1, b: 2) }
  x.report('Actionizer') { ActionizerAdd.call(a: 1, b: 2) }
  x.report('Interactor') { InteractorAdd.call(a: 1, b: 2) }
  x.compare!
end
Warming up --------------------------------------
                Ruby    63.091k i/100ms
           Verbalize    40.521k i/100ms
          Actionizer     5.226k i/100ms
          Interactor     4.874k i/100ms
Calculating -------------------------------------
                Ruby    751.604k (± 3.0%) i/s -      3.785M in   5.041472s
           Verbalize    457.598k (± 6.1%) i/s -      2.310M in   5.072488s
          Actionizer     54.874k (± 3.5%) i/s -    276.978k in   5.054541s
          Interactor     52.294k (± 3.2%) i/s -    263.196k in   5.038365s

Comparison:
                Ruby:   751604.0 i/s
           Verbalize:   457597.9 i/s - 1.64x  slower
          Actionizer:    54873.6 i/s - 13.70x  slower
          Interactor:    52293.6 i/s - 14.37x  slower

Testing

Happy Path

When testing positive cases of a Verbalize::Action, it is recommended to test using the call! class method and assert on the result. This implicitly ensures a successful result, and your tests will fail with a bang! if something goes wrong:

class MyAction
  include Verbalize::Action

  input :a

  def call
    fail!('#{a} is greater than than 100!') if a >= 100
    a + 1
  end
end

it 'returns the expected result' do
  result = MyAction.call!(a: 50)
  expect(result).to eq 51
end

Sad Path

When testing negative cases of a Verbalize::Action, it is recommended to test using the call non-bang class method which will return a Verbalize::Failure on failure.

Use of call! here is not advised as it will result in an exception being thrown. Set assertions on both the failure outcome and value:

class MyAction
  include Verbalize::Action

  input :a

  def call
    fail!('#{a} is greater than 100!') if a >= 100
    a + 1
  end
end

# rspec:
it 'fails when the input is out of bounds' do
  result = MyAction.call(a: 1000)

  expect(result).to be_failed
  expect(result.failure).to eq '1000 is greater than 100!'
end

Stubbing Responses

When unit testing, it may be necessary to stub the responses of Verbalize actions. To correctly stub responses, you should always stub the MyAction.perform class method on the action class being stubbed per the instructions below. Never stub the call or call! methods directly.

Stubbing .perform will enable Verbalize to wrap results correctly for references to either call or call!.

Stubbing Successful Responses

To simulate a successful response of the Verbalize::Action being stubbed, you should stub the MyAction.perform class method to return the value you expect the MyAction#call instance method to return.

For example, if you expect the action to return the value 123 on success:

class Foo
  def self.multiply_by(multiple)
    result = MyAction.call(a: 1)
    raise "I couldn't do the thing!" if result.failure?

    result.value * multiple
  end
end

# rspec:
describe Foo do
  describe '#something' do
    it 'does the thing when MyAction succeeds' do
      # Simulate the successful result
      allow(MyAction).to receive(:perform)
        .with(a: 1)
        .and_return(123)

      result = described_class.multiply_by(100)

      expect(result).to eq 12300
    end
  end
end

Stubbing Failure Responses

To simulate a failure response of the Verbalize::Action being stubbed, you should stub the MyAction.perform class method to throw ::Verbalize::THROWN_SYMBOL with the message you expect MyAction#call to throw when the simulated failure occurs.

For example, when you expect the outer class to raise an exception when MyAction fails:

# See also: Foo class definition in Stubbing Successful Responses above

# rspec:
describe Foo do
  describe '#multiply_by' do
    it 'raises an error when MyAction fails' do
      # Simulate the failure
      allow(MyAction).to receive(:perform)
        .with(a: 1)
        .and_throw(::Verbalize::THROWN_SYMBOL, 'Y U NO!')

      expect {
        described_class.multiply_by(100)
      }.to raise_error "I couldn't do the thing!"
    end
  end
end

Installation

Add this line to your application's Gemfile:

gem 'verbalize'

And then execute:

$ bundle

Or install it yourself as:

$ gem install verbalize

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

Releasing

  • Update CHANGELOG.md, and lib/verbalize/version.rb, commit, and push
  • Create a new release on github
  • Build the gem version: gem build verbalize.gemspec
  • Push the gem version to rubygems: gem push verbalize-<VERSION>.gem

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/taylorzr/verbalize.

License

The gem is available as open source under the terms of the MIT License.