From f20146c07180db98bd7aca697fd47c1b8ae9d514 Mon Sep 17 00:00:00 2001 From: Kirill Mokevnin Date: Fri, 28 Aug 2026 13:10:21 -0400 Subject: [PATCH 1/3] Include ActiveRecord::TestFixtures in the fixtures RBI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rails mixes `ActiveRecord::TestFixtures` into `ActiveSupport::TestCase` from the `active_record.test_fixtures` railtie initializer. Initializers do not run during gem RBI generation, so the include is missing from the gem RBI and Sorbet never sees the class methods the module contributes through `mixes_in_class_methods` — most visibly `fixtures`, so `fixtures :all` in a test case is an error under `srb tc` unless every app shims it by hand. The compiler already targets `ActiveSupport::TestCase` and knows Rails is loaded, so it is the natural place to make the include explicit. --- lib/tapioca/dsl/compilers/active_record_fixtures.rb | 9 +++++++++ manual/compiler_activerecordfixtures.md | 7 +++++++ .../dsl/compilers/active_record_fixtures_spec.rb | 10 ++++++++++ 3 files changed, 26 insertions(+) diff --git a/lib/tapioca/dsl/compilers/active_record_fixtures.rb b/lib/tapioca/dsl/compilers/active_record_fixtures.rb index a688b536e..ba9f38539 100644 --- a/lib/tapioca/dsl/compilers/active_record_fixtures.rb +++ b/lib/tapioca/dsl/compilers/active_record_fixtures.rb @@ -26,6 +26,8 @@ module Compilers # # test_case.rbi # # typed: true # class ActiveSupport::TestCase + # include ActiveRecord::TestFixtures + # # sig { returns(T::Array[Post]) } # No names: returns an Array of all fixtures # sig { params(fixture_name: T.any(String, Symbol)).returns(Post) } # One name: returns the requested fixture # sig { params(fixture_name: T.any(String, Symbol), other_fixtures: T.any(String, Symbol)) # Many names: returns an Array of the requested fixtures @@ -33,6 +35,11 @@ module Compilers # def posts(fixture_name = nil, *other_fixtures); end # end # ~~~ + # + # The `include` is generated because Rails mixes `ActiveRecord::TestFixtures` into + # `ActiveSupport::TestCase` from the `active_record.test_fixtures` railtie initializer, which + # does not run during gem RBI generation. Without it, Sorbet does not see the class methods + # the module contributes via `mixes_in_class_methods`, such as `fixtures`. #: [ConstantType = singleton(ActiveSupport::TestCase)] class ActiveRecordFixtures < Compiler MISSING = Object.new @@ -50,6 +57,8 @@ def decorate return if method_names.empty? root.create_path(constant) do |mod| + mod.create_include("ActiveRecord::TestFixtures") + method_names.each do |name| create_fixture_method(mod, name.to_s) end diff --git a/manual/compiler_activerecordfixtures.md b/manual/compiler_activerecordfixtures.md index b34fbf4db..4b3729404 100644 --- a/manual/compiler_activerecordfixtures.md +++ b/manual/compiler_activerecordfixtures.md @@ -18,6 +18,8 @@ The generated RBI by this compiler will produce the following # test_case.rbi # typed: true class ActiveSupport::TestCase + include ActiveRecord::TestFixtures + sig { returns(T::Array[Post]) } # No names: returns an Array of all fixtures sig { params(fixture_name: T.any(String, Symbol)).returns(Post) } # One name: returns the requested fixture sig { params(fixture_name: T.any(String, Symbol), other_fixtures: T.any(String, Symbol)) # Many names: returns an Array of the requested fixtures @@ -25,3 +27,8 @@ class ActiveSupport::TestCase def posts(fixture_name = nil, *other_fixtures); end end ~~~ + +The `include` is generated because Rails mixes `ActiveRecord::TestFixtures` into +`ActiveSupport::TestCase` from the `active_record.test_fixtures` railtie initializer, which +does not run during gem RBI generation. Without it, Sorbet does not see the class methods +the module contributes via `mixes_in_class_methods`, such as `fixtures`. diff --git a/spec/tapioca/dsl/compilers/active_record_fixtures_spec.rb b/spec/tapioca/dsl/compilers/active_record_fixtures_spec.rb index dd72389ea..f56bb2865 100644 --- a/spec/tapioca/dsl/compilers/active_record_fixtures_spec.rb +++ b/spec/tapioca/dsl/compilers/active_record_fixtures_spec.rb @@ -79,6 +79,8 @@ class Post < ActiveRecord::Base # typed: strong class ActiveSupport::TestCase + include ActiveRecord::TestFixtures + sig { returns(T::Array[Post]) } sig { params(fixture_name: T.any(String, Symbol)).returns(Post) } sig { params(fixture_name: T.any(String, Symbol), other_fixtures: T.any(String, Symbol)).returns(T::Array[Post]) } @@ -107,6 +109,8 @@ class Post < ActiveRecord::Base # typed: strong class ActiveSupport::TestCase + include ActiveRecord::TestFixtures + sig { returns(T::Array[Post]) } sig { params(fixture_name: T.any(String, Symbol)).returns(Post) } sig { params(fixture_name: T.any(String, Symbol), other_fixtures: T.any(String, Symbol)).returns(T::Array[Post]) } @@ -146,6 +150,8 @@ class User < ActiveRecord::Base # typed: strong class ActiveSupport::TestCase + include ActiveRecord::TestFixtures + sig { returns(T::Array[Blog::Post]) } sig { params(fixture_name: T.any(String, Symbol)).returns(Blog::Post) } sig { params(fixture_name: T.any(String, Symbol), other_fixtures: T.any(String, Symbol)).returns(T::Array[Blog::Post]) } @@ -181,6 +187,8 @@ class Post < ActiveRecord::Base # typed: strong class ActiveSupport::TestCase + include ActiveRecord::TestFixtures + sig { returns(T::Array[Post]) } sig { params(fixture_name: T.any(String, Symbol)).returns(Post) } sig { params(fixture_name: T.any(String, Symbol), other_fixtures: T.any(String, Symbol)).returns(T::Array[Post]) } @@ -204,6 +212,8 @@ def posts_with_other_names(fixture_name = nil, *other_fixtures); end # typed: strong class ActiveSupport::TestCase + include ActiveRecord::TestFixtures + sig { returns(T::Array[T.untyped]) } sig { params(fixture_name: T.any(String, Symbol)).returns(T.untyped) } sig { params(fixture_name: T.any(String, Symbol), other_fixtures: T.any(String, Symbol)).returns(T::Array[T.untyped]) } From d457ab8600dd53e3dd7c1de02ce7e09dc321a166 Mon Sep 17 00:00:00 2001 From: Kirill Mokevnin Date: Tue, 1 Sep 2026 13:37:49 -0400 Subject: [PATCH 2/3] Address review: correct the source of the include, keep it for unmapped fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes from review. The include does not come from a railtie initializer — there is none. It comes from the `:active_support_test_case` load hook in `rails/test_help.rb`, which an app requires from `test_helper.rb`, so it never runs during RBI generation. Reword the comment and the manual accordingly. The early return sat below the `select!` that drops fixture sets whose model constant cannot be resolved, so an app with fixture files but no matching models wrote `fixtures :all` and still got no include. Move the return above the `select!`: apps with no fixture files at all keep generating nothing, everything else gets the include. Co-Authored-By: Claude Opus 5 (1M context) --- .../dsl/compilers/active_record_fixtures.rb | 10 ++++++---- manual/compiler_activerecordfixtures.md | 7 ++++--- .../compilers/active_record_fixtures_spec.rb | 18 ++++++++++++++++++ 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/lib/tapioca/dsl/compilers/active_record_fixtures.rb b/lib/tapioca/dsl/compilers/active_record_fixtures.rb index ba9f38539..fc253e340 100644 --- a/lib/tapioca/dsl/compilers/active_record_fixtures.rb +++ b/lib/tapioca/dsl/compilers/active_record_fixtures.rb @@ -37,9 +37,10 @@ module Compilers # ~~~ # # The `include` is generated because Rails mixes `ActiveRecord::TestFixtures` into - # `ActiveSupport::TestCase` from the `active_record.test_fixtures` railtie initializer, which - # does not run during gem RBI generation. Without it, Sorbet does not see the class methods - # the module contributes via `mixes_in_class_methods`, such as `fixtures`. + # `ActiveSupport::TestCase` from a `:active_support_test_case` load hook in `rails/test_help.rb`, + # which is only required from an app's `test_helper.rb` and therefore never runs during RBI + # generation. Without it, Sorbet does not see the class methods the module contributes via + # `mixes_in_class_methods`, such as `fixtures`. #: [ConstantType = singleton(ActiveSupport::TestCase)] class ActiveRecordFixtures < Compiler MISSING = Object.new @@ -53,9 +54,10 @@ def decorate method_names_from_eager_fixture_loader end - method_names.select! { |name| fixture_class_mapping_from_fixture_files[name] != MISSING } return if method_names.empty? + method_names.select! { |name| fixture_class_mapping_from_fixture_files[name] != MISSING } + root.create_path(constant) do |mod| mod.create_include("ActiveRecord::TestFixtures") diff --git a/manual/compiler_activerecordfixtures.md b/manual/compiler_activerecordfixtures.md index 4b3729404..1d35818db 100644 --- a/manual/compiler_activerecordfixtures.md +++ b/manual/compiler_activerecordfixtures.md @@ -29,6 +29,7 @@ end ~~~ The `include` is generated because Rails mixes `ActiveRecord::TestFixtures` into -`ActiveSupport::TestCase` from the `active_record.test_fixtures` railtie initializer, which -does not run during gem RBI generation. Without it, Sorbet does not see the class methods -the module contributes via `mixes_in_class_methods`, such as `fixtures`. +`ActiveSupport::TestCase` from a `:active_support_test_case` load hook in `rails/test_help.rb`, +which is only required from an app's `test_helper.rb` and therefore never runs during RBI +generation. Without it, Sorbet does not see the class methods the module contributes via +`mixes_in_class_methods`, such as `fixtures`. diff --git a/spec/tapioca/dsl/compilers/active_record_fixtures_spec.rb b/spec/tapioca/dsl/compilers/active_record_fixtures_spec.rb index f56bb2865..645280586 100644 --- a/spec/tapioca/dsl/compilers/active_record_fixtures_spec.rb +++ b/spec/tapioca/dsl/compilers/active_record_fixtures_spec.rb @@ -91,6 +91,24 @@ def posts(fixture_name = nil, *other_fixtures); end assert_equal(expected, rbi_for("ActiveSupport::TestCase")) end + it "generates only the include if no fixture has an associated model" do + add_content_file("test/fixtures/serialized_data.yml", <<~YAML) + --- + field1: 123 + name: Hello + YAML + + expected = <<~RBI + # typed: strong + + class ActiveSupport::TestCase + include ActiveRecord::TestFixtures + end + RBI + + assert_equal(expected, rbi_for("ActiveSupport::TestCase")) + end + it "generates methods for fixtures" do add_content_file("test/fixtures/posts.yml", <<~YAML) super_post: From 2eae4fb568940e03f6c17c1850c0611da63baf59 Mon Sep 17 00:00:00 2001 From: Kaan Ozkan Date: Wed, 2 Sep 2026 17:47:43 +0300 Subject: [PATCH 3/3] Generate fixture mixin without accessors Remove the accessor-dependent early return so the DSL RBI always records the Rails test mixin. Combine the no-fixture output and typechecking coverage, and clarify why the runtime `include` is otherwise missing. --- .../dsl/compilers/active_record_fixtures.rb | 10 ++--- manual/compiler_activerecordfixtures.md | 8 ++-- .../compilers/active_record_fixtures_spec.rb | 40 +++++++++++++++++-- 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/lib/tapioca/dsl/compilers/active_record_fixtures.rb b/lib/tapioca/dsl/compilers/active_record_fixtures.rb index fc253e340..01b44b224 100644 --- a/lib/tapioca/dsl/compilers/active_record_fixtures.rb +++ b/lib/tapioca/dsl/compilers/active_record_fixtures.rb @@ -37,10 +37,10 @@ module Compilers # ~~~ # # The `include` is generated because Rails mixes `ActiveRecord::TestFixtures` into - # `ActiveSupport::TestCase` from a `:active_support_test_case` load hook in `rails/test_help.rb`, - # which is only required from an app's `test_helper.rb` and therefore never runs during RBI - # generation. Without it, Sorbet does not see the class methods the module contributes via - # `mixes_in_class_methods`, such as `fixtures`. + # `ActiveSupport::TestCase` through the `:active_support_test_case` load hook in + # `rails/test_help.rb`. Since RBI generation does not load an app's test helper, this runtime + # include is not captured. Without it, Sorbet does not see the class methods the module + # contributes via `mixes_in_class_methods`, such as `fixtures`. #: [ConstantType = singleton(ActiveSupport::TestCase)] class ActiveRecordFixtures < Compiler MISSING = Object.new @@ -54,8 +54,6 @@ def decorate method_names_from_eager_fixture_loader end - return if method_names.empty? - method_names.select! { |name| fixture_class_mapping_from_fixture_files[name] != MISSING } root.create_path(constant) do |mod| diff --git a/manual/compiler_activerecordfixtures.md b/manual/compiler_activerecordfixtures.md index 1d35818db..74a35a33f 100644 --- a/manual/compiler_activerecordfixtures.md +++ b/manual/compiler_activerecordfixtures.md @@ -29,7 +29,7 @@ end ~~~ The `include` is generated because Rails mixes `ActiveRecord::TestFixtures` into -`ActiveSupport::TestCase` from a `:active_support_test_case` load hook in `rails/test_help.rb`, -which is only required from an app's `test_helper.rb` and therefore never runs during RBI -generation. Without it, Sorbet does not see the class methods the module contributes via -`mixes_in_class_methods`, such as `fixtures`. +`ActiveSupport::TestCase` through the `:active_support_test_case` load hook in +`rails/test_help.rb`. Since RBI generation does not load an app's test helper, this runtime +include is not captured. Without it, Sorbet does not see the class methods the module contributes +via `mixes_in_class_methods`, such as `fixtures`. diff --git a/spec/tapioca/dsl/compilers/active_record_fixtures_spec.rb b/spec/tapioca/dsl/compilers/active_record_fixtures_spec.rb index 645280586..73f2d4400 100644 --- a/spec/tapioca/dsl/compilers/active_record_fixtures_spec.rb +++ b/spec/tapioca/dsl/compilers/active_record_fixtures_spec.rb @@ -47,12 +47,42 @@ class User assert_equal(["ActiveSupport::TestCase"], gathered_constants) end - it "does nothing if there are no fixtures" do + it "makes fixture class methods available when there are no fixtures" do expected = <<~RBI # typed: strong + + class ActiveSupport::TestCase + include ActiveRecord::TestFixtures + end RBI - assert_equal(expected, rbi_for("ActiveSupport::TestCase")) + generated_rbi = rbi_for("ActiveSupport::TestCase") + dependencies_rbi = add_content_file("dependencies.rbi", <<~RBI) + # typed: true + + module ActiveRecord::TestFixtures + mixes_in_class_methods ::ActiveRecord::TestFixtures::ClassMethods + end + + module ActiveRecord::TestFixtures::ClassMethods + def fixtures(*fixture_set_names); end + end + + class ActiveSupport::TestCase; end + RBI + generated_rbi_file = add_content_file("generated.rbi", generated_rbi) + test_file = add_content_file("test_case.rb", <<~RUBY) + # typed: true + + class PostTest < ActiveSupport::TestCase + fixtures :all + end + RUBY + + result = context.sorbet("--no-config", dependencies_rbi, generated_rbi_file, test_file) + + assert(result.status, result.err) + assert_equal(expected, generated_rbi) end it "ignores fixtures that do not have an associated model" do @@ -242,7 +272,7 @@ def posts(fixture_name = nil, *other_fixtures); end assert_equal(expected, rbi_for("ActiveSupport::TestCase")) end - it "generates no methods for file fixtures" do + it "generates only the include for file fixtures" do add_content_file("test/fixtures/files/posts.yml", <<~YAML) super_post: title: An incredible Ruby post @@ -253,6 +283,10 @@ def posts(fixture_name = nil, *other_fixtures); end expected = <<~RBI # typed: strong + + class ActiveSupport::TestCase + include ActiveRecord::TestFixtures + end RBI assert_equal(expected, rbi_for("ActiveSupport::TestCase"))