Introduction
Controller tests verify that your application responds correctly to HTTP requests. In Rails 5 and later, controller tests are integration-style tests that exercise the full request/response cycle, including routing, middleware, and rendering. Understanding how to write them is essential for ensuring your endpoints behave as users and API consumers expect.
Key Concepts
- ActionDispatch::IntegrationTest: The base class for all controller tests in modern Rails. It replaces the older
ActionController::TestCaseand provides a more realistic testing environment. - HTTP method helpers: Methods like
get,post,patch,put, anddeletethat simulate browser or API requests against your routes. - Response assertions: Matchers such as
assert_responseandassert_redirected_tothat verify the HTTP status code and redirect behavior of a response. - Functional vs integration style: Rails 5+ merged functional and integration test styles so that controller tests go through the router, giving you confidence that routes are wired correctly.
Real World Context
Every Rails application exposes endpoints that users interact with through a browser or an API client. Without controller tests, you rely on manual clicking or end-to-end browser tests to confirm that a page loads, a form submission redirects correctly, or an API returns the right status code. Controller tests fill the gap between fast model-level unit tests and slow full-browser system tests, giving you quick feedback on request handling without launching a browser.
Deep Dive
A basic controller test inherits from ActionDispatch::IntegrationTest and uses HTTP method helpers to simulate requests.
Here is a simple test that verifies CRUD actions for an articles resource:
rubyrequire 'test_helper' class ArticlesControllerTest < ActionDispatch::IntegrationTest test 'should get index' do get articles_url assert_response :success end test 'should get show' do article = articles(:first) get article_url(article) assert_response :success end test 'should get new' do get new_article_url assert_response :success end end
In the code above, articles_url and article_url are route helpers generated by resources :articles. The assert_response :success matcher accepts symbols (:success for 2xx, :redirect for 3xx, :not_found for 404) or integer status codes.
For write operations, use assert_difference to verify that the database changed as expected:
rubyclass ArticlesControllerTest < ActionDispatch::IntegrationTest test 'should create article' do assert_difference('Article.count', 1) do post articles_url, params: { article: { title: 'New', body: 'Content' } } end assert_redirected_to article_url(Article.last) end test 'should update article' do article = articles(:first) patch article_url(article), params: { article: { title: 'Updated Title' } } assert_redirected_to article_url(article) article.reload assert_equal 'Updated Title', article.title end test 'should destroy article' do article = articles(:first) assert_difference('Article.count', -1) do delete article_url(article) end assert_redirected_to articles_url end end
Notice that assert_difference takes a string expression and a delta. Pass a negative delta for deletions and a positive one for creations. After an update, call reload on the ActiveRecord object to fetch the latest values from the database.
Rails provides several response assertions you will use frequently:
rubyassert_response :success # 200-299 assert_response :redirect # 300-399 assert_response :not_found # 404 assert_response 201 # Specific status code assert_redirected_to articles_url assert_redirected_to @article assert_match 'Welcome', response.body
Common Pitfalls
- Forgetting to reload after update — After a
patchorput, the in-memory ActiveRecord object still holds old values. Callarticle.reloadbefore asserting on the updated attributes. - Using the wrong URL helper —
article_urlgenerates a full URL including host, whilearticle_pathgenerates a relative path. In integration tests, prefer the_urlvariants because the test runner expects absolute URLs. - Not wrapping state-changing requests in assert_difference — Simply checking the response status for a
postordeletedoes not confirm the database actually changed. Always pair the request withassert_difference.
Best Practices
- Test both success and failure paths — For every create or update action, write one test with valid params and another with invalid params that triggers validation errors.
- Use fixtures for test data — Fixtures load once per test suite and are wrapped in a transaction, making them faster than factories for controller tests.
- Keep controller tests focused on HTTP behavior — Assert status codes, redirects, and response bodies. Push business logic assertions into model tests.
Summary
- Controller tests in Rails inherit from
ActionDispatch::IntegrationTestand exercise the full request cycle. - Use
get,post,patch, anddeletehelpers with route URL helpers to simulate requests. - Verify outcomes with
assert_response,assert_redirected_to, andassert_difference. - Always reload ActiveRecord objects after updates and wrap mutations in
assert_differenceblocks. - Test both valid and invalid request scenarios for complete coverage.
Code Examples
require 'test_helper'
class ProductsControllerTest < ActionDispatch::IntegrationTest
test 'index returns success and lists products' do
get products_url
assert_response :success
assert_match 'Products', response.body
end
test 'create adds a product and redirects' do
assert_difference('Product.count', 1) do
post products_url, params: {
product: { name: 'Widget', price: 9.99 }
}
end
assert_redirected_to product_url(Product.last)
end
test 'destroy removes product' do
product = products(:gadget)
assert_difference('Product.count', -1) do
delete product_url(product)
end
assert_redirected_to products_url
end
end