ruby-on-railsselenium-webdrivercapybaradatabase-cleaner

Rails Capybara Minitest with selenium — session not persisted within test


I'm trying to get Capybara working with selenium-webdriver for the first time, with a legacy app. We have Capybara tests working that use rack_driver, but trying to implement Selenium on the same tests has not worked yet - the session is lost within a single test. I'm

Something is clearly wrong with either my test setup or helpers, or Database Cleaner config, but after reading through the Capybara docs and SO threads going back more than 10 years, i can't figure out what it is.

The test that's failing is simple, it involves logging in and creating a record with a form, which at first should fail because a field is invalid, and then validate when the fields are OK.

At some point during the test, the controller throws an error because User.current is undefined, which it should not be after login. The new and create actions both require a logged in user, so i don't understand how this integration test is even able to execute any code where User.current would be undefined, unless the session is being dropped.

Note that this same test passes, when the test is run under rack_driver.

I'm using old-school Minitest and everything seems pretty standard. I have a separate test class set up for Capybara integration tests. Note, though, that it's not a Rails System Test.

I have tried switching this to a system test with the same configuration, and it seems to make no difference. When I run the test inheriting from ActionDispatch::SystemTestCase the controller throws the same error: User.current unexpectedly becomes nil at some point after login.

class ObservationFormIntegrationTest < CapybaraIntegrationTestCase

  def test_create_minimal_observation
    rolf = users("rolf")
    login!(rolf)

    assert(page.has_link?("Create Observation"))
    click_on("Create Observation")

    assert(page.has_selector?("body.observations__new"))
    within("#observation_form") do
      fill_in("naming_name", with: "Unrealistic name")
      fill_in("observation_place_name", with: locations.first.name)
      click_commit
    end

    assert(page.has_selector?("#name_messages",
                              text: "We do not recognize the name"))
    assert_flash_warning
    assert_flash_text(
      :form_observations_there_is_a_problem_with_name.t.html_to_ascii
    )

    assert(page.has_selector?("#observation_form"))
    within("#observation_form") do
      fill_in("naming_name", with: "Coprinus comatus")
      fill_in("observation_place_name", with: locations.first.name)
      click_commit
    end

    assert(page.has_selector?("body.observations__show"))
    assert_flash_success
    assert_flash_text(:runtime_observation_success.t.html_to_ascii)
  end

So the error is thrown by the controller, that User.current is nil. Here is the error:

Minitest::UnexpectedError: NoMethodError: undefined method `projects_member' for nil:NilClass
User.current.projects_member(include: :observations).each do |project|
            ^^^^^^^^^^^^^^^^
app/controllers/observations_controller/edit_and_update.rb:163:in `update_projects'
app/controllers/observations_controller/new_and_create.rb:200:in `save_everything_else'
app/controllers/observations_controller/new_and_create.rb:116:in `create'
app/controllers/application_controller.rb:240:in `catch_errors_and_log_request_stats'

The odd thing is that if i binding.break at any point in the middle of this test, and manually execute the rest of the test assertions by command line, through to the last line, they all pass. The controller does not throw the error in that case.

This test is calling some of our test helpers. This may be where the session is lost, but until now it has handled passing the session (as self) OK.

  # Login the given user in the current session.
  def login(login = users(:zero_user).login, password = "testpassword",
            remember_me = true, session: self)
    login = login.login if login.is_a?(User) # get the right user field
    session.visit("/account/login/new")

    session.within("#account_login_form") do
      session.fill_in("user_login", with: login)
      session.fill_in("user_password", with: password)
      session.check("user_remember_me") if remember_me == true

      session.first(:button, type: "submit").click
    end
  end

  # Login the given user, testing to make sure it was successful.
  def login!(user, *args, **kwargs)
    login(user, *args, **kwargs)
    session = kwargs[:session] || self
    assert_flash_success(session: session)
    user = User.find_by(login: user) if user.is_a?(String)
    assert_equal(user.id, User.current_id, "Wrong user ended up logged in!")
  end

  def assert_flash_text(text = "", session: self)
    session.assert_selector("#flash_notices")
    session.assert_selector("#flash_notices", text: text)
  end

  def assert_flash_warning(text = "", session: self)
    session.assert_selector("#flash_notices.alert-warning")
    assert_flash_text(text, session: session) if text
  end

  def assert_flash_success(text = "", session: self)
    session.assert_selector("#flash_notices.alert-success")
    assert_flash_text(text, session: session) if text
  end

And here's my Capybara Integration test case, where everything is configured.

# Allow simuluation of user-browser interaction with capybara
require("capybara/rails")
require("capybara/minitest")
require("database_cleaner/active_record")

class CapybaraIntegrationTestCase < ActionDispatch::IntegrationTest
  # Make the Capybara DSL available in these integration tests
  include Capybara::DSL
  # Make `assert_*` methods behave like Minitest assertions
  include Capybara::Minitest::Assertions
  # Include our helpers
  include GeneralExtensions
  include FlashExtensions
  include CapybaraSessionExtensions
  include CapybaraMacros

  # Important to allow integration tests test the CSRF stuff to avoid unpleasant
  # surprises in production mode.
  def setup
    ApplicationController.allow_forgery_protection = true

    Capybara.default_max_wait_time = 2
    # default in our test_helper = true. for capybara with selenium should be false
    self.use_transactional_tests = false

    # NOTE: Shouldn't be necessary, but in case:
    # Capybara.reset_sessions!

    # needed for selenium
    Capybara.server = :webrick
    Capybara.current_driver = :selenium_headless

    # https://github.com/DatabaseCleaner/database_cleaner
    # https://github.com/DatabaseCleaner/database_cleaner#minitest-example
    # https://stackoverflow.com/questions/15675125/database-cleaner-not-working-in-minitest-rails
    DatabaseCleaner.strategy = :truncation # :transaction :truncation
    DatabaseCleaner.start

    # Treat Rails html requests as coming from non-robots.
    # If it's a bot, controllers often do not serve the expected content.
    # The requester looks like a bot to the `browser` gem because the User Agent
    # in the request is blank. I don't see an easy way to change that. -JDC
    Browser::Bot.any_instance.stubs(:bot?).returns(false)
  end

  def teardown
    Capybara.reset_sessions!
    Capybara.use_default_driver

    DatabaseCleaner.clean

    ApplicationController.allow_forgery_protection = false
  end
end

Solution

  • Figured it out. Since this was our first ever system test for this app in 15 years, it didn't occur to me that our JS autocompleters would be firing on the input fields.

    They were holding up test execution because the test wasn't waiting for the fields to be filled, and creating timeouts that caused Capybara to drop the session.

    The reason turning CSRF protection off for the tests allowed them to pass (comment above), is that it disabled the autocompleters — they were making authenticated calls to the app endpoints.

    It turns out CSRF auth in these autocompleter GET requests was unnecessary, but it's lucky I started randomly changing configs like ApplicationController.allow_forgery_protection, because this was the change that tipped me off to check the autocompleters.