diff --git a/lib/tapioca/dsl/compilers/active_record_fixtures.rb b/lib/tapioca/dsl/compilers/active_record_fixtures.rb index a688b536e..01b44b224 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,12 @@ module Compilers # def posts(fixture_name = nil, *other_fixtures); end # end # ~~~ + # + # The `include` is generated because Rails mixes `ActiveRecord::TestFixtures` into + # `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 @@ -47,9 +55,10 @@ def decorate end method_names.select! { |name| fixture_class_mapping_from_fixture_files[name] != MISSING } - 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..74a35a33f 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,9 @@ class ActiveSupport::TestCase def posts(fixture_name = nil, *other_fixtures); end end ~~~ + +The `include` is generated because Rails mixes `ActiveRecord::TestFixtures` into +`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 dd72389ea..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 @@ -79,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]) } @@ -89,6 +121,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: @@ -107,6 +157,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 +198,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 +235,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 +260,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]) } @@ -214,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 @@ -225,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"))