Test critical paths in your app with ease thanks to Dependency Injection

… and check why 5600+ Rails engineers read also this

Test critical paths in your app with ease thanks to Dependency Injection

Dependency Injection is one of my favorite programming patterns. In this short blogpost, I’ll present you how it helps testing potentially untestable code.

Imagine that your customer wants to easily identify orders in the e-commerce system which you are maintaining. They requested simple numeric identifier in a very specific 9-digit format which will make their life easier, especially when it comes to discussing order details with their client via the phone call. They want identifier starting with 100 and six random digits, e.g. 100123456.

Easy peasy you think, but you probably also know that the subset is limited to 999999 combinations and collisions may happen. You probably create a unique index on the database column, let’s call it order_number to prevent duplicates. However, instead of raising an error if the same number occurs again you want to make a retry.

Let’s start with a test for the best case scenario


RSpec.describe OrderNumberGenerator do
  specify do
    order = Order.create!

    OrderNumberGenerator.new.call(order.id)

    expect(order.reload.order_number).to be_between(100_000_001, 100_999_999)
  end
end

And the simple implementation:


class OrderNumberGenerator
  MAX_ATTEMPTS = 3

  def initialize
    @attempts = 0
  end

  def call(order_id)
    order = Order.find(order_id)
    order.order_number ||= random_number_generator.call
    order.save!
  rescue ActiveRecord::RecordNotUnique => doh
     @attemps += 1
     retry if @attemps < MAX_ATTEMPTS
     raise doh
  end

  private

  def random_number_generator
    rand(100_000_001..100_999_999)
  end
end

The code looks fine, but we’re not able to easily verify whether retry scenario works as intended. We could stub Ruby’s Kernel#rand but we want cleaner & more flexible solution, so let’s do a tiny refactoring.


class RandomNumberGenerator
  def call
    rand(100_000_001..100_999_999)
  end
end

class OrderNumberGenerator
  MAX_ATTEMPTS = 3

  def initialize(random_number_generator: RandomNumberGenerator.new)
    @attempts = 0
    @random_number_generator = random_number_generator
  end

  def call(order_id)
    order = Order.find(order_id)
    order.order_number ||= @random_number_generator.call
    order.save!
  rescue ActiveRecord::RecordNotUnique => doh
     @attemps += 1
     retry if @attemps < MAX_ATTEMPTS
     raise doh
  end
 end

Random number generator is no longer a private method, but a separate class RandomNumberGenerator. It’s injected to OrderNumberGenerator and the code still works as before. Instead of a default RandomNumberGenerator, for the testing purposes we pass simple lambda. Lambda pops elements from crafted array to cause intended unique index violation.


RSpec.describe OrderNumberGenerator do
  specify do
    order_1 = Order.create!
    order_2 = Order.create!

    numbers = [100_000_999, 100_000_001, 100_000_001, 100_000_001]
    order_number_generator = OrderNumberGenerator.new(random_number_generator: -> { numbers.pop })

    order_number_generator.call(order_1.id)

    expect { order_number_generator.call(order_2.id) }.not_to raise_error
  end

  specify do
    order_1 = Order.create!
    order_2 = Order.create!

    numbers = Array.new(4, 100_000_001)
    order_number_generator = OrderNumberGenerator.new(random_number_generator: -> { numbers.pop })

    order_number_generator.call(order_1.id)

    expect { order_number_generator.call(order_2.id) }.to raise_error(ActiveRecord::RecordNotUnique)
  end
end

Wrap up

As you can see, apart from being more confident about the critical code in our application due to having more test scenarios, we gained a lot of flexibility. Requirements related to order_number may change in the future. Injecting a different random_number_generator will do the job and core implementation of OrderNumberGenerator will remain untouched.

You might also like