How I write tests for my Rails (API) apps
These days I am building lot of Rails APIs and in this post I will discuss my approach for writing tests.
TLDR;
- Drive code by integration tests to get quick feedback.
- Integration tests over unit tests.
- Don't chase 100% test coverage.
- Testing outcomes over internals.
Background
Broadly there are two types of tests, unit tests and integration tests. Unit tests test a part of code in isolation whereas the integration tests test multiple pieces of the code together. Rails provides a testing DSL built on top of minitest out of the box which supports writing unit tests, integration tests in the form of system tests. RSpec - which is another testing library in the Ruby ecosystem - supports writing tests or specs for a Rails application using request specs, controller specs and model specs.
My approach of writing tests
I use RSpec for writing tests but my approach would have been similar with minitest as well. For every new API endpoint, I first write a simple request spec with the expectation of successful HTTP response.
Request specs are RSpec's way of writing integration specs. They perform an actual HTTP request so that we can test an user interaction end to end.
# spec/request/users_request_spec.rb
RSpec.describe 'Users', type: :request do
describe 'post users/' do
subject { post '/users' }
it 'returns success' do
subject
expect(response).to have_http_status(200)
end
end
end
This test makes sure that the request returns a successful 200 response. After this I go ahead and start writing the actual code for the UsersController
. I already have a test which verifies that the controller action returns HTTP 200 code. So I can develop the controller code slowly piece by piece by adding more functionality. At any point of time if the controller action does not return expected 200 response, the test will fail and give early feedback.
As I add specific code in the controller, I start updating the test with more details. For eg. when the code for creating users is added, I update the test to make sure user gets created.
# spec/request/users_request_spec.rb
RSpec.describe 'Users', type: :request do
describe 'post users/' do
subject { post '/users' }
it 'returns success' do
expect { subject }.to change(User, :count).by(1)
expect(response).to have_http_status(200)
end
end
end
I don't follow TDD religiously but make sure that the request specs cover most of the scenarios present in the workflow that I am working on.
Handling background jobs in request specs
Lot of the times, the controller actions themselves don't do anything. They just delegate the job of processing into background. In such cases, I prefer to execute the job in request specs themselves using the perform_enqueued_jobs
test helper.
# spec/request/users_request_spec.rb
RSpec.describe 'Users', type: :request do
describe 'post users/' do
perform_enqueued_jobs do
subject { post '/users' }
end
it 'returns success' do
expect { subject }.to change(User, :count).by(1)
expect(response).to have_http_status(200)
end
end
end
The perform_enqueued_job
method executes the background jobs and then we can assert about the tasks it executed.
The advantages for executing the background jobs within the request specs is that end to end scenario gets tested. Without this, connection between a request and a background job does not get tested and can result into broken contract between the two in production environment.
Although I prefer executing the jobs in 99% of the cases, executing the background jobs is not possible always. In such cases, I use assert_enqueued_jobs
to make sure that the jobs get enqueued as expected. I use VCR cassettes to store the third party API interactions so that they are not repeated in every test run.
As you might have guessed so far, the focus of the testing is not 100% test coverage but on covering most scenarios with request specs or integration specs as the emphasis is on covering the scenarios end to end. 100% code coverage is a myth. Instead focussing on it, we can get most impact from the tests using request specs as much as possible.
Testing outcomes instead of internals
Within a request spec, I prefer testing the outcome of the request. For eg. whether it updated the database, whether it changed a condition, what was the response code. I don't test the instance variables assigned in the controller action or which class was called from it. Testing the outcome also tests the code that causes the outcome. This also means I don't have to write specs for the services or classes that get called from the controller. The request specs cover that code as well.
What about unit tests
I sometimes do write unit tests for a specific class. They are important in cases when when the code has different output based on input conditions and not all such cases can be covered in the request specs.
Handling bugs
Bugs are a reality of our life as software developers. Though we try to minimise the bugs, we eventually run into bugs. In my case, every bug fix is accompanied by a test to ensure that the regression does not happen again.
Request specs are not limited to one request
For a long time, I had this belief that each request spec should test only one request. But the request or integration specs are not be limited to testing one request per test. In some cases, we want to test a full interaction which includes two or three requests in sequence. Let's say we have a onboarding flow where user's onboarding state is updated within each request. In such cases, we have the option of creating factory data and performing only one request in each spec for each state.
But I prefer to perform multiple requests instead of creating partial data by hand. This means there might be some duplication in tests but that is acceptable.
The reason for this approach this is that maintaining the code for partial data becomes hard over time and it can become out of date with the actual workflow over time.
Mocking and Stubbing
I avoid mocking and stubbing as much as possible. The idea is to execute as much as code as possible during tests instead of mocking or stubbing so as to validate it instead of mocking it.
Testing private methods
I prefer to testing private methods via the public methods that call them.
Speed concern
Will all of these request specs make my test suite slow? No! My test suite still finishes pretty fast. Also I can run tests in parallel if it becomes slow. Rails supports running test in parallel starting from Rails 6 onwards. RSpec also has support for running specs in parallel using gems such as parallel_tests.
Rails testing guide has detailed documentation about integration tests.
RSpec request specs docs can be found here.
So this is how I write tests for my Rails applications these days. Please let me know what approach you use for writing tests in comments or on Twitter.
If you like this blog post, please consider supporting me on Patreon. It will help me in producing more useful Ruby/Rails related content on consistent basis 🙏