May 26, 2024
How to Setup Rails-like Integration Tests in Ruby
As a Ruby on Rails developer, you must be well familiar with integration testing, where you make a simulated HTTP request to your application, passing headers and params, and assert the response status and body. Something like this:
test "can create an article" do
get "/articles/new"
assert_response :success
post "/articles", params: { article: { title: "can create", body: "article successfully." } }
assert_response :redirect
follow_redirect!
assert_response :success
assert_select "p", "Title:\n can create"
end
Have you ever wondered how does it all work? For example, where do the get, post methods come from, what exactly do they do? How do they make the HTTP request to your application and get the response?
Well, I had the same questions, and to answer them, we are going to be building integration testing in our Rails Companion project. Not only will it teach us how testing works in Rails, but we'll have a solid testing support for our plain, Ruby based web application as we build more Rails features in future.
This is the third post in my course Rails Companion, where we build a complete web application using Ruby, without Rails. The goal is to understand how web applications work, and to develop a deeper understanding and appreciation for everything Rails does for us.
To learn more, check out this post: Announcing Rails Companion 💡, and you can purchase the pre-release version of the course here 🙏
So far, this is our simple web application written in Ruby.
require_relative './config/routes'
class App
def call(env)
headers = { 'Content-Type' => 'text/html' }
response_html = router.build_response(env)
[200, headers, [response_html]]
end
private
def router
Router.instance
end
end
In the previous post, we added simple routing support to it. Here is our routes file containing three routes.
require_relative '../router'
Router.draw do
get('/') { "Hello World" }
get('/articles') { 'All Articles' }
get('/articles/1') do |env|
puts "Path: #{env['PATH_INFO']}"
"First Article"
end
end
Check it out: Build Your Own Router in Ruby
So far, whenever we made a change, we had to open the browser, enter the URL, and ensure we got the expected response back. It worked, but it is tedious and also, not practical. You are simply not going to visit each and every route to ensure it works as expected.
Testing provides a faster and efficient way to automate this activity.
Our goal is to write unit tests to ensure that when we visit the web application on the above routes, we receive the expected response.
Let's get started.
Step 1: Install a Testing Framework
I am a big fan of Minitest and how simple it is. The entire framework code consists of 18 files and 2230 lines of code! It's written in plain Ruby and you can just read it to understand how it works. It's easy, fast, readable, simple, and clean!
All this to say, we will be using Minitest to test our web application. Let's install it using bundler. In the weby directory, run the following commands, one after another:
bundle add minitest
Step 2: Write the First Test
Let's write a failing test to make sure we can create and run tests.
Create a test directory in your project and add a new file called app_test.rb in it.
# weby/test/app_test.rb
require "minitest/autorun"
class AppTest < Minitest::Test
def test_it_works
result = 3 + 2
assert_equal 4, result
end
end
To run the test, we simply run the above Ruby script.
$ ruby test/app_test.rb
Run options: --seed 13065
# Running:
F
Failure:
AppTest#test_it_works [test/app_test.rb:6]:
Expected: 4
Actual: 5
bin/rails test test/app_test.rb:4
Finished in 0.001620s, 617.2840 runs/s, 617.2840 assertions/s.
1 runs, 1 assertions, 1 failures, 0 errors, 0 skips
We can confirm that Minitest is all set up and running the tests. Very good!
However, running each test in the above way can get cumbersome. It would be nice if we could run a single command to run all tests.
Using Rake to Run Tests
Rake allows you to use ruby code to define "tasks" that you can run in the command line. Let's install Rake to create a task that will run the tests.
bundle add rake
Next, add a Rakefile in the weby directory as follows:
# Rakefile
require "minitest/test_task"
Minitest::TestTask.create(:test) do |t|
t.libs << "test"
t.libs << "lib"
t.warning = false
t.test_globs = ["test/**/*_test.rb"]
end
task :default => :test
Note: The lib directory doesn't exist yet, but let's add it anyway as we'll need it in future.
That's it. The last line marks the test task as default task, so you could run the tests using either of the following commands:
bundle exec rake
# or
bundle exec rake test
Nice! There's one improvement we could still make by making the output coloured and exciting with a progress bar.
Make Tests Pretty with Minitest Reporters
Let's add the minitest-reporters gem which prettifies the test results with coloured output and a progress bar.
bundle add minitest-reporters
Next, let's use the reporters in our test file as follows:
require "minitest/autorun"
require "minitest/reporters"
Minitest::Reporters.use!
class AppTest < Minitest::Test
# ...
end
Now run the test again and watch the colorful output. Pretty nice!
Support Integration Testing with Rack::Test
So far, we've added a test framework and written a simple test. However, to make it work like integration tests in Rails, we need the ability to simulate an HTTP request. That is, make the request to our application, passing the required headers and parameters, receive the HTTP response, and inspect response to assert it is correct.
We'll add the support using the rack-test gem, which is a small, simple testing API for Rack apps. It can be used on its own or as a reusable starting point for Web frameworks and testing libraries to build on. In fact, rails uses rack-test behind the scenes.
Let's add rack-test to our application.
bundle add rack-test
Next, we'll require it in the test file as follows:
# test/app_test.rb
require "rack/test"
...
The final step is to use the methods provided by this gem such as get, post, and so on to implement integration testing for our application.
If you check out the docs for the rack-test gem, you'll notice that we need to implement two things before we can use the helper methods:
- Include the Rack test methods.
- Provide the Rack application via the
appmethod.
# Rack-Test Example
class HomepageTest < Test::Unit::TestCase
include Rack::Test::Methods
def app
lambda { |env| [200, {'content-type' => 'text/plain'}, ['All responses are OK']] }
end
def test_response_is_ok
# Optionally set headers used for all requests in this spec:
#header 'accept-charset', 'utf-8'
# First argument is treated as the path
get '/'
assert last_response.ok?
assert_equal 'All responses are OK', last_response.body
end
end
Luckily, we have our Rack app ready to go. So let's require it, include the Rack test methods, and make an HTTP request. Here's the complete test code.
# test/app_test.rb
require "rack/test"
require "minitest/autorun"
require "minitest/reporters"
require_relative "../app"
Minitest::Reporters.use!
class AppTest < Minitest::Test
include Rack::Test::Methods
def test_make_http_request
get "/"
assert last_response.ok?
assert_equal "<h1>Hello World</h1>", last_response.body
end
private
def app
App.new
end
end
Let's run the test with bundle exec rake, and ensure that it runs successfully. It does!
bundle exec rake
Started with run options --seed 56349
1/1: [=====================================================================================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00332s
1 tests, 2 assertions, 0 failures, 0 errors, 0 skips
Now just to be safe, let's verify that it's actually working by changing the output. Remove the surrounding header tags from the response body corresponding to the / route as follows:
# config/routes.rb
require_relative '../router'
Router.draw do
get('/') { "Hello World" }
# ...
end
Now run the test again. It fails as expected!
Congratulations, we have successfully implemented integration testing for our Ruby web application.
💡 If you're curious about how the get method works, check out the custom_request method in the rack-test gem. The
Add Syntactic Sugar with ActiveSupport
We are almost there. Let's finish by adding a nice syntactic sugar like Rails tests.
Notice that right now, we are writing our test methods using the standard Ruby method syntax.
def test_make_http_request
get "/"
assert last_response.ok?
assert_equal "<h1>Hello World</h1>", last_response.body
end
However, I really like how the tests in Rails use the test method, providing the test description string, that reads like English. For example,
test "make HTTP request" do
# ...
end
This test method comes from the ActiveSupport framework, and isn't that complicated to implement. It formats the string to replace all the spaces with underscores, creates a method named test_xxx, and executes the provided block as the test body.
# File activesupport/lib/active_support/testing/declarative.rb, line 13
def test(name, &block)
test_name = "test_#{name.gsub(/\s+/, '_')}".to_sym
defined = method_defined? test_name
raise "#{test_name} is already defined in #{self}" if defined
if block_given?
define_method(test_name, &block)
else
define_method(test_name) do
flunk "No implementation provided for #{name}"
end
end
end
However, instead of rewriting that logic, we will simply reuse the method by requiring the ActiveSupport framework, as it provides a bunch of other niceties that we will need later.
Let's add the activesupport framework and require it in the test file.
bundle add activesupport
# test/app_test.rb
require "active_support"
Once that's set up, we need to inherit our test class from ActiveSupport::TestCase so we can access the test method. So replace Minitest::Test with ActiveSupport::TestCase and rewrite the test as follows.
# test/app_test.rb
# ...
require "active_support"
class AppTest < ActiveSupport::TestCase
include Rack::Test::Methods
test "make HTTP request" do
get "/"
assert last_response.ok?
assert_equal "<h1>Hello World</h1>", last_response.body
end
end
Rerun the test and ensure it passes as expected.
Extract Test Helper Class
Notice that our test file has become quite bloated, with a bunch of require statements and a private method that we will need whenever we write new tests. So instead of duplicating this logic, let's extract a test_helper file containing all the common testing code.
First create a test_helper.rb file under the test directory, containing the following extracted code from app_test.rb.
# test/test_helper.rb
require "rack/test"
require "active_support"
require "minitest/autorun"
require "minitest/reporters"
require_relative "../app"
Minitest::Reporters.use!
module ActiveSupport
class TestCase
include Rack::Test::Methods
def app
App.new
end
end
end
Notice that we have monkey-patched the ActiveSupport::TestCase class, just like Rails, so we could include the helper methods from rack-test and add the app method.
This simplifies our test code dramatically. Just require the test_helper and check out the new test. Pretty clean, right?
# test/app_test.rb
require "test_helper"
class AppTest < ActiveSupport::TestCase
test "make HTTP request" do
get "/"
assert last_response.ok?
assert_equal "<h1>Hello World</h1>", last_response.body
end
end
Congratulations, we have successfully implemented Rails-like integration testing in our Ruby web application. I hope that gave you a better understanding of integration testing in Rails.
Now here's an exercise for you. We have only added a single test that ensures we get the correct output when we visit the home page. Add the remaining tests for the other routes we have added in the application.
Subscribe to get future posts via email (or grab the RSS feed)