Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ActionController::TestCase: reset instance variables after each request #43735

Merged
merged 1 commit into from Dec 10, 2021

Conversation

ghiculescu
Copy link
Member

@ghiculescu ghiculescu commented Nov 27, 2021

ActionController::TestCase keeps a @controller instance variable, which represents the controller being tested. At the end of each request inside a test, its params and format are reset. But any other instance variables set in the test aren't reset. This creates a problem if you do something like this:

class UserController
  def show
    @user ||= User.find(params[:id])
    render plain: @user.name
  end
end
# ActionController::TestCase subclass

test "gets the user" do
  get :show, params: { id: users(:one).id }
  assert "one", response.body

  get :show, params: { id: users(:two).id }
  assert "two", response.body # fails; response.body is "one"
end

The second assertion will fail, because @user won't be re-assigned in the second test (due to ||=). This example is a bit contrived, but it shows how instance variables persisting between requests can lead to surprising outcomes.

This PR fixes this by clearing all instance variables that were created on the controller while a request was processed. This now matches the behaviour of ActionDispatch::IntegrationTest.

Questions

  • Is this behaviour intended? I don't know why it would be, but if it is I can update https://api.rubyonrails.org/classes/ActionController/TestCase.html instead of this change.
  • Does this need a config switch? To me it's just a bug, and the caveat below should mean it won't break most legitimate uses of this. But if we want a Rails update to never break any existing tests then it needs to be something you can opt out of. I think this should be enabled by default.

Caveats

It explicitly excludes instance variables that were created before any requests were run. And it leaves the instance variable around until the next request in the test. This means that you can still do this:

# ActionController::TestCase subclass

test "gets the user" do
  @controller.user = users(:one) # assuming `Controller#user` users an ivar internally, you can set the ivar here...

  get :show_current
  assert "one", response.body

  assert_equal users(:one), @controller.user # and you can read the ivar here
end

@rails-bot rails-bot bot added the actionpack label Nov 27, 2021
@ghiculescu ghiculescu force-pushed the as-testcase-ivars branch 3 times, most recently from 52cbd32 to f91164d Compare November 27, 2021 00:22
Comment on lines 471 to 472
@controller.purge_new_instance_variables_from_previous_requests
@controller.record_instance_variables
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't we instantiate a new controller instead?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, because then you can’t set ivars on the controller before any request runs.

While that’s not a good idea it’s possible and I’m sure lots of tests in the wild do it.

An example use case would be setting the current user.

@byroot
Copy link
Member

byroot commented Nov 27, 2021

Hum, my memory is fuzzy on this, but I think historically you weren't supposed to call two controller actions in a single tests. But to be honest I've seen this pattern a lot so it would be better to properly support it.

@ghiculescu
Copy link
Member Author

Yeah I agree. You aren’t meant to but there’s nothing in the framework that stops you, so in practice it happens all the time.

@byroot
Copy link
Member

byroot commented Dec 8, 2021

@ghiculescu yeah sorry about that one, I didn't really forget about this PR, it's just one of these things were you can't really think of a good solution. I'll think more about it and come back tomorrow.

@ghiculescu
Copy link
Member Author

Thanks. Was hoping to sneak it into 7 but no worries if it's too late.

@byroot
Copy link
Member

byroot commented Dec 8, 2021

If we find some decent solution I'll see with Rafael.

What I mean by no good solution is that I can see issues both ways with restoring the state or not.

@ghiculescu ghiculescu force-pushed the as-testcase-ivars branch 2 times, most recently from f625f94 to 8467c85 Compare December 8, 2021 20:24
Copy link
Member

@byroot byroot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I was kinda busy today and I forgot to get back to this PR.

My main concerns is that some of the pre-existing instance variables may be mutated etc, so really this solution is far from being 100% reliable.

But if we're being reasonable, it's way better than not doing it, and your added documentation recommending not to make more than one request per test is a nice touch.

Let's add some changelog entry, and it's 👍 from me.

@byroot
Copy link
Member

byroot commented Dec 9, 2021

And apologies for the delays.

…uest

`ActionController::TestCase` keeps a `@controller` instance variable, which represents the controller being tested. At the end of each request inside a test, its [params and format](https://github.com/rails/rails/blob/main/actionpack/lib/action_controller/metal/testing.rb) are reset. But any other instance variables set in the test aren't reset. This creates a problem if you do something like this:

```ruby
class UserController
  def show
    @user ||= User.find(params[:id])
    render plain: @user.name
  end
end
```

```ruby

test "gets the user" do
  get :show, params: { id: users(:one).id }
  assert "one", response.body

  get :show, params: { id: users(:two).id }
  assert "two", response.body
end
```

The second assertion will fail, because `@user` won't be re-assigned in the second test (due to `||=`). This example is a bit contrived, but it shows how instance variables persisting between requests can lead to surprising outcomes.

This PR fixes this by clearing all instance variables that were created on the controller while a request was processed.

It explicitly excludes instance variables that were created *before* any requests were run. And it leaves the instance variable around until the *next* request in the test. This means that you can still do this:

```ruby

test "gets the user" do
  @controller.user = users(:one) # assuming `Controller#user` users an ivar internally, you can set the ivar here...

  get :show_current
  assert "one", response.body

  assert_equal users(:one), @controller.user # and you can read the ivar here
end
```
@ghiculescu
Copy link
Member Author

And apologies for the delays.

No worries! No rush on this stuff, I appreciate the feedback.

@byroot byroot merged commit 09304e4 into rails:main Dec 10, 2021
@byroot
Copy link
Member

byroot commented Dec 10, 2021

cc @rafaelfranca can we backport this this 7-0-stable?

@ghiculescu ghiculescu deleted the as-testcase-ivars branch December 10, 2021 15:30
@rafaelfranca
Copy link
Member

Done!

rafaelfranca pushed a commit that referenced this pull request Dec 10, 2021
`ActionController::TestCase`: reset instance variables after each request
willnet added a commit to willnet/sorcery that referenced this pull request Jan 31, 2024
```
Failures:

  1) SorceryController with session timeout features with 'session_timeout_from_last_action' does not logout if there was activity
     Failure/Error: expect(response).to be_successful
       expected `#<ActionDispatch::TestResponse:0x0000557bab96c6d0 @mon_data=#<Monitor:0x0000557bab96c630>, @mon_data_...control={}, @request=#<ActionController::TestRequest GET "http://test.host/test_login" for 0.0.0.0>>.successful?` to return true, got false
     # ./spec/controllers/controller_session_timeout_spec.rb:146:in `block (4 levels) in <top (required)>'

Finished in 2.52 seconds (files took 1.66 seconds to load)
```

[Starting with Rails 7.0, instance variables are reset between controller test requests](rails/rails#43735).

This causes the second and subsequent requests to be judged as un-logged-in if there are no records in the DB.  Putting a record in the DB fixes the failure.
willnet added a commit to willnet/sorcery that referenced this pull request Jan 31, 2024
```
Failures:

  1) SorceryController with session timeout features with 'session_timeout_from_last_action' does not logout if there was activity
     Failure/Error: expect(response).to be_successful
       expected `#<ActionDispatch::TestResponse:0x0000557bab96c6d0 @mon_data=#<Monitor:0x0000557bab96c630>, @mon_data_...control={}, @request=#<ActionController::TestRequest GET "http://test.host/test_login" for 0.0.0.0>>.successful?` to return true, got false
     # ./spec/controllers/controller_session_timeout_spec.rb:146:in `block (4 levels) in <top (required)>'

Finished in 2.52 seconds (files took 1.66 seconds to load)
```

[Starting with Rails 7.0, instance variables are reset between controller test requests](rails/rails#43735).

This causes the second and subsequent requests to be judged as un-logged-in if there are no records in the DB.  Putting a record in the DB fixes the failure.
joshbuker added a commit to Sorcery/sorcery that referenced this pull request Mar 8, 2024
* Change CI settings for support Ruby3.0+ Rails6.1+

Now, Ruby 2.7 is EOL, and Rails 6.0 is also EOL.

So I changed the CI settings to support only those higher versions.

Fix #340

* Move rspec-rails development dependency to Gemfiles

Since the supported version of rspec-rails depends on the version of rails we use, move the description to each Gemfile

* Fix the following failure

```
Failures:

  1) SorceryController with session timeout features with 'session_timeout_from_last_action' does not logout if there was activity
     Failure/Error: expect(response).to be_successful
       expected `#<ActionDispatch::TestResponse:0x0000557bab96c6d0 @mon_data=#<Monitor:0x0000557bab96c630>, @mon_data_...control={}, @request=#<ActionController::TestRequest GET "http://test.host/test_login" for 0.0.0.0>>.successful?` to return true, got false
     # ./spec/controllers/controller_session_timeout_spec.rb:146:in `block (4 levels) in <top (required)>'

Finished in 2.52 seconds (files took 1.66 seconds to load)
```

[Starting with Rails 7.0, instance variables are reset between controller test requests](rails/rails#43735).

This causes the second and subsequent requests to be judged as un-logged-in if there are no records in the DB.  Putting a record in the DB fixes the failure.

* Fix failures of specs due to a change in the keyword argument specification

fix failing tests like the following

```
  1) SorceryController using create_from supports nested attributes
     Failure/Error: @user = user_class.create_from_provider(provider_name, @user_hash[:uid], attrs, &block)

       #<User(id: integer, username: string, email: string, crypted_password: string, salt: string, created_at: datetime, updated_at: datetime, activation_state: string, activation_token: string, activation_token_expires_at: datetime, last_login_at: datetime, last_logout_at: datetime, last_activity_at: datetime, last_login_from_ip_address: string) (class)> received :create_from_provider with unexpected arguments
         expected: ("facebook", "123", {:username=>"Haifa, Israel"}) (keyword arguments)
              got: ("facebook", "123", {:username=>"Haifa, Israel"}) (options hash)
     # ./lib/sorcery/controller/submodules/external.rb:194:in `create_from'
     # ./spec/rails_app/app/controllers/sorcery_controller.rb:456:in `test_create_from_provider'
     # ./spec/controllers/controller_oauth2_spec.rb:44:in `block (3 levels) in <top (required)>'
```

* Remove an useless spec

A spec fails like the following in Rails 7.1

```
  1) SorceryController when activated with sorcery #login when succeeds sets csrf token in session
     Failure/Error: expect(session[:_csrf_token]).not_to be_nil

       expected: not nil
            got: nil
     # ./spec/controllers/controller_spec.rb:68:in `block (5 levels) in <top (required)>'
```

Delete this because it didn't seem like a useful test.

* Add Changelog

* Readd rspec-rails to dev dependencies for running tests locally

* Do not remove the csrf test

* Gitignore new sqlite3 files

* Add pending clause for Rails 7.1 for CSRF token

---------

Co-authored-by: Josh Buker <crypto@joshbuker.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants