I have a set of business critical cron jobs that run overnight, once a week. If they don't run... it's bad news; really bad news. There's nothing worse than getting into the office, only to find out that one of the jobs threw an error and didn't complete. You get to a point where testing all the pieces in isolation just isn't good enough. I need to be confident that all of the pieces will work together. Time to get some integration tests in there (or feature specs, as we call them in the world of RSpec.)
A feature spec is a high level test that walks through an entire process to ensure that all of the pieces work together as expected. In a standard Rails application, that means using a headless browser to hit the UI, manipulate it in some way, and then check for an expected output. It touches all the layers; controller, model, view, and any other classes in between. It simulates a user initiating a process, interacting with an interface in a consistent, repeatable way, and expecting to get the same result each time.
What do you do when the thing initiating isn't a user? What if the UI isn't a web application? These might sound out of place for Rails, but if you're anything like me, you see them all the time. Crontab initiates multiple processes every day that run through a command line interface. These are rake tasks. I consider them features of my application, no different than the various features available through the web UI.
EDIT: Given that there has been some confusion in the comments, I just want to clarify my position a bit up front.
- The point here is about end-to-end tests. Nothing more, nothing less. Call them feature specs, call them integration tests. It's about being confident that your application will run, as expected, in your production environment.
- I'm not advocating that Rake tasks should be unit tests. I'm not talking about testing Rake itself or treating a Rake task like a class. I'm talking about ensuring that every entry point into your application has test coverage the exercises the entire application, from end-to-end. (See my response to the first comment for more info)
- I'm not advocating for raw code to be put in Rake tasks. I use a simple Rake task in the examples and raw code is the easiest way to illustrate the intention of the code, without required pages of code samples. In general, I do advocate for using a Ruby class to encapsulate the functionality of a Rake task. Regardless of the content of a Rake task, be it raw code or an object with a single method being called, end-to-end testing is still relevant. (See my response to the second comment for a detailed example, with code)
Now that we've gotten that out of the way... :)
Like any feature, to be able to test them, we need to be able to initiate the process and check for an expected output. Let's start with an example: say we are building an e-commerce web application that has a nightly cron job to update the inventory for all of the products. You might expect the structure of the rake task too look something like this:
namespace :products do desc "Update the inventory for all products" task :update_inventory => :environment do #...code to update inventory goes here... end end
Looks all too familiar I'm sure. Now let's look at a a feature spec to test this task.
require "rails_helper" require "rake" feature "crontab updates the inventory" do before do load Rails.root.join("lib/tasks/products.rake") Rake::Task.define_task(:environment) end after { Rake.application.clear } scenario "nightly at 12am" do create_list :product, 3, has_inventory: false task = Rake::Task["products:update_inventory"] expect { task.invoke }.to output(/Inventory updated for 3 products/).to_stdout end end
Let's break down what's going on here:
The first thing that is different from a standard feature spec is that we need to require the rake gem. This allows the Rake::Task to be instantiated and invoked.
There are 2 hooks, a before and an after. The before hook loads the rake task under test into memory and defines the :environment task (that's what loads the Rails environment so that we can access our Models and such). The after hook clears the rake task from memory, to ensure that subsequent tests do not inherit any state.
Then we have the scenario that we're testing. Like any good test, it has the setup first, where it creates a list of 3 products (using FactoryGirl) and initializes the Rake::Task that we want to exercise as part of this test.
Since the rake task sends its output to STDOUT, we need to use RSpec's "expect with block" syntax. The block captures the output from the rake task and allows us to match against it. Finally, calling the #invoke method on the Rake::Task is what runs the task, just as if it had been run via the command line using the bundle exec rake products:update_inventory syntax.
Conceptually this is the same as the feature specs that we all know and love. In practice the real difference is that we're invoking a rake task instead of loading up a web page, and matching against STDOUT instead of against an HTTP response.
Finally, we just need to write the code to make the test pass. A trivial implementation might look something like this
namespace :products do desc "Update the inventory for all products" task :update_inventory => :environment do products_to_update = Product.where(has_inventory: false) updated = 0 products_to_update.each do |product| if product.update_inventory updated += 1 end puts "Inventory updated for #{updated} #{'product'.pluralize(updated)}" end end
A passing feature spec gives us a high level guarantee that all of the pieces work together from end to end. It only covers the specific use case of the task and relies on the assumption that the Product class has it's own unit tests to cover all possible cases. In the end, it gives us confidence that when crontab initiates the task, it will run as expected.