spec_forge 0.1.0 → 0.3.0

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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -2
  3. data/README.md +366 -143
  4. data/flake.nix +2 -2
  5. data/lib/spec_forge/attribute/chainable.rb +19 -16
  6. data/lib/spec_forge/attribute/factory.rb +6 -18
  7. data/lib/spec_forge/attribute/faker.rb +56 -20
  8. data/lib/spec_forge/attribute/literal.rb +4 -0
  9. data/lib/spec_forge/attribute/resolvable.rb +4 -6
  10. data/lib/spec_forge/attribute/resolvable_array.rb +4 -0
  11. data/lib/spec_forge/attribute/resolvable_hash.rb +4 -0
  12. data/lib/spec_forge/attribute/variable.rb +6 -13
  13. data/lib/spec_forge/attribute.rb +3 -12
  14. data/lib/spec_forge/cli/init.rb +2 -9
  15. data/lib/spec_forge/configuration.rb +58 -0
  16. data/lib/spec_forge/factory.rb +4 -4
  17. data/lib/spec_forge/http/backend.rb +42 -8
  18. data/lib/spec_forge/http/client.rb +2 -2
  19. data/lib/spec_forge/http/request.rb +11 -14
  20. data/lib/spec_forge/normalizer/configuration.rb +77 -0
  21. data/lib/spec_forge/normalizer/expectation.rb +1 -0
  22. data/lib/spec_forge/normalizer/spec.rb +1 -0
  23. data/lib/spec_forge/normalizer.rb +14 -11
  24. data/lib/spec_forge/runner.rb +98 -72
  25. data/lib/spec_forge/spec/expectation/constraint.rb +2 -5
  26. data/lib/spec_forge/spec/expectation.rb +32 -13
  27. data/lib/spec_forge/spec.rb +20 -14
  28. data/lib/spec_forge/version.rb +1 -1
  29. data/lib/spec_forge.rb +20 -11
  30. data/lib/templates/forge_helper.tt +48 -0
  31. data/spec_forge/forge_helper.rb +37 -0
  32. data/spec_forge/specs/users.yml +6 -4
  33. metadata +7 -8
  34. data/lib/spec_forge/config.rb +0 -84
  35. data/lib/spec_forge/environment.rb +0 -71
  36. data/lib/spec_forge/normalizer/config.rb +0 -104
  37. data/lib/templates/config.tt +0 -19
  38. data/spec_forge/config.yml +0 -19
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Normalizer
5
+ class Configuration < Normalizer
6
+ STRUCTURE = {
7
+ base_url: SHARED_ATTRIBUTES[:base_url].except(:default), # Make it required
8
+ headers: SHARED_ATTRIBUTES[:headers],
9
+ query: SHARED_ATTRIBUTES[:query],
10
+ factories: {
11
+ type: Hash,
12
+ default: {},
13
+ structure: {
14
+ auto_discover: {
15
+ type: [TrueClass, FalseClass],
16
+ default: true
17
+ },
18
+ paths: {
19
+ type: Array,
20
+ default: []
21
+ }
22
+ }
23
+ },
24
+ on_debug: {
25
+ type: Proc
26
+ }
27
+ }.freeze
28
+ end
29
+
30
+ # On Normalizer
31
+ class << self
32
+ #
33
+ # Generates an empty configuration hash
34
+ #
35
+ # @return [Hash]
36
+ #
37
+ def default_configuration
38
+ Configuration.default
39
+ end
40
+
41
+ #
42
+ # Normalizes a configuration hash by standardizing its keys while ensuring the required data
43
+ # is provided or defaulted.
44
+ # Raises InvalidStructureError if anything is missing/invalid type
45
+ #
46
+ # @param input [Hash] The hash to normalize
47
+ #
48
+ # @return [Hash] A normalized hash as a new instance
49
+ #
50
+ def normalize_configuration!(input)
51
+ raise_errors! do
52
+ normalize_configuration(input)
53
+ end
54
+ end
55
+
56
+ #
57
+ # Normalize a configuration hash
58
+ # Used internally by .normalize_configuration!, but is available for utility
59
+ #
60
+ # @param configuration [Hash] Configuration representation as a Hash
61
+ #
62
+ # @return [Array] Two item array
63
+ # First - The normalized hash
64
+ # Second - Array of errors, if any
65
+ #
66
+ # @private
67
+ #
68
+ def normalize_configuration(configuration)
69
+ if !Type.hash?(configuration)
70
+ raise InvalidTypeError.new(configuration, Hash, for: "configuration")
71
+ end
72
+
73
+ Normalizer::Configuration.new("configuration", configuration).normalize
74
+ end
75
+ end
76
+ end
77
+ end
@@ -12,6 +12,7 @@ module SpecForge
12
12
  query: Normalizer::SHARED_ATTRIBUTES[:query],
13
13
  body: Normalizer::SHARED_ATTRIBUTES[:body],
14
14
  variables: Normalizer::SHARED_ATTRIBUTES[:variables],
15
+ debug: Normalizer::SHARED_ATTRIBUTES[:debug],
15
16
  expect: {type: Hash}
16
17
  }.freeze
17
18
  end
@@ -11,6 +11,7 @@ module SpecForge
11
11
  query: Normalizer::SHARED_ATTRIBUTES[:query],
12
12
  body: Normalizer::SHARED_ATTRIBUTES[:body],
13
13
  variables: Normalizer::SHARED_ATTRIBUTES[:variables],
14
+ debug: Normalizer::SHARED_ATTRIBUTES[:debug],
14
15
  expectations: {type: Array}
15
16
  }.freeze
16
17
  end
@@ -34,6 +34,11 @@ module SpecForge
34
34
  variables: {
35
35
  type: Hash,
36
36
  default: {}
37
+ },
38
+ debug: {
39
+ type: [TrueClass, FalseClass],
40
+ default: false,
41
+ aliases: %i[pry breakpoint]
37
42
  }
38
43
  }.freeze
39
44
 
@@ -103,10 +108,12 @@ module SpecForge
103
108
  #
104
109
  def default
105
110
  structure.transform_values do |value|
106
- if (default = value[:default])
107
- default.dup
111
+ if value.key?(:default)
112
+ value[:default].dup
108
113
  elsif value[:type] == Integer # Can't call new on int
109
114
  0
115
+ elsif value[:type] == Proc # Sameeee
116
+ -> {}
110
117
  else
111
118
  value[:type].new
112
119
  end
@@ -172,12 +179,8 @@ module SpecForge
172
179
  end
173
180
  end
174
181
 
175
- #######################################################################
176
- # These need to be required after the base class due to them requiring
177
- # a constant
178
- require_relative "normalizer/config"
179
- require_relative "normalizer/constraint"
180
- require_relative "normalizer/expectation"
181
- require_relative "normalizer/factory_reference"
182
- require_relative "normalizer/factory"
183
- require_relative "normalizer/spec"
182
+ ####################################################################################################
183
+ # These need to be required after the base class due to them requiring constants on Normalizer
184
+ Dir[File.expand_path("normalizer/*.rb", __dir__)].sort.each do |path|
185
+ require path
186
+ end
@@ -2,89 +2,115 @@
2
2
 
3
3
  module SpecForge
4
4
  class Runner
5
- #
6
- # Creates a spec runner and defines the spec with RSpec
7
- #
8
- # @param spec [Spec] The spec to run
9
- #
10
- def initialize(spec)
11
- define_spec(spec)
12
- end
5
+ class << self
6
+ #
7
+ # Runs any specs
8
+ #
9
+ def run
10
+ RSpec::Core::Runner.disable_autorun!
11
+ RSpec::Core::Runner.run([], $stderr, $stdout)
12
+ end
13
13
 
14
- #
15
- # Runs any RSpec specs
16
- #
17
- def run
18
- RSpec::Core::Runner.disable_autorun!
19
- RSpec::Core::Runner.run([], $stderr, $stdout)
20
- end
14
+ #
15
+ # Defines a spec with RSpec
16
+ #
17
+ # @param spec_forge [Spec] The spec to define
18
+ #
19
+ def define_spec(spec_forge)
20
+ runner_forge = self
21
+
22
+ RSpec.describe(spec_forge.name) do
23
+ spec_forge.expectations.each do |expectation|
24
+ # Define the example group
25
+ describe(expectation.name) do
26
+ constraints = expectation.constraints
27
+
28
+ let!(:expected_status) { constraints.status.resolve }
29
+ let!(:expected_json) { constraints.json.resolve.deep_stringify_keys }
30
+
31
+ before do
32
+ # Ensure all variables are called and resolved, in case they are not referenced
33
+ expectation.variables.resolve
34
+ end
35
+
36
+ subject(:response) { expectation.http_client.call }
37
+
38
+ it do
39
+ if spec_forge.debug? || expectation.debug?
40
+ runner_forge.handle_debug(expectation, self)
41
+ end
21
42
 
22
- #
23
- # Defines a spec with RSpec
24
- #
25
- # @param spec_forge [Spec] The spec to define
26
- #
27
- def define_spec(spec_forge)
28
- runner_forge = self
29
-
30
- RSpec.describe(spec_forge.name) do
31
- spec_forge.expectations.each do |expectation_forge|
32
- describe(expectation_forge.name) do
33
- runner_forge.define_variables(self, expectation_forge)
34
- runner_forge.define_examples(self, expectation_forge)
43
+ # Status check
44
+ expect(response.status).to eq(expected_status)
45
+
46
+ # JSON check
47
+ if constraints.json.size > 0
48
+ expect(response.body).to be_kind_of(Hash)
49
+ expect(response.body).to include(expected_json)
50
+ end
51
+ end
52
+ end
35
53
  end
36
54
  end
37
55
  end
38
- end
39
56
 
40
- #
41
- # Defines any variables as let statements in RSpec
42
- #
43
- # @param context [RSpec::ExampleGroup] The rspec example group for this spec
44
- # @param expectation [Expectation] The expectation that holds the variables
45
- #
46
- def define_variables(context, expectation)
47
- expectation.variables.each do |variable_name, attribute|
48
- context.let(variable_name, &attribute.to_proc)
57
+ def handle_debug(...)
58
+ DebugProxy.new(...).call
49
59
  end
50
60
  end
51
61
 
52
- #
53
- # Defines the expectation itself using the constraint
54
- #
55
- # @param context [RSpec::ExampleGroup] The RSpec example group for this spec
56
- # @param expectation [Expectation] The expectation that holds the constraint
57
- #
58
- def define_examples(context, expectation)
59
- context.instance_exec(expectation) do |expectation|
60
- # Ensures the only one API call occurs per expectation
61
- before(:all) { @response = expectation.http_client.call }
62
-
63
- constraints = expectation.constraints.resolve
64
- request = expectation.http_client.request
65
-
66
- # Define the example group
67
- context "#{request.http_method} #{request.url}" do
68
- subject(:response) { @response }
69
-
70
- # Status check
71
- expected_status = constraints[:status]
72
- it "expects the response to return a status code of #{expected_status}" do
73
- expect(response.status).to eq(expected_status)
74
- end
62
+ ################################################################################################
75
63
 
76
- # JSON check
77
- expected_json = constraints[:json]
78
- if expected_json.size > 0
79
- it "expects the body to return valid JSON" do
80
- expect(response.body).to be_kind_of(Hash)
81
- end
64
+ class DebugProxy
65
+ def self.default
66
+ -> { puts inspect }
67
+ end
82
68
 
83
- it "expects the body to include values" do
84
- expect(response.body).to include(expected_json)
85
- end
86
- end
87
- end
69
+ attr_reader :expectation, :variables, :expected_status, :expected_json, :request, :response
70
+
71
+ def initialize(expectation, spec_context)
72
+ @callback = SpecForge.configuration.on_debug
73
+
74
+ @expected_status = spec_context.expected_status
75
+ @expected_json = spec_context.expected_json
76
+
77
+ @request = expectation.http_client.request
78
+ @response = spec_context.response
79
+
80
+ @variables = expectation.variables
81
+ @expectation = expectation
82
+ end
83
+
84
+ def call
85
+ puts <<~STRING
86
+
87
+ Debug triggered for: #{expectation.name}
88
+
89
+ Available methods:
90
+ - expectation: Full expectation context
91
+ - variables: Current variable definitions
92
+ - expected_status: Expected HTTP status code (#{expected_status})
93
+ - expected_json: Expected response body
94
+ - request: HTTP request details (method, url, headers, body)
95
+ - response: HTTP response
96
+
97
+ Tip: Type 'self' for a JSON overview of the current state
98
+ Individual methods return full object details for advanced debugging
99
+ STRING
100
+
101
+ instance_exec(&@callback)
102
+ end
103
+
104
+ def inspect
105
+ hash = expectation.to_h
106
+
107
+ hash[:response] = {
108
+ headers: response.headers,
109
+ status: response.status,
110
+ body: response.body
111
+ }
112
+
113
+ JSON.pretty_generate(hash)
88
114
  end
89
115
  end
90
116
  end
@@ -17,11 +17,8 @@ module SpecForge
17
17
  super(status:, json: normalize_hash(json))
18
18
  end
19
19
 
20
- def resolve
21
- {
22
- status: status.resolve,
23
- json: json.resolve.deep_stringify_keys
24
- }
20
+ def to_h
21
+ super.transform_values(&:resolve)
25
22
  end
26
23
 
27
24
  private
@@ -5,6 +5,8 @@ require_relative "expectation/constraint"
5
5
  module SpecForge
6
6
  class Spec
7
7
  class Expectation
8
+ attr_predicate :debug
9
+
8
10
  attr_reader :name, :variables, :constraints, :http_client
9
11
 
10
12
  #
@@ -13,37 +15,54 @@ module SpecForge
13
15
  # @param input [Hash] A hash containing the various attributes to control the expectation
14
16
  # @param name [String] The name of the expectation
15
17
  #
16
- def initialize(name, input, global_options: {})
17
- load_name(name, input)
18
-
18
+ def initialize(input, global_options: {})
19
19
  # This allows defining spec level attributes that can be overwritten by the expectation
20
- input = Attribute.from(overlay_options(global_options, input))
20
+ input = Attribute.from(Configuration.overlay_options(global_options, input))
21
21
 
22
+ load_debug(input)
22
23
  load_variables(input)
23
24
 
24
25
  # Must be after load_variables
25
26
  load_constraints(input)
26
27
 
27
- # Must be last
28
- @http_client = HTTP::Client.new(variables:, **input.except(:name, :variables, :expect))
28
+ @http_client = HTTP::Client.new(
29
+ variables:, **input.except(:name, :variables, :expect, :debug)
30
+ )
31
+
32
+ # Must be after http_client
33
+ load_name(input)
34
+ end
35
+
36
+ def to_h
37
+ {
38
+ name:,
39
+ debug: debug?,
40
+ variables: variables.resolve,
41
+ request: http_client.request.to_h,
42
+ constraints: constraints.to_h
43
+ }
29
44
  end
30
45
 
31
46
  private
32
47
 
33
- def overlay_options(source, overlay)
34
- # Remove any blank values to avoid overwriting anything from source
35
- overlay = overlay.delete_if { |_k, v| v.blank? }
36
- source.deep_merge(overlay)
37
- end
48
+ def load_name(input)
49
+ # GET /users
50
+ @name = "#{http_client.request.http_verb.upcase} #{http_client.request.url}"
38
51
 
39
- def load_name(name, input)
40
- @name = input[:name].presence || name
52
+ # GET /users - Returns a 404
53
+ if (name = input[:name].resolve.presence)
54
+ @name += " - #{name}"
55
+ end
41
56
  end
42
57
 
43
58
  def load_variables(input)
44
59
  @variables = Attribute.bind_variables(input[:variables], input[:variables])
45
60
  end
46
61
 
62
+ def load_debug(input)
63
+ @debug = input[:debug].resolve
64
+ end
65
+
47
66
  def load_constraints(input)
48
67
  constraints = Attribute.bind_variables(input[:expect], variables)
49
68
  @constraints = Constraint.new(**constraints)
@@ -5,13 +5,13 @@ require_relative "spec/expectation"
5
5
  module SpecForge
6
6
  class Spec
7
7
  #
8
- # Loads the specs from their yml files and runs them
8
+ # Loads the specs from their yml files and defines them with the test runner
9
9
  #
10
10
  # @param path [String, Path] The base path where the specs directory is located
11
11
  #
12
- def self.load_and_run(base_path)
12
+ def self.load_and_define(base_path)
13
13
  specs = load_from_path(base_path.join("specs", "**/*.yml"))
14
- specs.each(&:run)
14
+ specs.each(&:define)
15
15
  end
16
16
 
17
17
  #
@@ -40,6 +40,8 @@ module SpecForge
40
40
 
41
41
  ############################################################################
42
42
 
43
+ attr_predicate :debug
44
+
43
45
  attr_reader :name, :file_path, :expectations
44
46
 
45
47
  #
@@ -55,23 +57,27 @@ module SpecForge
55
57
  @file_path = file_path
56
58
 
57
59
  input = Normalizer.normalize_spec!(input)
58
- global_options = input.except(:expectations)
60
+
61
+ # Don't pass this down to the expectations
62
+ @debug = input.delete(:debug) || false
63
+
64
+ global_options = normalize_global_options(input)
59
65
 
60
66
  @expectations =
61
67
  input[:expectations].map.with_index do |expectation_input, index|
62
- Expectation.new(
63
- "expectations (item #{index})",
64
- expectation_input,
65
- global_options:
66
- )
68
+ Expectation.new(expectation_input, global_options:)
67
69
  end
68
70
  end
69
71
 
70
- #
71
- # Runs the spec
72
- #
73
- def run
74
- Runner.new(self).run
72
+ def define
73
+ Runner.define_spec(self)
74
+ end
75
+
76
+ private
77
+
78
+ def normalize_global_options(input)
79
+ config = SpecForge.configuration.to_h.slice(:base_url, :headers, :query)
80
+ Configuration.overlay_options(config, input.except(:expectations))
75
81
  end
76
82
  end
77
83
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SpecForge
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/spec_forge.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "logger"
4
+
4
5
  require "active_support"
5
6
  require "active_support/core_ext"
6
7
  require "commander"
@@ -17,8 +18,7 @@ require "yaml"
17
18
 
18
19
  require_relative "spec_forge/attribute"
19
20
  require_relative "spec_forge/cli"
20
- require_relative "spec_forge/config"
21
- require_relative "spec_forge/environment"
21
+ require_relative "spec_forge/configuration"
22
22
  require_relative "spec_forge/error"
23
23
  require_relative "spec_forge/factory"
24
24
  require_relative "spec_forge/http"
@@ -35,10 +35,15 @@ module SpecForge
35
35
  # @param path [String] The file path that contains factories and specs
36
36
  #
37
37
  def self.run(path = SpecForge.forge)
38
- SpecForge.environment.load
38
+ forge_helper = path.join("forge_helper.rb")
39
+ require_relative forge_helper if File.exist?(forge_helper)
40
+
41
+ configuration.validate
39
42
 
40
43
  Factory.load_and_register(path)
41
- Spec.load_and_run(path)
44
+ Spec.load_and_define(path)
45
+
46
+ Runner.run
42
47
  end
43
48
 
44
49
  #
@@ -60,12 +65,20 @@ module SpecForge
60
65
  end
61
66
 
62
67
  #
63
- # Returns SpecForge's config
68
+ # Returns SpecForge's configuration
64
69
  #
65
70
  # @return [Config]
66
71
  #
67
- def self.config
68
- @config ||= Config.new
72
+ def self.configuration
73
+ @configuration ||= Configuration.new
74
+ end
75
+
76
+ #
77
+ # Yields SpecForge's configuration to a block
78
+ #
79
+ def self.configure(&block)
80
+ block&.call(configuration)
81
+ configuration
69
82
  end
70
83
 
71
84
  #
@@ -83,8 +96,4 @@ module SpecForge
83
96
  cleaner
84
97
  end
85
98
  end
86
-
87
- def self.environment
88
- @environment ||= Environment.new
89
- end
90
99
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##########################################
4
+ # Framework Integration
5
+ ##########################################
6
+
7
+ # Rails Integration
8
+ # require_relative "../config/environment"
9
+
10
+ # RSpec Integration (includes your existing configurations)
11
+ # require_relative "../spec/spec_helper"
12
+
13
+ # Custom requires (models, libraries, etc)
14
+ # Dir[File.join(__dir__, "..", "lib", "**", "*.rb")].sort.each { |f| require f }
15
+
16
+ ##########################################
17
+ # Configuration
18
+ ##########################################
19
+
20
+ SpecForge.configure do |config|
21
+ # Base configuration
22
+ config.base_url = "http://localhost:3000"
23
+
24
+ # Default request headers
25
+ config.headers = {
26
+ "Authorization" => "Bearer #{ENV.fetch("API_TOKEN", "")}"
27
+ }
28
+
29
+ # Optional: Default query parameters
30
+ # config.query = {api_key: ENV['API_KEY']}
31
+
32
+ # Factory configuration
33
+ # config.factories.auto_discover = false # Default: true
34
+ # config.factories.paths += ["lib/factories"] # Adds to default paths
35
+
36
+ # Debug configuration
37
+ # Available in specs with debug: true (aliases: breakpoint, pry)
38
+ # Defaults to printing state overview (-> { puts inspect })
39
+ # Available context: expectation, variables, request, response,
40
+ # expected_status, expected_json
41
+ # config.on_debug { binding.pry }
42
+
43
+ # Test Framework Configuration
44
+ # Useful for database cleaners, test data setup, etc
45
+ # config.specs.before(:suite) { }
46
+ # config.specs.around { |example| example.run }
47
+ # config.specs.formatter = :documentation
48
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ ## Using Rails? Uncomment to load your app
4
+ # ENV["RAILS_ENV"] ||= "test"
5
+ # require_relative "../config/environment"
6
+
7
+ ## Not using Rails? Load anything you need here
8
+ # Dir[SpecForge.root.join("lib", "my_api", "models", "**/*.rb")].sort.each { |path| require path }
9
+
10
+ ## Using RSpec? Uncomment to use your existing configurations
11
+ # require_relative "../spec/spec_helper"
12
+
13
+ SpecForge.configure do |config|
14
+ ## Base URL prefix for all API requests. All test paths will be appended to this URL
15
+ config.base_url = "http://localhost:3000"
16
+
17
+ ## Default request headers - commonly used for authentication and content negotiation
18
+ api_token = ENV.fetch("API_TOKEN", "")
19
+ config.headers = {
20
+ "Authorization" => "Bearer #{api_token}"
21
+ }
22
+
23
+ ## Default query parameters - useful for API keys or additional request context
24
+ # config.query = {api_token:}
25
+
26
+ ## Factory configuration options
27
+ ##
28
+ ## Enable/disable automatic factory discovery. When enabled, SpecForge will automatically
29
+ ## load factories from FactoryBot's default paths. Note: Factories defined in
30
+ ## "spec_forge/factories" are always loaded regardless of this setting.
31
+ # config.factories.auto_discover = false # Default: true
32
+
33
+ ##
34
+ ## Additional paths, relative to the project folder, for discovering FactoryBot factories
35
+ ## By default, FactoryBot looks in "spec/factories" and "test/factories"
36
+ # config.factories.paths += ["custom/factories/path"]
37
+ end
@@ -7,15 +7,17 @@ index_users:
7
7
  show_user:
8
8
  url: /users/{id}
9
9
  expectations:
10
- - expect:
10
+ - query:
11
+ id: -1
12
+ expect:
11
13
  status: 404
12
- - expect:
14
+ - query:
15
+ id: 1
16
+ expect:
13
17
  status: 200
14
18
  json:
15
19
  name: kind_of.string
16
20
  email: /\w+@example\.com/i
17
- query:
18
- id: 1
19
21
 
20
22
  create_user:
21
23
  url: /users