Skip to content

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.

  1. To be more familiar with RSpec we recommend you to visit this page.
  2. 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 describe for top-level grouping (methods, features, endpoints).
  • Use context only for conditional branches or different states (e.g., "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 optimize 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/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_to with a fixed time when testing date/datetime-dependent behavior (e.g., attendance, expiration, time calculations).
  • Wrap time travel in around blocks 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 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

Important 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

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
    bundle exec rspec
    
  • 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
    bundle exec rspec plugins/easyproject/easy_plugins/easy_extensions/test/spec/models/user_spec.rb:31
    

For faster execution when running large portions of the test suite, use parallel testing:

  • Run all unit tests (excludes feature specs):
bundle exec rake rspec:parallel:unit
  • Customize worker count (default is 12):
bundle exec rake "rspec:parallel:unit[6]"
  • Run specific directory in parallel:
    ./bin/parallel_rspec easy_engines/easy_extensions/spec/models
    

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
    brew install --cask chromedriver
    
  • As an "external" app you need to add it to quarantine to run it
    xattr -d com.apple.quarantine chromedriver
    
  • Optionally you might need to update your spec_helper.rb and add path to your browser binary to capybara chrome driver registration:
    # example for using Brave browser
    browser_options = Selenium::WebDriver::Chrome::Options.new(args: ENV['CHROME_OPTIONS'].to_s.split,
                                                               binary: '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser')
    
  • Then to run the test use
    JS_DRIVER=chrome bundle exec rspec PATH/TO/SPECFILE