diff --git a/Gemfile b/Gemfile index 4e84163..83c7374 100644 --- a/Gemfile +++ b/Gemfile @@ -24,3 +24,4 @@ gem "rspec" gem "ruby-lsp" gem "sqlite3" gem "standard" +gem "test-unit" diff --git a/Rakefile b/Rakefile index 99ab136..b796861 100644 --- a/Rakefile +++ b/Rakefile @@ -8,7 +8,13 @@ RSpec::Core::RakeTask.new require "minitest/test_task" Minitest::TestTask.create +require "rake/testtask" +Rake::TestTask.new(:test_unit) do |task| + task.libs << "lib" << "test_unit" << "test" + task.pattern = "test_unit/**/*_test.rb" +end + # standard rake task require "standard/rake" -task default: %i[spec test standard] +task default: %i[spec test test_unit standard] diff --git a/lib/with_model.rb b/lib/with_model.rb index 224f09a..26f22ec 100644 --- a/lib/with_model.rb +++ b/lib/with_model.rb @@ -113,8 +113,13 @@ def setup_object(object, scope: nil, runner: nil) class_eval do include MiniTestLifeCycle.call(object) end + when :test_unit + # These options place creation before and destruction after user callbacks, + # preserving declaration and reverse order across inheritance. + setup(before: :append) { object.create } + teardown(after: :prepend) { object.destroy } else - raise ArgumentError, "Unsupported test runner set, expected :rspec or :minitest" + raise ArgumentError, "Unsupported test runner set, expected :rspec, :minitest, or :test_unit" end end end diff --git a/test_unit/lifecycle_test.rb b/test_unit/lifecycle_test.rb new file mode 100644 index 0000000..6f3ad97 --- /dev/null +++ b/test_unit/lifecycle_test.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +require "test_unit_helper" + +class TestUnitLifecycleBaseTest < Test::Unit::TestCase + FIXTURE_EVENTS = [] + + with_model :TestUnitLifecycleParent do + table { |t| t.string "name" } + end + + def setup + FIXTURE_EVENTS.clear + assert_kind_of Class, TestUnitLifecycleParent + FIXTURE_EVENTS << :inherited_setup + end + + def teardown + assert_kind_of Class, TestUnitLifecycleParent + FIXTURE_EVENTS << :inherited_teardown + end +end + +class TestUnitLifecycleTest < TestUnitLifecycleBaseTest + with_model :TestUnitLifecycleChild do + table do |t| + t.references "parent", foreign_key: {to_table: TestUnitLifecycleParent.table_name} + end + + model { belongs_to :parent, class_name: TestUnitLifecycleParent.name } + end + + def setup + super + assert_kind_of Class, TestUnitLifecycleChild + FIXTURE_EVENTS << :local_setup + end + + def teardown + assert_kind_of Class, TestUnitLifecycleChild + FIXTURE_EVENTS << :local_teardown + super + return unless name == "test_models_are_available_through_user_fixtures" + + assert_equal [ + :inherited_setup, + :local_setup, + :test_body, + :local_teardown, + :inherited_teardown + ], FIXTURE_EVENTS + end + + def test_models_are_available_through_user_fixtures + parent = TestUnitLifecycleParent.create!(name: "parent") + child = TestUnitLifecycleChild.create!(parent: parent) + + assert_equal parent, child.reload.parent + FIXTURE_EVENTS << :test_body + end + + def test_model_destruction_unwinds_inheritance_order + parent_test_case = Class.new(Test::Unit::TestCase) do + with_model :TestUnitLifecycleOrderParent do + table + end + end + test_case = Class.new(parent_test_case) do + teardown(after: :prepend) do + assert_false Object.const_defined?(:TestUnitLifecycleOrderChild) + assert_false ActiveRecord::Base.connection.data_source_exists?(child_table_name) + assert_kind_of Class, TestUnitLifecycleOrderParent + assert ActiveRecord::Base.connection.data_source_exists?(parent_table_name) + end + + with_model :TestUnitLifecycleOrderChild do + table + end + + define_method(:parent_table_name) { TestUnitLifecycleOrderParent.table_name } + define_method(:child_table_name) { @child_table_name } + define_method(:test_lifecycle) { @child_table_name = TestUnitLifecycleOrderChild.table_name } + end + + result = Test::Unit::TestResult.new + test_case.new("test_lifecycle").run(result) {} + + assert_equal 1, result.run_count + assert_equal 0, result.error_count + end +end + +class TestUnitUserHookFailureTest < Test::Unit::TestCase + def test_setup_error_does_not_prevent_model_cleanup + test_case = Class.new(Test::Unit::TestCase) do + class << self + attr_accessor :created_table_name + end + + with_model :TestUnitSetupFailureModel do + table + end + + def setup + self.class.created_table_name = TestUnitSetupFailureModel.table_name + raise "setup failed" + end + + def test_setup_failure + end + end + + result = Test::Unit::TestResult.new + test_case.new("test_setup_failure").run(result) {} + + assert_hook_failure_cleans_up_model( + result, + RuntimeError, + "setup failed", + :TestUnitSetupFailureModel, + test_case.created_table_name + ) + end + + def test_teardown_error_does_not_prevent_model_cleanup + test_case = Class.new(Test::Unit::TestCase) do + class << self + attr_accessor :created_table_name + end + + with_model :TestUnitTeardownFailureModel do + table + end + + def teardown + self.class.created_table_name = TestUnitTeardownFailureModel.table_name + raise "teardown failed" + end + + def test_teardown_failure + end + end + + result = Test::Unit::TestResult.new + test_case.new("test_teardown_failure").run(result) {} + + assert_hook_failure_cleans_up_model( + result, + RuntimeError, + "teardown failed", + :TestUnitTeardownFailureModel, + test_case.created_table_name + ) + end + + private + + def assert_hook_failure_cleans_up_model(result, error_class, message, constant, table_name) + assert_equal 1, result.run_count + assert_equal 1, result.error_count + assert_equal error_class, result.errors.first.exception.class + assert_equal message, result.errors.first.exception.message + assert_false Object.const_defined?(constant) + assert_false ActiveRecord::Base.connection.data_source_exists?(table_name) + end +end + +class TestUnitPartialModelCreationFailureTest < Test::Unit::TestCase + def test_preserves_the_setup_error_when_model_creation_does_not_assign_a_model + test_case = Class.new(Test::Unit::TestCase) do + with_model :TestUnitPartiallyCreatedModel, superclass: Object do + table(false) + end + + def test_setup_failure + end + end + + result = Test::Unit::TestResult.new + test_case.new("test_setup_failure").run(result) {} + + assert_equal 1, result.run_count + assert_equal 1, result.error_count + assert_kind_of WithModel::InvalidSuperclass, result.errors.first.exception + end +end diff --git a/test_unit/sti_test.rb b/test_unit/sti_test.rb new file mode 100644 index 0000000..fd0ef0e --- /dev/null +++ b/test_unit/sti_test.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "test_unit_helper" + +# Stands in for an application's own model, the usual reason to want single table +# inheritance: its table is created once and outlives every test here, so a +# with_model child's rows cannot be disposed of by dropping the table. +module TestUnitAppModels + class Cupboard < ActiveRecord::Base + end +end + +ActiveRecord::Base.connection.create_table TestUnitAppModels::Cupboard.table_name, + force: true do |t| + t.string "type" + t.string "name" +end + +# A with_model parent and an STI child in the same test case. The child's +# superclass is resolved per-example, so this only works if the parent is +# created before the child and destroyed after it. +class TestUnitStiTest < Test::Unit::TestCase + with_model :TestUnitVehicle do + table do |t| + t.string "type" + t.string "name" + end + end + + with_model :TestUnitTruck, superclass: -> { TestUnitVehicle } do + table(false) + end + + with_model :TestUnitChest, superclass: "TestUnitAppModels::Cupboard" do + table(false) + end + + def test_the_child_shares_the_parents_table + assert_equal TestUnitVehicle.table_name, TestUnitTruck.table_name + end + + def test_the_child_stores_its_own_type + truck = TestUnitTruck.create!(name: "Ford F-150") + + assert_equal "TestUnitTruck", truck.reload.type + assert_instance_of TestUnitTruck, TestUnitVehicle.first + end + + # Rows in a table the child does not own have to be deleted when it goes away, + # since nothing else will: Cupboard's table is still standing afterwards, and a + # row naming a class that no longer exists makes it unloadable. Both tests write + # one and first insist the table is empty, so whichever test happens to run + # second fails if the rows survived teardown. + def test_the_childs_rows_do_not_outlive_it + assert_empty TestUnitAppModels::Cupboard.all, + "a previous test's rows outlived it" + + TestUnitChest.create!(name: "sideboard") + + assert_equal 1, TestUnitAppModels::Cupboard.count + end + + def test_the_childs_rows_leave_the_superclass_loadable + assert_empty TestUnitAppModels::Cupboard.all, + "a previous test's rows outlived it" + + TestUnitChest.create!(name: "wardrobe") + + assert_instance_of TestUnitChest, TestUnitAppModels::Cupboard.first + end +end diff --git a/test_unit/test_unit_helper.rb b/test_unit/test_unit_helper.rb new file mode 100644 index 0000000..d265b5a --- /dev/null +++ b/test_unit/test_unit_helper.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) + +require "active_record" +require "test/unit" +require "with_model" + +WithModel.runner = :test_unit + +Test::Unit::TestCase.extend WithModel + +# WithModel requires ActiveRecord::Base.connection to be established. +# If ActiveRecord already has a connection, as in a Rails app, this is unnecessary. +ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") diff --git a/test_unit/with_model_test.rb b/test_unit/with_model_test.rb new file mode 100644 index 0000000..ff99aa1 --- /dev/null +++ b/test_unit/with_model_test.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "test_unit_helper" + +class TestUnitWithModelTest < Test::Unit::TestCase + with_model :TestUnitBlogPost do + table { |t| t.string "title" } + model { define_method(:fancy_title) { "Title: #{title}" } } + end + + with_table :test_unit_widgets do |t| + t.string "name" + end + + def test_creates_a_temporary_active_record_model + record = TestUnitBlogPost.create!(title: "New blog post") + + assert_equal "New blog post", record.reload.title + end + + def test_defines_model_methods + assert_equal "Title: New blog post", TestUnitBlogPost.new(title: "New blog post").fancy_title + end + + def test_defines_the_model_constant + assert_kind_of Class, TestUnitBlogPost + end + + def test_creates_a_temporary_table + assert_true ActiveRecord::Base.connection.data_source_exists?("test_unit_widgets") + end +end + +class TestUnitPerDeclarationRunnerTest < Test::Unit::TestCase + with_model :TestUnitPerDeclarationModel, runner: :test_unit do + table + end + + def test_supports_a_test_unit_runner_override + assert_kind_of Class, TestUnitPerDeclarationModel + end +end + +class TestUnitRunnerValidationTest < Test::Unit::TestCase + def test_names_all_supported_runners_for_an_unsupported_runner + error = assert_raise(ArgumentError) do + Class.new(Test::Unit::TestCase) do + extend WithModel + + with_model :UnsupportedTestUnitModel, runner: :unsupported do + table + end + end + end + + assert_equal "Unsupported test runner set, expected :rspec, :minitest, or :test_unit", error.message + end +end