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).
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?
-
If we were debugging
create_order
, we would need to hunt down every instance ofInjectPayments.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. -
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. -
If it turns out that every single time we use
InjectPayments
we are passing inPayments.new
, well then what is the benefit of this indirection? Ourcreate_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, butInjectPayments
still has the exact same dependencies as if we were usingPayments
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.
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.