Restraining Oneself From Dependency Hell

The advice to Kill Your Dependencies has many merits. Transitive dependencies which contain pessimistic version constraints for their own dependencies are a major headache. These constraints block you from updating shared dependencies when you want to — and become a nightmare when you have to update to receive a critical security patch.

Despite the common due diligence checklist for selecting dependencies (Is it still receiving updates? Are PRs merged? Are there tests / CI?) I think it’s fair to say most developers don’t routinely check transitive dependencies with the same rigour.

Overall dependency management is a tricky beast. It’s not ideal to reinvent the wheel, yet it’s shortsighted to include huge or poorly maintained dependencies which you may only use a fraction of.

Practising What You Preach

Recently I’ve been working on a project which has benefitted greatly from parameterized testing (a.k.a table tests) to concisely verify business logic with a variety of inputs.

The Original Incarnation

Originally these tests were implemented with a humble array of arrays and #each like so:

1
2
3
4
5
6
7
8
9
10
11
12
RSpec.describe "my complex business logic" do
  [
    ["2019-06-05", "weekly",    "2019-06-13"],
    ["2019-06-05", "bi-weekly", "2019-06-20"],
    ["2019-06-05", "monthly",   "2019-07-13"],
  ].each do |today, schedule, expected_date|
    it "determines the next appointment date" do
      result = NextAppointmentCalculator.new.process(today, schedule)
      expect(result).to eq expected_date
    end
  end
end

This approach is simple and introduces no extra dependencies, but the readability isn’t optimal; the parameter names are after the data - so you have to read the lines in an unnatural order.

Fancy. But no.

I found a library called RSpec::Parameterized. The resulting test reads well:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
RSpec.describe "my complex business logic" do
  where(:today, :schedule, :expected_date) do
    [
      ["2019-06-05", "weekly",    "2019-06-13"],
      ["2019-06-05", "bi-weekly", "2019-06-20"],
      ["2019-06-05", "monthly",   "2019-07-13"],
    ]
  end

  with_them do
    it "determines the next appointment date" do
      result = NextAppointmentCalculator.new.process(today, schedule)
      expect(result).to eq expected_date
    end
  end
end

Now the parameter names precede the values. This gem even includes a somewhat terrifying table-style syntax:

1
2
3
4
5
6
7
using RSpec::Parameterized::TableSyntax

where(:today, :schedule, :expected_date) do
  "2019-06-05" | "weekly"    | "2019-06-13"
  "2019-06-05" | "bi-weekly" | "2019-06-20"
  "2019-06-05" | "monthly"   | "2019-07-13"
end

But at what cost? Well, its dependencies include:

1
2
3
4
5
6
rspec-parameterized (0.4.2)
  binding_ninja (>= 0.2.3)
  parser
  proc_to_ast
  rspec (>= 2.13, < 4)
  unparser

And those new dependencies require:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
parser (2.6.3.0)
  ast (~> 2.4.0)
proc_to_ast (0.1.0)
  coderay
  parser
  unparser
unparser (0.4.5)
  abstract_type (~> 0.0.7)
  adamantium (~> 0.2.0)
  concord (~> 0.1.5)
  diff-lcs (~> 1.3)
  equalizer (~> 0.0.9)
  parser (~> 2.6.3)
  procto (~> 0.0.2)

Run for the hills.

Happy medium?

I ended up creating a micro-DSL and releasing it as a gem called RSpec::WithParams. It allows you to write tests like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
RSpec.describe "my complex business logic" do
  extend RSpec::WithParams::DSL

  with_params(
    [:today,        :schedule,    :expected_date],
    ["2019-06-05",  "weekly",     "2019-06-13"],
    ["2019-06-05",  "bi-weekly",  "2019-06-20"],
    ["2019-06-05",  "monthly",    "2019-07-13"],
  ) do
    it "determines the next appointment date" do
      result = NextAppointmentCalculator.new.process(today, schedule)
      expect(result).to eq expected_date
    end
  end
end

This solution fixes my main bugbear with the plain array of arrays approach - I’d like the parameter names above the values - without introducing a ton of dependencies.

Perhaps this is still the wrong option. It’s an extra dependency. The answer could be to stick with the first option and use plain Ruby. Alternatively, you may want to avoid the single extra dependency and keep the readability benefit by vendoring the helper function - it’s only ~14 lines of code.