Backend testing¶
Write automated tests for your modification plugin with RSpec, rspec-rails and FactoryBot. For general advice see Better Specs. To create shared data once per example group, see Using let_it_be.
Where specs live¶
Put the specs of a plugin in its test/spec/ directory, for example plugins/easyproject/easy_plugins/modification_<name>/test/spec/models/my_model_spec.rb. Start every spec file with:
Specs of RYS engines live in the engine's spec/ directory.
Run specs¶
Run all specs of one file, or a single example by line number:
bundle exec rspec plugins/easyproject/easy_plugins/modification_<name>/test/spec/models/my_model_spec.rb
bundle exec rspec plugins/easyproject/easy_plugins/modification_<name>/test/spec/models/my_model_spec.rb:31
If the repository has a .envrc, prefix the command with direnv exec ..
RSpec conventions¶
Structure and organisation¶
- Use
describefor top-level grouping (methods, features, endpoints). - Use
contextonly for conditional branches or different states (for example "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 improve 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 and controller specs at the HTTP boundary; see Controller tests below.
- 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¶
Two clocks disagree in specs: Rails Time.zone in tests is UTC, while a machine often runs with a different system timezone (for example Europe/Prague, UTC+1/+2). Between 22:00 and 24:00 UTC (for a Prague machine), Date.today (system timezone) returns a different day than Date.current
(Rails timezone). Specs mixing the two clocks pass most of the day and then fail at night; the rules below avoid that.
Clock rules:
- Never use
Date.today,Date.tomorrow,Date.yesterday,Time.now, orDateTime.nowin specs or factories. UseDate.current,Time.current(or the equivalentTime.zone.today,Time.zone.now), andDate.current ± n.daysinstead. The only exception is an example that deliberately tests system-timezone or per-user-timezone behaviour. - Any example whose expected value is derived from run-time "now"/"today" (date arithmetic
(
Date.current + 20),beginning_of_week/end_of_month, working-day or SLA logic, hour-of-day logic) must run under frozen time. Otherwise it can fail when midnight passes mid-run, when it runs on a weekend, on the 29th to 31st, near DST transitions, or in a leap year. - Freeze time with the
travel_tometadata (provided byspec/rails_helper.rb):
RSpec.describe EasyEntityCsvImport, travel_to: "2024-06-15 09:00:00" do
it "expires the token", travel_to: true do # default instant 2024-06-15 09:00:00 UTC
or with a classic around block (around { |example| travel_to(fixed_time) { example.run } }).
- Pick frozen instants far from midnight (in both UTC and CET/CEST), month edges, DST transitions
(late March, late October), February 29, and year end. The suite default is 2024-06-15 09:00:00.
- let_it_be / before(:all) / before_all data is created before per-example hooks run, so the
travel_to metadata (and per-example around blocks) do not affect it. When such data is
date-sensitive, travel inside before_all instead:
- In feature/js (Capybara) specs, freezing Ruby time does not change the browser clock. Only normalise
clock usage there; add
travel_toonly when the asserted value is computed server-side. - When a test genuinely targets timezone behaviour, set the zone explicitly (
Time.use_zone, user'stime_zoneattribute); never rely on the machine timezone of the test runner.
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
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¶
The application sets config.action_controller.include_all_helpers = false (config/application.rb), so each controller includes only its own helper module. In view and helper specs, helpers of other controllers are not available unless you include them.
Feature specs in a browser¶
Feature specs (type: :feature, with js: true) drive Chrome through Selenium (spec/capybara_config.rb). The browser opens visibly by default, so you need a Chrome-based browser installed. Set HEADLESS=true to run headless.