ruby_llm-test 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6320355012e11036b37b8a13d6a7bd5782e573a1e7ab1a94601d67836e3e02ab
4
- data.tar.gz: 566e774f1e9ac7fb636b5f05eeef52fc27565eb2d9fb9997f0f14b6630ba67c0
3
+ metadata.gz: ef753ca266479d9530fb44f0660cb2292bc916c2c949376c65686488bd41f5d9
4
+ data.tar.gz: f96175266a8866d9038b529b37ae0f099f1b20cdf677f591ab36acc941e566bb
5
5
  SHA512:
6
- metadata.gz: 4a5b97108962cfa24230b4733e4357f2042ed6240dbe934d466567c102aadfee07224d0762b0105121c4092879d8ae5ef8f98ef7e76146170e366057aa09f15d
7
- data.tar.gz: e30334a2adf81c0faeb482daf76a96dda06efa7664f567c2778a6aafd1e02c934686f33f9302f6533eae184f390c7f7bba7355e5b0fd09dc2408ff79276282eb
6
+ metadata.gz: e58becb7a4ae7b1d2248746f8aad685aa5a382307fe8c57887d0b6d2c170e35ffc4e97ae095cc0601ebf17e50de3374cab6060619c6ba57adfed4c85f5be576b
7
+ data.tar.gz: 76456e9ec3ed46ec76eafbae1bdebe6fd9598cce423f527c505028a43520dfa01c538341fd28c71fddfd70f36d7c47410b2cfb2fb8425fa6ba0abe6ccc6aac55
data/README.md CHANGED
@@ -1,10 +1,27 @@
1
+ [![Gem Version](https://badge.fury.io/rb/ruby_llm-test.svg)](https://badge.fury.io/rb/ruby_llm-test)
2
+ [![Tests](https://github.com/RockSolt/ruby_llm-test/actions/workflows/test.yml/badge.svg)](https://github.com/RockSolt/ruby_llm-test/actions/workflows/test.yml)
3
+ [![RuboCop](https://github.com/RockSolt/ruby_llm-test/actions/workflows/rubocop.yml/badge.svg)](https://github.com/RockSolt/ruby_llm-test/actions/workflows/rubocop.yml)
4
+
1
5
  # RubyLLM::Test
2
6
 
3
- This gem provides testing utilities for RubyLLM, a Ruby library for working with large language models (LLMs). It enables calls to LLM's to be stubbed so that the surrounding application logic can be tested without making actual calls to the LLM. This is particularly useful for testing code that interacts with LLMs, as it allows developers to simulate responses from the LLM without incurring the cost, latency, or randomness of real API calls.
7
+ How do you test your business logic when it interacts with large language models (LLMs)? From a testing point of view, an LLM is just an external system. To keep tests fast and consistent, pick a boundary then stub or mock the external system.
8
+
9
+ Part of the appeal of the RubyLLM library is that code using it does not need to know the specifics of provider APIs. The RubyLLM::Test gem brings the same benefit to your tests. No API or provider-specific knowledge is required to stub calls.
10
+
11
+ Simple stubs make for simple tests. Keep your tests fast, consistent, and thorough with RubyLLM::Test!
12
+
13
+ ```ruby
14
+ RubyLLM::Test.stub_response("Outlook good")
15
+
16
+ chat = RubyLLM.chat
17
+ response = chat.ask "What are the odds this works?"
18
+
19
+ assert_equal "Outlook good", response.content
20
+ ```
4
21
 
5
22
  ## Installation
6
23
 
7
- Add this line to your application's Gemfile:
24
+ Add this line to your application's Gemfile in the test group:
8
25
 
9
26
  ```ruby
10
27
  gem 'ruby_llm-test'
@@ -20,7 +37,7 @@ Or install it yourself as:
20
37
 
21
38
  ## Usage
22
39
 
23
- Add the following lines to your `spec/spec_helper.rb` or `test/test_helper.rb`:
40
+ Add the following lines to your `spec_helper.rb` or `test_helper.rb`:
24
41
 
25
42
  ```ruby
26
43
  require 'ruby_llm/test'
@@ -34,21 +51,22 @@ Then, in your tests, you can use the `stub_response` method to stub responses fr
34
51
  it 'returns a stubbed response' do
35
52
  RubyLLM::Test.stub_response('Hello, world!')
36
53
 
37
- response = MyLLMClient.call('Hello?')
38
- expect(response).to eq('Hello, world!')
54
+ response = RubyLLM.chat.ask 'Hello?'
55
+ expect(response.content).to eq('Hello, world!')
39
56
  end
40
57
  ```
41
58
 
42
- If you make multiple calls to the LLM, you can call `stub_response` more than once or use method `stub_responses` to stub multiple responses at once. For example:
59
+ If you make multiple calls to the LLM, you can call `stub_response` more than once or use the `stub_responses` method to stub multiple responses at once. For example:
43
60
 
44
61
  ```ruby
45
62
  it 'returns multiple stubbed responses' do
46
- RubyLLM::Test.stub_responses('Hello, world!', 'How are you?')
47
- response1 = MyLLMClient.call('Hello?')
48
- response2 = MyLLMClient.call('How are you?')
63
+ RubyLLM::Test.stub_responses('Blue.', 'No, yellow.')
64
+ chat = RubyLLM.chat
65
+ response1 = chat.ask 'What is your favorite color?'
66
+ response2 = chat.ask 'Are you sure?'
49
67
 
50
- expect(response1).to eq('Hello, world!')
51
- expect(response2).to eq('How are you?')
68
+ expect(response1.content).to eq('Blue.')
69
+ expect(response2.content).to eq('No, yellow.')
52
70
  end
53
71
  ```
54
72
 
@@ -61,7 +79,7 @@ it 'returns a stubbed message' do
61
79
  message = RubyLLM::Message.new(role: :assistant, content: 'Hello, world!')
62
80
  RubyLLM::Test.stub_response(message)
63
81
 
64
- response = MyLLMClient.call('Hello?')
82
+ response = RubyLLM.chat.ask 'Hello?'
65
83
  expect(response).to eq(message)
66
84
  end
67
85
  ```
@@ -75,14 +93,14 @@ it 'returns a stubbed JSON message' do
75
93
  hash = { key: 'value' }
76
94
  RubyLLM::Test.stub_response(hash)
77
95
 
78
- response = MyLLMClient.call('Hello?')
96
+ response = RubyLLM.chat.ask 'Hello?'
79
97
  expect(response.content).to eq(hash.to_json)
80
98
  end
81
99
  ```
82
100
 
83
101
  ### Resetting Stubs
84
102
 
85
- Make sure to reset stubs after each test to avoid interference before or between tests.
103
+ Reset stubs before each test to ensure a clean slate.
86
104
 
87
105
  ```ruby
88
106
  RubyLLM::Test.reset
@@ -93,13 +111,29 @@ RubyLLM::Test.reset
93
111
  You can also stub responses in a block, which handles the setup and teardown of stubs automatically. For example:
94
112
 
95
113
  ```ruby
96
-
97
114
  RubyLLM::Test.with_responses('Hello, world!') do
98
- response = MyLLMClient.call('Hello?')
99
- expect(response).to eq('Hello, world!')
115
+ response = RubyLLM.chat.ask 'Hello?'
116
+ expect(response.content).to eq('Hello, world!')
100
117
  end
101
118
  ```
102
119
 
120
+ ### Testing Arguments
121
+
122
+ You can verify arguments passed to the LLM by checking the requests received by the test provider with methods `requests` and `last_request`.
123
+
124
+ ```ruby
125
+ RubyLLM::Test.stub_response('Hello, world!')
126
+ chat = RubyLLM.chat(model: 'gpt-5-nano')
127
+ chat.with_tools(GreeterTool)
128
+ chat.ask('Hello?')
129
+ request = RubyLLM::Test.last_request
130
+
131
+ assert_equal 'gpt-5-nano', request.model
132
+ assert_includes request.tool_classes, GreeterTool
133
+ ```
134
+
135
+ Any parameters passed to the Provider's `complete` method are available on the request object. The `tool_classes` method is a helper that returns the classes of any tools included in the request.
136
+
103
137
  ## Development
104
138
 
105
139
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -2,27 +2,69 @@
2
2
 
3
3
  module RubyLLM
4
4
  module Test
5
- # This class encapsulates all the parameters that are passed to the `complete` method of the `Test` class. It
6
- # serves as a structured way to manage and access these parameters throughout the testing process.
5
+ # This class encapsulates all the parameters that are passed to the `complete` method of the wrapped provider. It
6
+ # stores the raw arguments and uses the wrapped provider's actual method signature to expose named accessors for
7
+ # inspection in tests.
7
8
  class CompleteParameters
8
- attr_reader :messages, :tools, :temperature, :model, :params, :headers, :schema, :thinking, :tool_prefs, :block
9
-
10
- def initialize(messages:, tools:, temperature:, model:, params:, # rubocop:disable Metrics/ParameterLists
11
- headers:, schema:, thinking:, tool_prefs:, block:)
12
- @messages = messages
13
- @tools = tools
14
- @temperature = temperature
15
- @model = model
16
- @params = params
17
- @headers = headers
18
- @schema = schema
19
- @thinking = thinking
20
- @tool_prefs = tool_prefs
9
+ attr_reader :args, :kwargs, :block, :parameter_definitions
10
+
11
+ def self.capture_from(provider, *args, **kwargs, &block)
12
+ parameters = provider.method(:complete).parameters
13
+ new(args:, kwargs:, block:, parameter_definitions: parameters)
14
+ end
15
+
16
+ def initialize(args:, kwargs:, block:, parameter_definitions:)
17
+ @args = args
18
+ @kwargs = kwargs
21
19
  @block = block
20
+ @parameter_definitions = parameter_definitions
22
21
  end
23
22
 
24
23
  def block_received?
25
- !@block.nil?
24
+ !block.nil?
25
+ end
26
+
27
+ def [](name)
28
+ name = name.to_sym
29
+ return block if name == :block
30
+
31
+ positional_name_to_value[name] || kwargs[name]
32
+ end
33
+
34
+ def key?(name)
35
+ name = name.to_sym
36
+ name == :block || positional_name_to_value.key?(name) || kwargs.key?(name)
37
+ end
38
+
39
+ def to_h
40
+ positional_name_to_value.merge(kwargs).merge(block: block)
41
+ end
42
+
43
+ def method_missing(name, *call_args)
44
+ return super unless call_args.empty?
45
+ return self[name] if key?(name)
46
+
47
+ super
48
+ end
49
+
50
+ def respond_to_missing?(name, include_private = false)
51
+ key?(name) || super
52
+ end
53
+
54
+ def tool_classes
55
+ (kwargs[:tools] || {}).values.map(&:class)
56
+ end
57
+
58
+ private
59
+
60
+ def positional_name_to_value
61
+ @positional_name_to_value ||= begin
62
+ positional_names = parameter_definitions
63
+ .select { |parameter| %i[req opt].include?(parameter.first) }
64
+ .map(&:last)
65
+
66
+ positional_names.each_with_index.to_h { |param_name, index| [ param_name, args[index] ] }
67
+ end
26
68
  end
27
69
  end
28
70
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Test
5
+ # This class serves as a test harness for stubbing responses and recording requests sent to the provider.
6
+ # It allows tests to set up expected responses and then verify that the provider received the correct
7
+ # parameters.
8
+ class Harness
9
+ attr_reader :requests
10
+
11
+ def initialize
12
+ reset
13
+ end
14
+
15
+ # Pass in a RubyLLM::Message to have full control; strings and hashes will be wrapped in a message
16
+ def stub_response(body)
17
+ responses << body
18
+ end
19
+
20
+ def stub_responses(*bodies)
21
+ responses.concat(bodies)
22
+ end
23
+
24
+ def with_responses(*bodies)
25
+ previous_responses = responses.dup
26
+ @responses = []
27
+ stub_responses(*bodies)
28
+ yield
29
+ ensure
30
+ @responses = previous_responses
31
+ end
32
+
33
+ def next_response
34
+ responses.shift
35
+ end
36
+
37
+ def responses_empty?
38
+ responses.empty?
39
+ end
40
+
41
+ def record_request(request)
42
+ requests << request
43
+ end
44
+
45
+ def last_request
46
+ requests.last
47
+ end
48
+
49
+ def clear_requests
50
+ requests.clear
51
+ end
52
+
53
+ def reset
54
+ @responses = []
55
+ @requests = []
56
+ end
57
+
58
+ private
59
+
60
+ def responses
61
+ @responses ||= []
62
+ end
63
+ end
64
+ end
65
+ end
@@ -12,7 +12,7 @@ module RubyLLM
12
12
  module ResolveWithTestProvider
13
13
  def resolve(...)
14
14
  model, provider_instance = super
15
- [ model, Test::TestProvider.new(provider_instance, RubyLLM::Test) ]
15
+ [ model, Test::TestProvider.new(provider_instance, RubyLLM::Test.send(:harness)) ]
16
16
  end
17
17
  end
18
18
  end
@@ -6,24 +6,21 @@ module RubyLLM
6
6
  # the `complete` method, allowing tests to assert that the correct parameters were passed and to simulate
7
7
  # responses from the provider.
8
8
  class TestProvider < SimpleDelegator
9
- extend Forwardable
10
-
11
- attr_reader :complete_calls
12
-
13
- def_delegators :last_call, :messages, :tools, :temperature, :model, :params, :headers, :schema, :thinking,
14
- :tool_prefs, :block_received?
15
-
16
- def initialize(provider, test_harness = RubyLLM::Test)
9
+ def initialize(provider, test_harness)
17
10
  super(provider)
18
11
  @test_harness = test_harness
19
- @complete_calls = []
20
12
  end
21
13
 
22
- def complete(messages, tools:, temperature:, model:, params: {}, # rubocop:disable Metrics/ParameterLists
23
- headers: {}, schema: nil, thinking: nil, tool_prefs: nil, &block)
24
- @complete_calls << CompleteParameters.new(messages:, tools:, temperature:, model:, params:, headers:, schema:,
25
- thinking:, tool_prefs:, block:)
26
- raise Errors::NoResponseProvidedError, messages if @test_harness.responses_empty?
14
+ # There are a number of cases that access `provider.class.display_name`, which avoids the delegation. Adding
15
+ # dummy stub for this. TBD if this needs to be more sophisticated in the future.
16
+ def self.display_name
17
+ "TestProvider"
18
+ end
19
+
20
+ def complete(...)
21
+ parameters = CompleteParameters.capture_from(__getobj__, ...)
22
+ @test_harness.record_request(parameters)
23
+ raise Errors::NoResponseProvidedError, parameters.messages if @test_harness.responses_empty?
27
24
 
28
25
  response = @test_harness.next_response
29
26
  return response if response.is_a?(Message)
@@ -33,10 +30,6 @@ module RubyLLM
33
30
  content: response.is_a?(Hash) ? response.to_json : response
34
31
  )
35
32
  end
36
-
37
- def last_call
38
- @complete_calls.last
39
- end
40
33
  end
41
34
  end
42
35
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RubyLLM
4
4
  module Test
5
- VERSION = "0.1.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
data/lib/ruby_llm/test.rb CHANGED
@@ -7,43 +7,115 @@ loader = Zeitwerk::Loader.for_gem_extension(RubyLLM)
7
7
  loader.setup
8
8
 
9
9
  module RubyLLM
10
- # The Test module provides a simple way to stub responses from an LLM for testing purposes. You can use it to set up
11
- # predetermined responses that your tests can rely on, allowing you to test your code's behavior without making
12
- # actual calls to an LLM.
10
+ # The Test module provides a simple way to stub responses from an LLM for testing purposes.
11
+ # You can use it to set up predetermined responses that your tests can rely on, allowing
12
+ # you to test your code's behavior without making actual calls to an LLM.
13
13
  module Test
14
14
  class << self
15
+ # Reset the test harness state.
16
+ #
17
+ # This clears all queued stubbed responses and all recorded requests.
18
+ #
19
+ # Use this at the start of a test, or whenever you want to ensure no state
20
+ # carries over from a previous example.
15
21
  def reset
16
- @responses = nil
22
+ harness.reset
17
23
  end
18
24
 
19
- # Pass in a RubyLLM::Message to have full control; strings and hashes will be wrapped in a message
25
+ # Queue a single stubbed response.
26
+ #
27
+ # Parameters:
28
+ #
29
+ # - `body`: The response to queue. Pass a `RubyLLM::Message` to control the
30
+ # message directly. Strings and hashes are wrapped in a message
31
+ # automatically.
32
+ #
33
+ # This is useful when your test only needs one response from the model.
34
+ #
35
+ # Example:
36
+ #
37
+ # RubyLLM::Test.stub_response("Hello from the test harness!")
38
+ #
39
+ # chat = RubyLLM.chat
40
+ # response = chat.ask("Say hello")
41
+ # response.content
42
+ # # => "Hello from the test harness!"
20
43
  def stub_response(body)
21
- responses << body
44
+ harness.stub_response(body)
22
45
  end
23
46
 
47
+ # Queue multiple stubbed responses.
48
+ #
49
+ # Parameters:
50
+ #
51
+ # - `*bodies`: One or more responses to queue.
52
+ #
53
+ # Responses are returned in the same order they are provided, making this
54
+ # useful for tests that perform multiple LLM calls in sequence.
55
+ #
56
+ # Example:
57
+ #
58
+ # RubyLLM::Test.stub_responses("First reply", "Second reply")
59
+ #
60
+ # chat = RubyLLM.chat
61
+ # first_response = chat.ask("First question")
62
+ # second_response = chat.ask("Second question")
63
+ #
64
+ # first_response.content
65
+ # # => "First reply"
66
+ # second_response.content
67
+ # # => "Second reply"
24
68
  def stub_responses(*bodies)
25
- responses.concat(bodies)
69
+ harness.stub_responses(*bodies)
26
70
  end
27
71
 
28
- def with_responses(*bodies)
29
- previous_responses = responses.dup
30
- @responses = []
31
- stub_responses(*bodies)
32
- yield
33
- ensure
34
- @responses = previous_responses
72
+ # Run a block with a temporary set of stubbed responses.
73
+ #
74
+ # Parameters:
75
+ #
76
+ # - `*bodies`: The responses to make available inside the block.
77
+ # - `&block`: The code to run while those responses are active.
78
+ #
79
+ # The provided responses are available only for the duration of the block.
80
+ # This is useful when you want to scope stubbed responses to a single part
81
+ # of a test without affecting later assertions.
82
+ #
83
+ # Example:
84
+ #
85
+ # RubyLLM::Test.with_responses("Scoped reply") do
86
+ # chat = RubyLLM.chat
87
+ # chat.ask("Question")
88
+ # end
89
+ def with_responses(*bodies, &)
90
+ harness.with_responses(*bodies, &)
35
91
  end
36
92
 
37
- def next_response
38
- responses.shift
93
+ # Return all recorded requests.
94
+ #
95
+ # This is useful for assertions about prompts, parameters, or other request
96
+ # details captured by the harness.
97
+ def requests
98
+ harness.requests
39
99
  end
40
100
 
41
- def responses_empty?
42
- responses.empty?
101
+ # Return the most recent request.
102
+ #
103
+ # Use this when you only care about the latest request made during a test.
104
+ def last_request
105
+ harness.last_request
43
106
  end
44
107
 
45
- def responses
46
- @responses ||= []
108
+ # Clear all recorded requests.
109
+ #
110
+ # This leaves queued responses unchanged.
111
+ def clear_requests
112
+ harness.clear_requests
113
+ end
114
+
115
+ private
116
+
117
+ def harness
118
+ @harness ||= Harness.new
47
119
  end
48
120
  end
49
121
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_llm-test
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Todd Kummer
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-04-22 00:00:00.000000000 Z
10
+ date: 2026-09-08 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ruby_llm
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 1.14.0
18
+ version: 1.5.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: 1.14.0
25
+ version: 1.5.0
26
26
  description: Provides a RubyLLM::Provider that allows you to stub responses for testing
27
27
  purposes. You can stub individual responses or a sequence of responses, and you
28
28
  can also temporarily stub responses within a block.
@@ -37,13 +37,16 @@ files:
37
37
  - lib/ruby_llm/test.rb
38
38
  - lib/ruby_llm/test/complete_parameters.rb
39
39
  - lib/ruby_llm/test/errors/no_response_provided_error.rb
40
+ - lib/ruby_llm/test/harness.rb
40
41
  - lib/ruby_llm/test/resolve_with_test_provider.rb
41
42
  - lib/ruby_llm/test/test_provider.rb
42
43
  - lib/ruby_llm/test/version.rb
43
44
  homepage: https://github.com/RockSolt/ruby_llm-test
44
45
  licenses:
45
46
  - MIT
46
- metadata: {}
47
+ metadata:
48
+ rubygems_mfa_required: 'true'
49
+ homepage_uri: https://github.com/RockSolt/ruby_llm-test
47
50
  rdoc_options: []
48
51
  require_paths:
49
52
  - lib