Testing Nested RESTful Resources in Rails

Let’s scaffold some nested RESTful resources in Rails.

rails --database sqlite3 sandbox
script/generate scaffold_resource Artist name:string
script/generate scaffold_resource Song title:string artist_id:integer

… and see if we have a green build.

rake db:migrate
rake

So far so good, including the 14 functional tests that came with the scaffold.

We want Song to be associated to Artist as a one-to-many relationship and we want our application to reflect that in a RESTful way, so that we can access an artist’s songs as http://expample.com/artists/11/songs/4. In config/routes.rb, we replace the two lines:

map.resources :songs
map.resources :artists

with

map.resources :artists do |artists|
  artists.resources :songs
end

We need to tell Song that it belongs_to :artist and Artist that it has_many :songs. The SongsController needs to be aware of the Artist associated with the songs:

class SongsController < ApplicationController

  before_filter(:capture_artist)

  def index
    @songs = @artist.songs
    respond_to do |format| format.html end
  end

  [...]

  def create
    @song = @artist.songs.create(params[:song])

    respond_to do |format|
      if @song.valid?
        flash[:notice] = 'Song was successfully created.'
        format.html { redirect_to song_url(@artist, @song) }
      else
        format.html { render :action => "new" }
      end
    end
  end

  private

  def capture_artist
    @artist = Artist.find(params[:artist_id])
  end
end

The song_url methods in the SongsController and the Views in app/views/songs also need to be updated to reflect the relationship between the Artist and Song resources, as song_url(@artist, @song), etc.

By now it would make perfect sense to delete the following two lines from config/routes.rb, as they don’t any more make much sense as far as the context of our resources goes:

map.connect ':controller/:action/:id.:format'
map.connect ':controller/:action/:id'

At this point we have managed to break the build, because the tests in SongsControllerTest do not match the newly defined routes. Testing the new routing map is relatively easy and can be achieved as:

def test_should_route_songs_of_artist
  options = {:controller => 'songs', :action => 'index', :artist_id => "2"}
  assert_routing('artists/2/songs', options)
end

Then, the Controller tests must conform to the amended routes.

def test_should_get_new
  get :new, :artist_id => 1
  assert_response :success
end

My complete test/functional/songs_controller_test.rb looks like:

require File.dirname(__FILE__) + '/../test_helper'
require 'songs_controller'

class SongsController; def rescue_action(e) raise e end; end

class SongsControllerTest < Test::Unit::TestCase

  def setup
    @controller = SongsController.new
    @request    = ActionController::TestRequest.new
    @response   = ActionController::TestResponse.new
    Artist.create(:name => 'test')
  end

  def test_should_route_songs_of_artist
    options = { :controller => 'songs',
                :action => 'index',
                :artist_id => '2' }
    assert_routing('artists/2/songs', options)
  end

  def test_should_route_song_of_artist
    options = { :controller => 'songs',
                :action => 'show',
                :artist_id => '2',
                :id => '1' }
    assert_routing('artists/2/songs/1', options)
  end

  def test_should_get_new
    get :new, :artist_id => 1
    assert_response :success
  end

  def test_should_show_song
    Song.create(:title => 'test')
    get :show, :artist_id => 1, :id => 1
    assert_response :success
  end

  def test_should_get_edit
    Song.create(:title => 'test')
    get :edit, :artist_id => 1, :id => 1
    assert_response :success
  end

  def test_should_destroy_song
    Song.create(:title => 'test')
    delete :destroy, :artist_id => 1, :id => 1
    assert_equal 0, Song.count
  end

  def test_should_redirect_to_songs_list_after_destroy
    Song.create(:title => 'test')
    delete :destroy, :artist_id => 1, :id => 1
    assert_redirected_to songs_path
  end

  def test_should_update_song
    Song.create(:title => 'test')
    put :update, :artist_id => 1, :id => 1, :song => { }
    assert_redirected_to song_path(assigns(:artist), assigns(:song))
  end

  def test_should_create_song
    post :create, :artist_id => '1', :song => { }
    assert_equal 1, Song.count
  end

  def test_should_show_song_after_create
    post :create, :artist_id => '1', :song => { }
    assert_redirected_to song_path(assigns(:artist), assigns(:song))
  end

  def teardown
    Artist.find(:all).each do |a| a.destroy end
    Song.find(:all).each do |s| s.destroy end
  end
end

As the code stands now, and if you have removed all traces of fixtures from your tests, like I have (I usually avoid Rails fixtures, but that’s another story…), the build should be happy again.

3 Responses to “Testing Nested RESTful Resources in Rails”

  1. joost baaij Says:

    Would it be possible to list all songs in the database, using only the routes you have?

    I know that /artists/1/songs works, but would /songs work too? I have not been able to get this running, but it seems I should be able to.

    As far as I can tell, once nested the ‘parent’ resource must always be present in the url. If you have figured out a way around this, I sure would love to know!

  2. George Malamidis Says:

    You need to add a mapping in config/routes.rb similar to:

    map.connect ’songs’, :controller => ’songs’, :action => ‘list’

    Then create the ‘list’ action in songs_controller.rb:

    def list
    @songs = Song.find(:all)
    end

    And the corresponding view - app/views/songs/list.rhtml

    Also, make sure the ‘capture_artist’ filter method doesn’t complain when an artist_id is not present. Something like this should do the job:

    def capture_artist
    @artist = Artist.find(params[:artist_id]) unless params[:artist_id].nil?
    end

  3. Web 2.0 Announcer Says:

    Nutrun » Blog Archive » Testing Nested RESTful Resources in Rails…

    [...][...]…