Backend testing
Testing happens on two fronts, automated tests written by coders and user testing by our QA team. It is done before code is merged (automated always, QA if necessary) and always before releases when the entire bulk of changes is tested as a whole. In this section we will focus on automated testing.
Documentation Precedence: If existing spec files conflict with conventions in this document, follow this document.
Automated tests
Testing is done automatically by GitLab CI on every commit pushed. See the pipeline
definition in .gitlab-ci.yml in the repo.
Getting started
Our test framework is RSpec and rspec-rails. For factories, we use FactoryBot.
- To be more familiar with RSpec we recommend you to visit this page.
- For best practices visit this page.
Improve your tests' performance by mastering let_it_be
Define context persistent objects to reduce time spent preparing data for your examples.
See Using let_it_be.
RSpec conventions
Follow these conventions when writing or reviewing specs:
Structure and organization
- Use
describefor top-level grouping (methods, features, endpoints). - Use
contextonly for conditional branches or different states (e.g., "with valid params", "as admin"). - Declare one
subjectperdescribe/contextblock — never define multiple subjects at the same level. - Place
subjectimmediately after thedescribe/contextline, before anyletorlet_it_bedeclarations.
Subjects and expectations
- Always use named subjects:
subject(:method_name)instead of anonymoussubject. - Prefer one-liner
it { is_expected.to ... }when testing a single expectation on the subject. - Use descriptive
it "..."blocks for complex multi-step assertions or when context is needed. - Add
:aggregate_failuresmetadata when an example contains multiple expectations to optimize performance and reduce factory overhead.
User authentication
- Use
logged: :admin,logged: true, orlogged: falsemetadata instead of manualallow(User).to receive(:current)stubs. - See Logged User Configuration for details.
Performance with let_it_be
- Scope
let_it_beto the narrowestdescribe/contextwhere the variable is actually used. - Use
let_it_be(..., refind: true)when mutation or cached association risks exist. - See Using
let_it_befor full guidance.
Test boundaries
- Keep request/controller specs focused on routing, HTTP status, redirects, rendering, and assigned instance variables.
- Test service/job logic in dedicated service/job specs, not in controller specs.
- Stub external network calls with WebMock to avoid flaky tests.
Time-sensitive tests
- Use
travel_towith a fixed time when testing date/datetime-dependent behavior (e.g., attendance, expiration, time calculations). - Wrap time travel in
aroundblocks to ensure deterministic tests across DST changes, midnight boundaries, and different timezones. - This prevents false positives when tests run at different times or in different environments.
Example structure
RSpec.describe MyService, logged: :admin do
subject(:call_service) { described_class.call(params) }
let(:params) { { name: "Test" } }
describe "#call" do
context "with valid params" do
it { is_expected.to be_success }
it "creates a record" do
expect { call_service }.to change(MyModel, :count).by(1)
end
end
context "with invalid params" do
let(:params) { { name: "" } }
it { is_expected.to be_failure }
end
end
end
Example with multiple expectations
RSpec.describe EasyWebHook, type: :model do
subject(:webhook) { build(:easy_web_hook) }
describe "validations" do
it "requires name, entity_type, action and url", :aggregate_failures do
hook = build(:easy_web_hook, name: "", entity_type: "", action: "", url: "")
hook.valid?
expect(hook.errors[:name]).to be_present
expect(hook.errors[:entity_type]).to be_present
expect(hook.errors[:action]).to be_present
expect(hook.errors[:url]).to be_present
end
end
end
Example with time travel
RSpec.describe EasyAttendance, type: :model do
subject(:attendance) { build(:easy_attendance, arrival: arrival_time) }
let(:fixed_time) { Time.zone.parse("2024-06-15 09:00:00") }
let(:arrival_time) { fixed_time }
around do |example|
travel_to(fixed_time) { example.run }
end
describe "#working_hours" do
it "calculates hours correctly" do
attendance.departure = fixed_time + 8.hours
expect(attendance.working_hours).to eq(8.0)
end
end
end
Controller tests
Be mindful of the different RSpec types and their purpose when writing tests. Avoid testing behaviour of jobs using a
controller test for example. If you need to test a job - use type: :job test. Controller tests should only test what
the controller is responsible for, specifically:
- templates rendering (not their content)
- redirects
- instance variables assigned in the controller to be shared with views
- cookies sent back with the response
So if there is a job performed or a service called, controller tests should just test that they are performed or called with correct parameters, not their implementation.
Logged User Configuration
When writing specs that require authenticated users, you can use the logged metadata to automatically set up the
current user. This is configured in spec/logged_user_config.rb.
Available user types:
logged: false- skip logging, anonymous userlogged: true- Regular authenticated userlogged: :admin- Admin userlogged: :easy_ai_user- Easy AI user
Basic examples:
# Test with admin user
RSpec.describe "Admin features", logged: :admin do
it "can access admin panel" do
expect(User.current).to be_admin
end
end
# Test with regular user
RSpec.describe "User profile", logged: true do
it "is authenticated" do
expect(User.current).not_to be_an_instance_of(AnonymousUser)
end
end
# Test with anonymous user (explicit)
RSpec.describe "Public pages", logged: false do
it "allows anonymous access" do
expect(User.current).to be_an_instance_of(AnonymousUser)
end
end
Using different users in different contexts:
RSpec.describe "Dashboard" do
context "as admin", logged: :admin do
it "shows admin widgets" do
expect(User.current).to have_attributes(admin: true)
end
end
context "as regular user", logged: true do
it "shows user widgets" do
expect(User.current).not_to have_attributes(admin: false)
end
end
context "with role permissions", logged: true do
before do
Role.non_member.add_permission!(:view_dashboard)
end
it "allows access based on role" do
expect(current_user.allowed_to_globally?(:view_dashboard)).to eq true
end
end
end
Helper methods available in tests:
admin_user- Returns the admin user instanceeasy_ai_user- Returns the Easy AI user instanceregular_user- Returns the regular user instancelogged_user(user)- Manually set a specific user as currentcurrent_user- Returns the current user based on metadata
RSpec.describe "Custom user scenarios", logged: :admin do
it "can access predefined users" do
expect(admin_user.login).to eq("admin--user")
expect(regular_user.login).to eq("normal--user")
end
context "with a specific user", logged: :admin do
let(:admin_user) { FactoryBot.create(:admin_user, login: "custom--user") }
it "uses the custom user as current" do
expect(current_user.login).to eq("custom--user")
end
it "User.current object is also the custom user" do
expect(User.current.login).to eq("custom--user")
end
end
end
Important notes:
- Default users (admin, AI, and regular) are created once before the test suite runs
User.currentis automatically mocked based on theloggedmetadata- If no
loggedmetadata is specified, the user defaults to anonymous - For feature tests, default users are recreated after each test to ensure consistency
RSpec configuration notes
Partial doubles verification
We have mocks.verify_partial_doubles = true commented out in spec_helper.rb (currently disabled).
If re-enabled, RSpec would verify that any method you stub or mock with allow(obj).to receive(:method)
actually exists on the object. If it doesn't, the test would fail — catching typos and interface mismatches early.
When you genuinely need to stub a method that doesn't exist on the object (e.g. a helper method
that is mixed in at runtime), wrap the stub in without_partial_double_verification:
before do
without_partial_double_verification do
allow(view).to receive(:some_helper_method).and_return("value")
end
end
Helpers are not globally included
We set config.action_controller.include_all_helpers = false in the application configuration.
This means each controller only includes its own corresponding helper module, not all helpers from
every controller. In tests, this means view helpers from other controllers may not be available — if
you need to stub such a helper on view, you will likely need without_partial_double_verification
as shown above.
Structure
In RYSes, all specs are in the /spec folder.
On the platform (devel/devel), tests are in each plugin directory in test/spec.
Running tests locally
Serial execution (single process)
- All tests
- Specific test
bundle exec rspec PATH/TO/SPECFILE
# example in RYS
bundle exec rspec spec/services/easy_data_receiver/joomla_recurring_payment_service_spec.rb
# example in platform
bundle exec rspec plugins/easyproject/easy_plugins/easy_extensions/test/spec/models/user_spec.rb
- Specific example using line number
Parallel execution (recommended for full suite)
For faster execution when running large portions of the test suite, use parallel testing:
- Run all unit tests (excludes feature specs):
- Customize worker count (default is 12):
- Run specific directory in parallel:
Setup required: See Parallel RSpec Setup for one-time configuration steps before using parallel testing.
Performance: Parallel testing can reduce full suite execution time from ~60 minutes to ~10-15 minutes on multi-core machines (with 12 workers).
Feature tests in browser
Sometimes you need to run legacy feature tests in browser to see what's really happening and detect hidden problems. For that you need to have a chrome based browser installed and chromedriver working.
Mac
- Install chromedriver
- As an "external" app you need to add it to quarantine to run it
- Optionally you might need to update your
spec_helper.rband add path to your browser binary to capybara chrome driver registration: - Then to run the test use