Testing subdomains in Rails with xip.io

- - posted in rails, testing

A popular pattern in multi-tenant applications is the use of subdomains to partition tenants. This is when you have one tenant accessing your app at tenant1.myawesomeapp.com, another at tenant2.myawesomeapp.com, and so on.

Using subdomains for this purpose has it’s advantages and disadvantages, but that’s a different topic. I’m going to describe how to use xip.io to test subdomains in your application, as an alternative to adding an entry in /etc/hosts on each machine you are testing on and for each subdomain you are testing.

If you haven’t heard of xip.io, it is a service provided kindly by 37signals to act as a “magic domain name that provides wildcard DNS for any IP address.” So 127.0.0.1.xip.io resolves to 127.0.0.1. Nothing interesting here. The cool part is when tenant1.127.0.0.1.xip.io resolves to 127.0.0.1 but as far as Rails is concerned the request now has a subdomain of tenant1.

So let’s say we have an application with multiple tenants, and each tenant has their own stuff. We might have a routes.rb with this entry.

config/routes.rb
1
get '/stuff' => 'stuff#index', constraints: -> { |request| request.subdomain.present? && request.subdomain != 'www' }

This will hit the StuffController. For simplicity, lets treat authentication as out-of-scope for this article.

app/controllers/stuff_controller.rb
1
2
3
4
5
class StuffController < ApplicationController
  def index
    @stuff = Tenant.find_by_subdomain(request.subdomain).stuff
  end
end

All is well and good so far, but how do we test this with RSpec and Capybara? Capybara’s visit can take either a path or a full URL. To use subdomains we need to give it a full URL, but host of this URL will need to resolve to an IP. This is where you could change the /etc/hosts file of the machine to map a subdomain to 127.0.0.1. Or you could use xip.io to do it.

1
2
3
def url_for_subdomain subdomain="www", path="/"
  "http://#{subdomain}.127.0.0.1.xip.io:#{Capybara.server_port}#{path}"
end
spec/features/stuff_spec.rb
1
2
3
4
5
6
7
8
9
10
require 'spec_helper'
describe "Getting the stuff" do

  let(:tenant) { tenants(:a_fixtured_tenant) }

  it 'renders the stuff correctly' do
    visit url_for_subdomain tenant.subdomain, '/'
    #validate that the correct stuff (according to your fixtures) is rendered on the page
  end
end

No changes to /etc/hosts required. There is one small change you will need to make to config/environments/test.rb, however. You need to tell it that your top level domain will be a little longer than it expects. The default is 1, for example.com, but you need to set it to 5 for 127.0.0.1.xip.io. This tells Rails to treat anything before 127.0.0.1.xip.io as a subdomain

config/environments/test.rb
1
config.action_dispatch.tld_length = 5

Now, because you have made this change to test.rb, the controller spec needs to respect the fact that you have a tld_length of 5. This means that for subdomains to work in your controller spec, you need to have a request.host that is the same length as your Capybara specs. In this case it doesn’t have to be 127.0.0.1.xip.io, it could be blah.blah.blah.blah.blah.blah, as long as it had 5 dot separators.

spec/controllers/stuff_controller_spec.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
require 'spec_helper'
describe StuffController do

  let(:tenant) { tenants(:a_fixtured_tenant) }

  before do
    request.accept = 'application/json'
    request.host = "#{tenant.subdomain}.127.0.0.1.xip.io"
  end

  describe 'getting the stuff' do
    it 'gets the correct stuff' do
      get :stuff
      expect(assigns(:stuff)).to match_array(tenant.stuff)
    end
  end
end

So there you have it, another trick to put in your toolbelt when using subdomains with Rails.