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.
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
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
app
method.
# 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.
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.
That's a wrap. I hope you found this article helpful and you learned something new.
As always, if you have any questions or feedback, didn't understand something, or found a mistake, please leave a comment below or send me an email. I reply to all emails I get from developers, and I look forward to hearing from you.
If you'd like to receive future articles directly in your email, please subscribe to my blog. Your email is respected, never shared, rented, sold or spammed. If you're already a subscriber, thank you.