Rails Testing
General rules
- Use RSpec for testing.
- Use WebMock to mock external HTTP requests.
- Use FactoryGirl to load data into database.
- Use Timecop to change or freeze date.
- Use RSpec’s Expect syntax.
- Use
not_to
instead ofto_not
for negative expectations. - Don’t use instance variables.
-
Avoid
let!
. Uselet
instead and build objects inbefore
block when needed. If you need to load multiple objects in before statement - place them into array:before do [ object_1, object_2, object_2 ] end
Tips
- When running tests in RRRspec, check for error traces in WORKERS section. Usually it gives good idea which workers are crashing and why.
Unit & Functional tests
- Use render_views to make sure views don’t crash.
-
Use
context
to nest tests and define data or precondition. When describing a context use “when”, “with”, “on” or similar words:context 'when user has name' do let(:name) { 'John' } it 'is valid' do expect(user).to be_valid end end context 'with no name' do let(:name) { nil } it 'user is valid' do expect(user).not_to be_valid end end
- Use
described_class
to avoid repeating class name. - Test only one thing in each example.
- Use
.method_name
to describe class methods. - Use
#method_name
to describe instance methods. - When testing interactors use only top level describe and nest tests into
context
. - Use imperative mood to write test descriptions.
- Keep example descriptions short.
- Don’t test configuration.
- Don’t use
subject
. - Don’t use one-liner example syntax.
Feature tests
- Use Capybara with rspec syntax to write feature tests.
- Place tests into
spec/features
directory. - Use correct content/selector matchers for performance reasons:
-
When you expect element to be in page, use matchers that wait for elements to appear:
have_selector have_xpath have_css have_link have_button have_field have_checked_field have_unchecked_field have_select have_table have_text have_content
-
When you expect element not to be in page, use matchers that wait for elements to disappear:
have_no_selector have_no_selector have_no_xpath have_no_css have_no_link have_no_button have_no_field have_no_checked_field have_no_unchecked_field have_no_select have_no_table have_no_text have_no_content
-
- Use real browser to run tests (eg, Chrome). This ensures that test cases will be more real-life.
- Use Headless to run feature tests on CI.
-
Extract repeated test initialization into methods:
def visit_my_page visit '/my_page' click 'My button' wait_for_ajax end it 'tests something' do visit_my_page # .... end it 'tests another thing' do visit_my_page # .... end
- Write methods that wait for ajax to complete or react-js component to finish rendering.
- Tag examples that test JavaScript-heavy application parts and increase Capybara timeouts.
- Make sure test files do not run too long - each file should finish within 2 minutes. That way tests will parallelize better when executed in rrrspec or parallel_tests.
- Use truncation strategy from Database Cleaner to clean database after each example.
- Don’t respect single assert per example rule - preparation for example takes lots of time, so it’s better to have multiple asserts in one example.
Request tests
- Use request tests to test API endpoints.
- Test response code and body.
-
Use helper methods for testing json body:
def json_body ActiveSupport::JSON.decode(response.body) end it 'tests my_api' do get '/api/v1/my_api' expect(json_body['my_key']).to eq('my_value') end
Mutant
We use Mutant to ensure robustness of the test suite.
Mutant creates mutations by deleting or modifying pieces of code (e.g. changes <
to >
) and runs the corresponding tests. If tests succeed, then this mutation was not killed and it will be printed in the output. In this case, we need to improve the test suite.
Tool execution speed:
- Fast, if executed on isolated and tested pieces of code - interactors, services, policies, etc.
- Slow, if executed on models which touch a lot of tests (especially feature tests).
Mutant should be used at the end of a feature development. It is also useful when used before refactoring code to check if code to be refactored is covered with tests.
How to run:
RAILS_ENV=test SPEC_OPTS="--pattern spec/interactors/some/interactor_spec.rb" bundle exec mutant -r ./config/environment --jobs 1 --use rspec 'Some::Interactor'
| |
Run only this specific spec. Namespace with class name.
The most common mistake when writing the test suite
When writing the test suite, quite often, not all of the code conditions and branches are tested. For example, the data set might not be big enough to cover all of the code branches or the test suite might need additional contexts.
To cover all of the different code branches, either:
- Create more than one record with different states and expect the output to match only a subset of the initial data.
- Add additional context to the test suite.
In the following examples, only one record was created in the test suite with only one context. As a result:
-
The scope is vulnerable to future changes and the test suite will not detect them.
def reindex_items - Item.where(owner_type: "User", owner_id: some_scope.ids).reindex! + Item.where(nil).reindex! end
-
The condition can be deleted and the test suite will not detect it.
def confirm_user - if current_user.unconfirmed - current_user.confirm - end + current_user.confirm end
-
The whole branch can be deleted and the test suite will not detect it.
def after_create item_id = params[:item_id] - if item_id - redirect_to(items_path(item_id: item_id)) - else + unless item_id redirect_to(items_path) end end
False positives
We treat some of the mutations as false positives. This list includes, but is not limited to:
-
Data sorting.
Mutant shows code which sorts some data as untested, after deleting that code specs are failing (most of the time). For example:
- Code sorts some data.
- Specs for that code expect that the data is sorted.
- Mutant deletes the code which sorts the data.
- Sometimes specs will succeed because the data is retrieved in a random order from the database.
In this case:
- Make sure that the dataset in the spec is as varied as possible (e.g. if user first_name and last_name is sorted, create all possible variations of first_name and last_name).
- Consider increasing the size of the data set to increase the chance of failure in these specs.
-
Constant resolving.
def can_access? - user.is_a?(::Admin) && (!user.anonymous?) + user.is_a?(Admin) && (!user.anonymous?) end
In this example, when Admin is called without
::
, wrong Admin class will be loaded if two of them exists in the project (e.g. Admin and SomeOther::Admin). -
Transactions (except the ones which are easy to test).
def attempt_to_create_agreement - Agreement.transaction do - agreement.save! - end + agreement.save! end
-
params.fetch
def receiver - @receiver ||= User.by_email(params[:receiver_email]) + @receiver ||= User.by_email(params.fetch(:receiver_email)) end
-
eql?
def call - if user == item.owner + if user.eql?(item.owner) item.destroy! else notify_admin end end
Use
eql?
for strict value comparison, for example, when you need to differentiate between Integer and Float. -
instance_of?
def call - if item.is_a?(Item) + if item.instance_of?(Item) item.destroy! end end
-
includes
def index respond_to do |format| format.html format.json do - render(json: items.includes(:sender)) + render(json: items.includes(nil)) end end end
Ignored because not possible to test what was included.
-
record.id
def excluded_user - company.users.where.not(id: excluded.id).ids + company.users.where.not(id: excluded).ids end
Optimizing tests
Over the time as the project grows and new features are added your spec suite is bound to expand. As new specs are added it is very likely that somewhere, a few unconsidered statements will be added that will heavily slow down your suite.
The most common cases are:
- Exponential factory dependency growth (a factory that spawns tons of dependencies creating a large amount of records).
- Unnecessary record creation.
- Live requests in feature specs.
If you’re new to the project and have no idea where to look at, here’s a link to this nice article that gives quite a few suggestions on how to profile your spec suite.
In practice, the most common causes are likely to be either disregarded factories that create useless records or large data structures built using the factory - which is rather slow.
If reviewing the factory and optimizing the useless record creation didn’t help there are only a few solutions available:
- Optimizing the SQL (e.g. activerecord-import).
- Preloading the data into the general data set. (if your project has one).
- Using context-aware record caching. (e.g. once-ler).
Once-ler
RSpec.configure do |config|
config.include Onceler::BasicHelpers
end
This gem uses Rails virtual nested transactions (save points) which are currently only supported only by MySQL and PostgreSQL.
The idea behind the speed-up is running a factory that creates a record -once-. The gem sets a save point after running the code that is supposed to be run only once, then loads and uses Marshal.load
to restore the returned variable
when it has been already created, without re-running the SQL statements.
Use this approach wherever you have time-consuming let
definitions. It will speed up your specs tremendously.