You don't need dependency injection
In statically typed OOP languages, dependency injection is necessary to achieve testability in many cases. In Ruby, however, the language's dynamic nature and open classes allow us to achieve the same testability without introducing the indirection of dependency injection.
Consider an Order that emails a confirmation through a Mailer:
class Mailer
def self.send(to:, subject:, body:)
# ... actually delivers email ...
end
end
class Order
attr_reader :id, :email
def initialize(id, email)
@id = id
@email = email
end
def process
Mailer.send(
to: email,
subject: "Order #{id} confirmed",
body: "Thanks for your order!",
)
:ok
end
end
interface Mailer {
void send(String to, String subject, String body);
}
class SmtpMailer implements Mailer {
public void send(String to, String subject, String body) {
// ... actually delivers email ...
}
}
class Order {
private final int id;
private final String email;
private final Mailer mailer;
Order(int id, String email, Mailer mailer) {
this.id = id;
this.email = email;
this.mailer = mailer;
}
String process() {
mailer.send(
email,
"Order " + id + " confirmed",
"Thanks for your order!"
);
return "ok";
}
}
Note how the Java version requires a Mailer interface, a concrete SmtpMailer, and an Order constructor that accepts a Mailer. In Ruby we just call out to Mailer directly.
To test the Ruby version without actually sending email, we replace Mailer.send for the duration of the test rather than fiddle with dependency injection:
require "minitest/autorun"
class OrderTest < Minitest::Test
def test_process_sends_confirmation
captured = nil
original = Mailer.method(:send)
Mailer.define_singleton_method(:send) do |**args|
captured = args
:delivered
end
order = Order.new(42, "[email protected]")
assert_equal :ok, order.process
assert_equal "[email protected]", captured[:to]
assert_equal "Order 42 confirmed", captured[:subject]
ensure
Mailer.singleton_class.define_method(:send, original) if original
end
end
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class OrderTest {
static class FakeMailer implements Mailer {
String to, subject, body;
public void send(String to, String subject, String body) {
this.to = to;
this.subject = subject;
this.body = body;
}
}
@Test
void processSendsConfirmation() {
FakeMailer fake = new FakeMailer();
Order order = new Order(42, "[email protected]", fake);
assertEquals("ok", order.process());
assertEquals("[email protected]", fake.to);
assertEquals("Order 42 confirmed", fake.subject);
}
}
In real Ruby projects we would be using minitest-mock or a similar library to handle the stubbing and expectations more cleanly, but in this example I wanted to show the underlying mechanics of stubbing in raw Ruby.
Note: There may still be cases when dependency injection makes sense, such as when you genuinely have a need for a "plug and play" kind of architecture. But you don't have to complicate your application code just for the sake of testability!
History
The ability to redefine class methods at runtime has been a core feature of Ruby since its inception.
Object#define_singleton_method was added in Ruby 1.9.2 (2010) as a cleaner alternative to the older class << obj; def ...; end; end idiom.