Trouble testing the result of an operation

After I got my sample working with the help of A hanami slice in a Rails app - #8 by cllns I ran into a testing issue, which makes me wonder whether I even understood the basic of how you’d use / test the result of an operation:

rm -rf hanami_sample2

hanami new hanami_sample2
cd hanami_sample2

mkdir -p lib/hanami_sample2/predictor

bundle exec hanami generate operation predictor.create_prediction
echo '
module HanamiSample2
  module Predictor
    class CreatePrediction < HanamiSample2::Operation
      def call(input)
        if input_valid?(input)
          Success("Prediction created")
        else
          Failure("Invalid input")
        end
      end

      private

      def input_valid?(input)
        !input.nil? && !input.empty?
      end
    end
  end
end
' > app/predictor/create_prediction.rb

mkdir -p spec/predictor

echo '
require "spec_helper"

RSpec.describe HanamiSample2::Predictor::CreatePrediction do
  let(:operation) { described_class.new }

  describe "#call" do
    context "when input is valid" do
      it "returns a success result" do
        result = operation.call("valid input")

        # expect(result).to be_a Success
        expect(result).to be_success
        expect(result.value!).to eq("Prediction created")
      end
    end

    context "when input is invalid" do
      it "returns a failure result" do
        result = operation.call(nil)

        # expect(result).to be_a Failure
        expect(result).to be_failure
        expect(result.failure).to eq("Invalid input")
      end
    end
  end
end
' > spec/predictor/create_prediction_spec.rb

bundle exec rspec

Given the spec support file for operations I was expecting to be able to use Success and Failure, but those names are not defined. Comment those two lines in to see that failure.

With the code as written above, I get:

  1) HanamiSample2::Predictor::CreatePrediction#call when input is invalid returns a failure result
     Failure/Error: expect(result).to be_failure
       expected `Success(Failure("Invalid input")).failure?` to be truthy, got false
     # ./spec/predictor/create_prediction_spec.rb:23:in `block (4 levels) in <top (required)>'

  2) HanamiSample2::Predictor::CreatePrediction#call when input is valid returns a success result
     Failure/Error: expect(result.value!).to eq("Prediction created")

       expected: "Prediction created"
            got: Success("Prediction created")

       (compared using ==)

       Diff:
       @@ -1 +1 @@
       -"Prediction created"
       +Success("Prediction created")

     # ./spec/predictor/create_prediction_spec.rb:14:in `block (4 levels) in <top (required)>'

dry-rb - dry-monads v1.6 - Result says that calling value! will unpack the result. That seems to not happen here.

The failure doesn’t seem to be correctly recognized as a failure. Which you can also see if you comment back in lines 13 and 23.

Any help in understanding what is going on is much appreciated!

I haven’t had a ton of time to look into it, but I’m stumped why config.include Dry::Monads[:result] in the operations spec support file isn’t working. Even directly including include Dry::Monads[:result] directly in the spec isn’t working :thinking:

As for the other issue, you need to put step in front of the Success and Failure calls. This isn’t at all obvious though and we should make the ergonomics better. We should at least improve the documentation to talk about this gotcha. It’s quite weird to add step directly in front of Success and Failure constructors, so I’d re-work it to something like this:

module HanamiSample2
  module Predictor
    class CreatePrediction < HanamiSample2::Operation
      def call(input)
        step validate_input(input)
      end

      private

      def validate_input(input)
        if !input.nil? && !input.empty?
          Success("Prediction created")
        else
          Failure("Invalid input")
        end
      end
    end
  end
end

By not adding step, it’s wrapping your result in Success. Instead of that, I think I’d prefer seeing a helpful error telling the user about step conventions. Additionally, this could be fixed at a lower level, by preventing all nested monads, e.g. Success(Failure(...)). It’s hard to reason about monads in a dynamic type system, so I feel like this is a reasonable trade-off to make it simpler for users.

I raised this suggestion previously and now we have a concrete use-case of it being confusing for someone else: https://discourse.hanamirb.org/t/extract-dry-result-with-guardrails/985

Thank you, @cllns! This allows me to fully implement my bigger example… ah, not quite. I will, once again switch to a new topic, Rails and hanami-view don't get along, to discuss a different issue.

Ah so this is as simple as it is tricky. using include Dry::Monads[:result] gives you access to the constructor methods Success(...) and Failure(...) but not the class constants themselves (Success, and Failure). I’ve opened an issue in dry-monads since this was surprising behavior: Include Dry::Monads[...] doesn't give access to class constants · Issue #182 · dry-rb/dry-monads · GitHub

1 Like

I’ve been dealing with this annoyance for a long time. Here’s the thing: it does give you access to those class constants, but RSpec is a special case because everything is written in blocks, and so the scope of constant lookup is actually the global scope, and not the ExampleGroup class.

I’m not certain that adding these constants to the global scope is desirable. Some ways I work around it: M alias for Dry::Monads so I can ref M::Success etc, and success_array and failure_array helpers.

1 Like

Ahhh yes, I see. That makes sense @alassek, thanks for the insight.

Wouldn’t this work as well as our operation spec support file, to get the short-hand Success and Failure constants only for specs? So we don’t pollute the global namespace.

# frozen_string_literal: true

require "dry/monads"

RSpec.configure do |config|
  # Provide `Success` and `Failure` for testing operation results
  config.include Dry::Monads[:result]

  config.before(:all) do
    Success = Dry::Monads::Success
    Failure = Dry::Monads::Failure
  end
end

(Dry::Monads::Success is an alias for Dry::Monads::Result::Success)

Here’s a reason why I don’t like adding these to the global scope: if you are referencing one of them in a place where normally those constants wouldn’t exist, you won’t see this bug in your test suite, only in production.

I think if you’re going to do this, it needs to go all the way. You need to define these globals for the whole app in production as well.

I don’t think it’s possible to have it both ways. This is a consequence of RSpec’s DSL being built around closures; something like Minitest wouldn’t have this problem.

Ah of course. I was thinking it would just define it within RSpec’s context, but it does pollute the global namespace for app code too, and I agree that’s a bad idea. I once got bit by something similar (using FactoryBot, which was adding a monkey patch only in test, but not production).

@flash-gordon has an idea to use const_missing to get this behavior in spec files: Include Dry::Monads[...] doesn't give access to class constants · Issue #182 · dry-rb/dry-monads · GitHub

That const_missing trick is interesting. I’ll have to experiment with it.