Skip to content

Instantly share code, notes, and snippets.

@davetron5000
Last active January 5, 2023 21:49
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save davetron5000/9997e9d90c9076a8f8c528303ab5e502 to your computer and use it in GitHub Desktop.
Save davetron5000/9997e9d90c9076a8f8c528303ab5e502 to your computer and use it in GitHub Desktop.

Let us create a class that relies on the time:

class MockTime
  def create_order(customer, product)
    order = Order.create!(customer: customer, product: product)
    Payments.new.charge(customer.payment_method, order.total)
  end
end

Suppose that the creation time of the order is important, so we test it

it "creates an order" do
  customer = create(:customer)
  product  = create(:product)

  # handwaving over Payments for now since we are just focused on time
  MockTime.new.create_order(customer,product)
  order = customer.orders.first

  # IME you almost never care about the precise time, just that
  # it happened "now"
  expect(order.created_at).to be_within(10.seconds).of(Time.zone.now)
end

This works great in most cases, because you rarely care about the precise time, you just want it be "about now"

However, if you did care about the precise time, you would either use TimeCop (which creates all sorts of problems, just like real time travel does), or you make time injectible:

  class InjectTime
   def initialize(time)
     @time = time
   end

    def create_order(customer, product)
      order = Order.create!(customer: customer
                            product: product,
                            created_at: Time.zone.now)
      Payments.new.charge(customer.payment_method, order.total)
    end
  end

To test it, we create a mock time and pass that to the constructor:

  it "creates an order" do
    customer = create(:customer)
    product  = create(:product)

   now = Time.zone.now
   time = double("time", zone: double("zone", now: now))
   InjectTime.new(time).create_order(customer,product)
    order = customer.orders.first

   expect(order.created_at).to eq(now)
  end

This approach has a problem, because the caller must pass in the Time class. You could make it less annoying by passing in the actual time to create_order, but you still end up requiring the caller to pass in the time, when they just want it to be figured out for them.

So, you default the value of the injected dependency:

  class InjectTimeWithDefault
   def initialize(time=Time)
      @time = time
    end

    def create_order(customer, product)
      order = Order.create!(customer: customer
                            product: product,
                            created_at: Time.zone.now)
      Payments.new.charge(customer.payment_method, order.total)
    end

  end

Now, you have a bigger problem, because the behavior of this class in production—making use of the default value of time—is never tested. Meaning, a change like so will not fail a test:

  class InjectTimeConstructorWithDefault
   def initialize(time=nil)
      @time = Time
    end
  end

Oops!

So, what do you do? You could add a test that Time is used as a default, and what have you accomplished? You've now made two tests: one for the actual behavior and one that uses injected behavior (which is technically useless in light of the first test).

OK, time is a special case, what about Payments

Same argument. Let's inject Payments.new:

  class InjectPayments
   def initialize(payments)
     @payments = payments
   end

    def create_order(customer, product)
      order = Order.create!(customer: customer
                            product: product)
     @payments.charge(customer.payment_method, order.total)
    end

  end
  
  # to use, e.g.
  obj = InjectPayments.new(Payments.new)

Although create_order now has fewer depdendencies (namely, Payments), what can we say about this code?

  1. If we were debugging create_order, we would need to hunt down every instance of InjectPayments.new to figure out what the actual value of @payments was. In a large system, this can be difficult, or even impossible without deploying some telemetry.

  2. Because Ruby is dynamically typed, it's extremely hard to figure out what to pass in without some documentation. You literally cannot look at the code to find a list of candidate classes and would have to scan for all uses of InjectPayments.new. I have done this and it sucks.

  3. If it turns out that every single time we use InjectPayments we are passing in Payments.new, well then what is the benefit of this indirection? Our create_order routine no longer reflects the reality of the system. InjectPayments has a feature that it did not need to have that is making things harder, not easier.

    Yes, create_order's code has fewer dependencies, but InjectPayments still has the exact same dependencies as if we were using Payments directly. The code just doesn't make that clear. Why would you want that?

    To inject an instance of Payments into our original routine is easy - there are many mocking libraries to say "when Payments.new is called, return this: xxx.

So why even propose it?

Oh Java. In Java, you cannot proxy calls to new, so if you had this:

public class InjectPayments {
  public createOrder(customer, product) {
    Order order = Order.create(customer, product)
    Payments payments = new Payments()
    payments.charge(customer.getPaymentMethod(), product.getTotal())
  }
}

As far as I know, you cannot change what happens when new Payments() is called, and in the early 2000's, you definitely could not. So you must inject. And that is reasonable in Java. Not in Ruby.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment