smartest 0.1.0.alpha1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/exe/smartest ADDED
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+
6
+ require "smartest"
7
+
8
+ usage = <<~USAGE
9
+ Usage:
10
+ smartest [paths...]
11
+ smartest --init
12
+ smartest --version
13
+ smartest --help
14
+
15
+ When no paths are given, Smartest loads smartest/**/*_test.rb.
16
+ USAGE
17
+
18
+ command = :run
19
+
20
+ begin
21
+ if ARGV.include?("--help") || ARGV.include?("-h")
22
+ puts usage
23
+ exit 0
24
+ end
25
+
26
+ if ARGV.include?("--version") || ARGV.include?("-v")
27
+ puts Smartest::VERSION
28
+ exit 0
29
+ end
30
+
31
+ if ARGV.include?("--init")
32
+ command = :init
33
+ exit Smartest::InitGenerator.new.run
34
+ end
35
+
36
+ Smartest.disable_autorun!
37
+ Kernel.prepend Smartest::DSL
38
+ test_load_path = File.expand_path("smartest", Dir.pwd)
39
+ $LOAD_PATH.unshift(test_load_path) if Dir.exist?(test_load_path) && !$LOAD_PATH.include?(test_load_path)
40
+
41
+ files =
42
+ if ARGV.empty?
43
+ Dir["smartest/**/*_test.rb"]
44
+ else
45
+ ARGV.flat_map do |pattern|
46
+ matches = Dir[pattern]
47
+ matches.empty? ? [pattern] : matches
48
+ end.uniq
49
+ end
50
+
51
+ files.each do |file|
52
+ load File.expand_path(file)
53
+ end
54
+
55
+ exit Smartest::Runner.new.run
56
+ rescue Exception => error
57
+ raise if Smartest.fatal_exception?(error)
58
+
59
+ warn(command == :init ? "Error initializing Smartest:" : "Error loading tests:")
60
+ warn "#{error.class}: #{error.message}"
61
+ warn error.backtrace&.first
62
+ exit 1
63
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require_relative "../smartest"
5
+
6
+ Kernel.prepend Smartest::DSL
7
+
8
+ Smartest.register_autorun!
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smartest
4
+ module DSL
5
+ def test(name, **metadata, &block)
6
+ Smartest.suite.tests.add(
7
+ TestCase.new(
8
+ name: name,
9
+ metadata: metadata,
10
+ block: block,
11
+ location: caller_locations(1, 1).first
12
+ )
13
+ )
14
+ end
15
+
16
+ def use_fixture(klass)
17
+ Smartest.suite.fixture_classes.add(klass)
18
+ end
19
+
20
+ private :test, :use_fixture
21
+ end
22
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smartest
4
+ class Error < StandardError; end
5
+
6
+ class FixtureNotFoundError < Error
7
+ def initialize(name)
8
+ super("fixture not found: #{name}")
9
+ end
10
+ end
11
+
12
+ class DuplicateFixtureError < Error
13
+ def initialize(name, fixture_classes)
14
+ class_names = fixture_classes.map { |klass| klass.name || klass.inspect }
15
+
16
+ super(<<~MESSAGE.chomp)
17
+ duplicate fixture: #{name}
18
+ defined in:
19
+ #{class_names.map { |class_name| " #{class_name}" }.join("\n")}
20
+ MESSAGE
21
+ end
22
+ end
23
+
24
+ class CircularFixtureDependencyError < Error
25
+ def initialize(path)
26
+ super("circular fixture dependency: #{path.join(' -> ')}")
27
+ end
28
+ end
29
+
30
+ class InvalidFixtureScopeError < Error
31
+ def initialize(scope)
32
+ super("invalid fixture scope: #{scope.inspect}; supported scopes: test, suite")
33
+ end
34
+ end
35
+
36
+ class InvalidFixtureScopeDependencyError < Error
37
+ def initialize(dependent_name:, dependent_scope:, dependency_name:, dependency_scope:)
38
+ message =
39
+ if dependent_name
40
+ "#{dependent_scope}-scoped fixture #{dependent_name} cannot depend on #{dependency_scope}-scoped fixture #{dependency_name}"
41
+ else
42
+ "cannot resolve #{dependency_scope}-scoped fixture #{dependency_name} from #{dependent_scope} fixture scope"
43
+ end
44
+
45
+ super(message)
46
+ end
47
+ end
48
+
49
+ class InvalidFixtureParameterError < Error; end
50
+
51
+ class AssertionFailed < Error; end
52
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smartest
4
+ class ExecutionContext
5
+ include Expectations
6
+ include Matchers
7
+ end
8
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smartest
4
+ class ExpectationTarget
5
+ def initialize(actual)
6
+ @actual = actual
7
+ end
8
+
9
+ def to(matcher)
10
+ return self if matcher.matches?(@actual)
11
+
12
+ raise AssertionFailed, matcher.failure_message
13
+ end
14
+
15
+ def not_to(matcher)
16
+ return self unless matcher.matches?(@actual)
17
+
18
+ raise AssertionFailed, matcher.negated_failure_message
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smartest
4
+ module Expectations
5
+ def expect(actual = nil, &block)
6
+ ExpectationTarget.new(block || actual)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smartest
4
+ class Fixture
5
+ class << self
6
+ def fixture(name, scope: :test, &block)
7
+ define_fixture(
8
+ name,
9
+ scope: scope,
10
+ block: block,
11
+ location: caller_locations(1, 1).first
12
+ )
13
+ end
14
+
15
+ def suite_fixture(name, &block)
16
+ define_fixture(
17
+ name,
18
+ scope: :suite,
19
+ block: block,
20
+ location: caller_locations(1, 1).first
21
+ )
22
+ end
23
+
24
+ def fixture_definitions
25
+ inherited =
26
+ if superclass.respond_to?(:fixture_definitions)
27
+ superclass.fixture_definitions
28
+ else
29
+ {}
30
+ end
31
+
32
+ inherited.merge(own_fixture_definitions)
33
+ end
34
+
35
+ private
36
+
37
+ def define_fixture(name, scope:, block:, location:)
38
+ definition = FixtureDefinition.new(
39
+ name: name,
40
+ block: block,
41
+ location: location,
42
+ scope: scope
43
+ )
44
+
45
+ own_fixture_definitions[definition.name] = definition
46
+ end
47
+
48
+ def own_fixture_definitions
49
+ @fixture_definitions ||= {}
50
+ end
51
+ end
52
+
53
+ def initialize(fixture_set:, context:)
54
+ @fixture_set = fixture_set
55
+ @context = context
56
+ end
57
+
58
+ private
59
+
60
+ def cleanup(&block)
61
+ raise ArgumentError, "cleanup block is required" unless block
62
+
63
+ @fixture_set.add_cleanup(&block)
64
+ end
65
+
66
+ def method_missing(method_name, *args, &block)
67
+ if @context.respond_to?(method_name, true)
68
+ @context.__send__(method_name, *args, &block)
69
+ else
70
+ super
71
+ end
72
+ end
73
+
74
+ def respond_to_missing?(method_name, include_private = false)
75
+ @context.respond_to?(method_name, true) || super
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smartest
4
+ class FixtureClassRegistry
5
+ include Enumerable
6
+
7
+ def initialize
8
+ @fixture_classes = []
9
+ end
10
+
11
+ def add(klass)
12
+ unless klass.is_a?(Class) && klass <= Fixture
13
+ raise ArgumentError, "fixture class must inherit from Smartest::Fixture"
14
+ end
15
+
16
+ @fixture_classes << klass unless @fixture_classes.include?(klass)
17
+ end
18
+
19
+ def each(&block)
20
+ @fixture_classes.each(&block)
21
+ end
22
+
23
+ def to_a
24
+ @fixture_classes.dup
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smartest
4
+ class FixtureDefinition
5
+ VALID_SCOPES = %i[test suite].freeze
6
+
7
+ attr_reader :name, :block, :dependencies, :location, :scope
8
+
9
+ def initialize(name:, block:, location:, scope: :test)
10
+ raise ArgumentError, "fixture name is required" if name.nil? || name.to_s.empty?
11
+ raise ArgumentError, "fixture block is required" unless block
12
+
13
+ @name = name.to_sym
14
+ @block = block
15
+ @location = location
16
+ @scope = normalize_scope(scope)
17
+ @dependencies = ParameterExtractor.required_keyword_names(block, usage: :fixture)
18
+ end
19
+
20
+ private
21
+
22
+ def normalize_scope(scope)
23
+ symbol_scope = scope.to_sym
24
+ return symbol_scope if VALID_SCOPES.include?(symbol_scope)
25
+
26
+ raise InvalidFixtureScopeError, scope
27
+ rescue NoMethodError
28
+ raise InvalidFixtureScopeError, scope
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smartest
4
+ class FixtureSet
5
+ def initialize(fixture_classes, context:, scope: :test, parent: nil)
6
+ @fixture_classes = fixture_classes.to_a
7
+ @context = context
8
+ @scope = normalize_scope(scope)
9
+ @parent = parent
10
+ @cache = {}
11
+ @setup_errors = {}
12
+ @cleanups = []
13
+ @resolving = []
14
+
15
+ build_fixture_index
16
+ end
17
+
18
+ def resolve_keywords(names)
19
+ names.to_h do |name|
20
+ symbol_name = name.to_sym
21
+ [symbol_name, resolve(symbol_name)]
22
+ end
23
+ end
24
+
25
+ def resolve(name)
26
+ symbol_name = name.to_sym
27
+
28
+ if @resolving.include?(symbol_name)
29
+ cycle_start = @resolving.index(symbol_name)
30
+ raise CircularFixtureDependencyError, @resolving[cycle_start..] + [symbol_name]
31
+ end
32
+
33
+ definition = @definitions[symbol_name]
34
+ raise FixtureNotFoundError, symbol_name unless definition
35
+
36
+ return resolve_from_parent(symbol_name, definition) unless definition.scope == @scope
37
+ return @cache[symbol_name] if @cache.key?(symbol_name)
38
+ raise @setup_errors[symbol_name] if @setup_errors.key?(symbol_name)
39
+
40
+ @resolving << symbol_name
41
+ dependencies = resolve_keywords(definition.dependencies)
42
+ @cache[symbol_name] = @instances[symbol_name].instance_exec(**dependencies, &definition.block)
43
+ rescue Exception => error
44
+ raise if Smartest.fatal_exception?(error)
45
+
46
+ @setup_errors[symbol_name] = error if definition&.scope == @scope
47
+ raise
48
+ ensure
49
+ @resolving.pop if @resolving.last == symbol_name
50
+ end
51
+
52
+ def add_cleanup(&block)
53
+ raise ArgumentError, "cleanup block is required" unless block
54
+
55
+ @cleanups << block
56
+ end
57
+
58
+ def run_cleanups
59
+ errors = []
60
+
61
+ @cleanups.reverse_each do |cleanup|
62
+ cleanup.call
63
+ rescue Exception => error
64
+ raise if Smartest.fatal_exception?(error)
65
+
66
+ errors << error
67
+ end
68
+
69
+ errors
70
+ end
71
+
72
+ private
73
+
74
+ def normalize_scope(scope)
75
+ symbol_scope = scope.to_sym
76
+ return symbol_scope if FixtureDefinition::VALID_SCOPES.include?(symbol_scope)
77
+
78
+ raise InvalidFixtureScopeError, scope
79
+ rescue NoMethodError
80
+ raise InvalidFixtureScopeError, scope
81
+ end
82
+
83
+ def resolve_from_parent(name, definition)
84
+ return @parent.resolve(name) if @parent && definition.scope == :suite
85
+
86
+ raise InvalidFixtureScopeDependencyError.new(
87
+ dependent_name: @resolving.last,
88
+ dependent_scope: @scope,
89
+ dependency_name: name,
90
+ dependency_scope: definition.scope
91
+ )
92
+ end
93
+
94
+ def build_fixture_index
95
+ definitions_by_name = Hash.new { |hash, key| hash[key] = [] }
96
+ instances_by_class = {}
97
+
98
+ @fixture_classes.each do |fixture_class|
99
+ instances_by_class[fixture_class] = fixture_class.new(fixture_set: self, context: @context)
100
+
101
+ fixture_class.fixture_definitions.each_value do |definition|
102
+ definitions_by_name[definition.name] << [fixture_class, definition]
103
+ end
104
+ end
105
+
106
+ duplicate = definitions_by_name.find { |_name, entries| entries.length > 1 }
107
+ raise DuplicateFixtureError.new(duplicate.first, duplicate.last.map(&:first)) if duplicate
108
+
109
+ @definitions = {}
110
+ @instances = {}
111
+
112
+ definitions_by_name.each do |name, entries|
113
+ fixture_class, definition = entries.first
114
+ @definitions[name] = definition
115
+ @instances[name] = instances_by_class[fixture_class]
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Smartest
6
+ class InitGenerator
7
+ FILES = {
8
+ "smartest/test_helper.rb" => <<~RUBY,
9
+ # frozen_string_literal: true
10
+
11
+ require "smartest/autorun"
12
+
13
+ Dir[File.join(__dir__, "fixtures", "**", "*.rb")].sort.each do |fixture_file|
14
+ require fixture_file
15
+ end
16
+ RUBY
17
+ "smartest/example_test.rb" => <<~RUBY
18
+ # frozen_string_literal: true
19
+
20
+ require "test_helper"
21
+
22
+ test("example") do
23
+ expect(1 + 1).to eq(2)
24
+ end
25
+ RUBY
26
+ }.freeze
27
+
28
+ def initialize(root: Dir.pwd, output: $stdout)
29
+ @root = root
30
+ @output = output
31
+ end
32
+
33
+ def run
34
+ create_directory("smartest")
35
+ create_directory("smartest/fixtures")
36
+ FILES.each { |path, contents| create_file(path, contents) }
37
+
38
+ @output.puts
39
+ @output.puts "Run your test suite with: bundle exec smartest"
40
+
41
+ 0
42
+ end
43
+
44
+ private
45
+
46
+ def create_directory(path)
47
+ absolute_path = File.join(@root, path)
48
+
49
+ if Dir.exist?(absolute_path)
50
+ @output.puts "exist #{path}"
51
+ return
52
+ end
53
+
54
+ FileUtils.mkdir_p(absolute_path)
55
+ @output.puts "create #{path}"
56
+ end
57
+
58
+ def create_file(path, contents)
59
+ absolute_path = File.join(@root, path)
60
+
61
+ if File.exist?(absolute_path)
62
+ @output.puts "exist #{path}"
63
+ return
64
+ end
65
+
66
+ File.write(absolute_path, contents)
67
+ @output.puts "create #{path}"
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smartest
4
+ module Matchers
5
+ def eq(expected)
6
+ EqMatcher.new(expected)
7
+ end
8
+
9
+ def include(expected)
10
+ IncludeMatcher.new(expected)
11
+ end
12
+
13
+ def be_nil
14
+ BeNilMatcher.new
15
+ end
16
+
17
+ def raise_error(expected_error = StandardError)
18
+ RaiseErrorMatcher.new(expected_error)
19
+ end
20
+ end
21
+
22
+ class EqMatcher
23
+ def initialize(expected)
24
+ @expected = expected
25
+ end
26
+
27
+ def matches?(actual)
28
+ @actual = actual
29
+ actual == @expected
30
+ end
31
+
32
+ def failure_message
33
+ "expected #{@actual.inspect} to eq #{@expected.inspect}"
34
+ end
35
+
36
+ def negated_failure_message
37
+ "expected #{@actual.inspect} not to eq #{@expected.inspect}"
38
+ end
39
+ end
40
+
41
+ class IncludeMatcher
42
+ def initialize(expected)
43
+ @expected = expected
44
+ end
45
+
46
+ def matches?(actual)
47
+ @actual = actual
48
+ actual.include?(@expected)
49
+ rescue NoMethodError
50
+ false
51
+ end
52
+
53
+ def failure_message
54
+ "expected #{@actual.inspect} to include #{@expected.inspect}"
55
+ end
56
+
57
+ def negated_failure_message
58
+ "expected #{@actual.inspect} not to include #{@expected.inspect}"
59
+ end
60
+ end
61
+
62
+ class BeNilMatcher
63
+ def matches?(actual)
64
+ @actual = actual
65
+ actual.nil?
66
+ end
67
+
68
+ def failure_message
69
+ "expected #{@actual.inspect} to be nil"
70
+ end
71
+
72
+ def negated_failure_message
73
+ "expected #{@actual.inspect} not to be nil"
74
+ end
75
+ end
76
+
77
+ class RaiseErrorMatcher
78
+ def initialize(expected_error)
79
+ @expected_error = expected_error
80
+ @actual_error = nil
81
+ @callable = true
82
+ end
83
+
84
+ def matches?(actual)
85
+ @actual_error = nil
86
+ @callable = actual.respond_to?(:call)
87
+ return false unless @callable
88
+
89
+ actual.call
90
+ false
91
+ rescue Exception => error
92
+ raise if Smartest.fatal_exception?(error)
93
+
94
+ @actual_error = error
95
+ error.is_a?(@expected_error)
96
+ end
97
+
98
+ def failure_message
99
+ return "expected a block to raise #{@expected_error}" unless @callable
100
+ return "expected block to raise #{@expected_error}, but nothing was raised" unless @actual_error
101
+
102
+ "expected block to raise #{@expected_error}, but raised #{@actual_error.class}: #{@actual_error.message}"
103
+ end
104
+
105
+ def negated_failure_message
106
+ "expected block not to raise #{@expected_error}, but raised #{@actual_error.class}: #{@actual_error.message}"
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smartest
4
+ class ParameterExtractor
5
+ POSITIONAL_PARAMETER_TYPES = %i[req opt rest].freeze
6
+
7
+ class << self
8
+ def required_keyword_names(block, usage:)
9
+ raise ArgumentError, "block is required" unless block
10
+
11
+ parameters = block.parameters
12
+ positional = parameters.select { |type, _name| POSITIONAL_PARAMETER_TYPES.include?(type) }
13
+
14
+ raise InvalidFixtureParameterError, positional_parameter_message(usage) if positional.any?
15
+
16
+ parameters.filter_map do |type, name|
17
+ name if type == :keyreq
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def positional_parameter_message(usage)
24
+ case usage
25
+ when :test
26
+ <<~MESSAGE.chomp
27
+ Positional fixture parameters are not supported.
28
+
29
+ Use keyword fixture injection:
30
+
31
+ test("bad") do |user:|
32
+ ...
33
+ end
34
+ MESSAGE
35
+ when :fixture
36
+ <<~MESSAGE.chomp
37
+ Positional fixture dependencies are not supported.
38
+
39
+ Use keyword fixture dependencies:
40
+
41
+ fixture :client do |server:|
42
+ ...
43
+ end
44
+ MESSAGE
45
+ else
46
+ "Positional fixture parameters are not supported."
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end