Skip to content

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:

require "easy_extensions/spec_helper"

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 describe for top-level grouping (methods, features, endpoints).
  • Use context only for conditional branches or different states (for example "with valid params", "as admin").
  • Declare one subject per describe/context block; never define multiple subjects at the same level.
  • Place subject immediately after the describe/context line, before any let or let_it_be declarations.

Subjects and expectations

  • Always use named subjects: subject(:method_name) instead of anonymous subject.
  • 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_failures metadata when an example contains multiple expectations to improve performance and reduce factory overhead.

User authentication

  • Use logged: :admin, logged: true, or logged: false metadata instead of manual allow(User).to receive(:current) stubs.
  • See Logged User Configuration for details.

Performance with let_it_be

  • Scope let_it_be to the narrowest describe/context where the variable is actually used.
  • Use let_it_be(..., refind: true) when mutation or cached association risks exist.
  • See Using let_it_be for 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, or DateTime.now in specs or factories. Use Date.current, Time.current (or the equivalent Time.zone.today, Time.zone.now), and Date.current ± n.days instead. 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_to metadata (provided by spec/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:

before_all { travel_to Time.zone.parse("2024-06-15 09:00:00") }
after(:all) { travel_back }
  • In feature/js (Capybara) specs, freezing Ruby time does not change the browser clock. Only normalise clock usage there; add travel_to only when the asserted value is computed server-side.
  • When a test genuinely targets timezone behaviour, set the zone explicitly (Time.use_zone, user's time_zone attribute); 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 user
  • logged: true - Regular authenticated user
  • logged: :admin - Admin user
  • logged: :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 instance
  • easy_ai_user - Returns the Easy AI user instance
  • regular_user - Returns the regular user instance
  • logged_user(user) - Manually set a specific user as current
  • current_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.current is automatically mocked based on the logged metadata
  • If no logged metadata 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.

bundle exec rspec PATH/TO/SPECFILE