Speedy TDD with Rails (the wrong way)

Here are the slides for a presentation I gave at the Sheffield Ruby User Group for ShRUG 26: Speedy TDD with Rails.

To summarise: this is a report of my efforts to increase the TDD feedback speed using Rails (3.1 at the time). It is based on work at a client, in a team of 3. All the work was done within the constraint that I couldn’t make major architectural changes as I was not going to be a long-term maintainer of the code. I was also the only member of the team applying TDD, so all changes had to be made in an unobtrusive way. This is why the presentation is subtitled the wrong way: the best I achieved within these constraints was a set of hacks to make TDD in Rails bearable, if not enjoyable.

I go into a little more detail below.

The death of the feedback loop

TDD in Rails has recently become hampered by the fact that between Rails 2 and 3, the boot time for Rails went up to 10 or 20s on many development machines. This means that a naive TDD cycle of [write test / run failing test / change code / run passing test] easily takes 30 seconds, minimum. If you’re used to strict TDD, and running tests on every change, this means it can literally take an hour to do what would otherwise take a few minutes. It is basically impossible to do TDD in an unmodified Rails environment.

Preloading Rails

Spin offers one type solution, by preloading as much of Rails as possible, but only booting the environment modified during a TDD cycle on demand. Unfortunately, it only saves a few seconds, and doesn’t really change the quality of the TDD cycle. Spork is able to preload more, but only by extensively monkey-patching Rails. My own experience, one shared by members of the ShRUG audience, is that this can introduce so many subtle bugs that the time you might save in the TDD loop is lost fixing unexpected weirdness. On this basis, I consider pre-forking an ineffective strategy to improve TDD cycle time. And as someone else has already pointed out, it solves the wrong problem anyway.

Persistent test environment

Another strategy I tried, which only works with browser integration tests, is to keep a persistent Rails environment running and turn on code reloading, as used in the development environment. Guard::Rails helps here. While code reloading in Rails is also a hack, it’s a more reliable and better-understood one than (metaprogramming-“optimised”) pre-forking. The downside is that because both Cucumber and RSpec expect to be run only once during each Rails process lifetime, you have to run the tests in a separate process. In my case, I was using RSpec to drive Capybara in one process, with a separate Guard::Rails-managed process running the app. For want of an application service layer, I controlled application state by making a second connection to the application database from the RSpec process, and using the Mongoid models directly. While all of this leads to slow tests, it’s still (ironically) faster than running a controller test.

Isolating components

The only strategy I had any significant success with was to break up the tests based on their dependencies, and only load Rails where necessary. However, it turned out options for this can be quite limited. Mongoid is quite straightforward to break out. Testing Mongoid models doesn’t give you unit tests (they’re still Mongoid integration tests), but Mongoid only takes a second or two to load and connect to the database, which is an order of magnitude faster than Rails. Other parts of the app will be more or less separable on the basis of their dependencies. For example, I had some luck initially testing Draper decorator objects, until an upgrade to Draper introduced a direct dependency on ActionController we couldn’t remove without monkey-patching.

Conclusion

This last obstacle was what finally formed my opinion: the Rails community, on aggregate, either does not value TDD, or has a serious underestimation of the level of feedback it can provide. Whatever the Rails core team values enough to let the boot time reach its current epic size, it is not TDD. And many of the gems I use on this project (including Draper, Devise, CanCan) are not designed to work in a way that enables easy testing in isolation. This is not to say they aren’t thoroughly tested, or that they weren’t developed TDD themselves, but they do not facilitate TDD for their users. I do not believe that any significant proportion of the Rails community is trying to break down dependencies in such a way that gives inherently fast TDD, although I hope to be proved wrong. Gary Bernhardt is one exception, Kevin Rutherford is another. And if you can make ShRUG in February, you’ll see that Tom Crayford is a third, when he gives his talk Isolation vs Rails: More Fastererer Speedy Testing Mk II Edition.

3 responses
I really want to stay positive about Rails because that's what we use at work. The only way that I can think of to achieve fast feedback is to not load the entire application.

Like you are saying, loading a more isolated environment for individual tests and loading the entire environment for suites.

Have you tried this in practice?

Apologies, for some reason I didn't see the slides. Anyways, have you tried this in a production application? If yes, please share the successes and problems that came about.

Thank you for sharing your isolation practice!

Hi etharwebs. I was only involved in the project I used as the basis for these slides for a short while (~2 months). The biggest win I had isolating code was with Mongoid objects, which we managed to test without Rails. Obviously, these aren't unit tests, and they were still slow, but they were acceptably quick to develop with compared to the rest. I would have liked to isolate more, but I wasn't really in a position to start making major changes to the codebase.

The main problem I found was that so much code (in gems) is written with the assumption it will be running in a Rails environment that you find dependencies everywhere. Unfortunately, isolatable code isn't something I've seen many Rails-oriented gem maintainers aim for, which means this can feel like an uphill battle. However this is just my experience, and YMMV.