resteze 0.3.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +31 -0
- data/CLAUDE.md +74 -0
- data/README.md +33 -0
- data/docs/ADVANCED_USAGE.md +760 -0
- data/docs/API.md +410 -0
- data/docs/CONFIGURATION.md +681 -0
- data/docs/ERROR_HANDLING.md +609 -0
- data/docs/TESTING.md +768 -0
- data/lib/resteze/client.rb +1 -0
- data/lib/resteze/instrumentation.rb +14 -0
- data/lib/resteze/testing/minitest/configuration.rb +18 -0
- data/lib/resteze/testing/minitest/object.rb +58 -0
- data/lib/resteze/testing/minitest.rb +10 -0
- data/lib/resteze/testing/rspec/configure.rb +16 -0
- data/lib/resteze/testing/rspec/object.rb +49 -0
- data/lib/resteze/testing/rspec.rb +10 -0
- data/lib/resteze/version.rb +1 -1
- data/lib/resteze.rb +2 -0
- metadata +16 -2
data/docs/TESTING.md
ADDED
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
# Testing Guide
|
|
2
|
+
|
|
3
|
+
## Table of Contents
|
|
4
|
+
- [Testing Setup](#testing-setup)
|
|
5
|
+
- [Unit Testing](#unit-testing)
|
|
6
|
+
- [Integration Testing](#integration-testing)
|
|
7
|
+
- [Mocking and Stubbing](#mocking-and-stubbing)
|
|
8
|
+
- [Testing with VCR](#testing-with-vcr)
|
|
9
|
+
- [Testing Best Practices](#testing-best-practices)
|
|
10
|
+
- [Continuous Integration](#continuous-integration)
|
|
11
|
+
|
|
12
|
+
## Testing Setup
|
|
13
|
+
|
|
14
|
+
### Minitest Setup
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
# test/test_helper.rb
|
|
18
|
+
require 'minitest/autorun'
|
|
19
|
+
require 'minitest/spec'
|
|
20
|
+
require 'webmock/minitest'
|
|
21
|
+
require 'vcr'
|
|
22
|
+
require 'resteze'
|
|
23
|
+
require 'resteze/testing/minitest'
|
|
24
|
+
|
|
25
|
+
# Configure VCR
|
|
26
|
+
VCR.configure do |config|
|
|
27
|
+
config.cassette_library_dir = 'test/fixtures/vcr_cassettes'
|
|
28
|
+
config.hook_into :webmock
|
|
29
|
+
config.filter_sensitive_data('<API_KEY>') { ENV['API_KEY'] }
|
|
30
|
+
config.filter_sensitive_data('<API_SECRET>') { ENV['API_SECRET'] }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Test API module
|
|
34
|
+
module TestApi
|
|
35
|
+
include Resteze
|
|
36
|
+
|
|
37
|
+
configure do |config|
|
|
38
|
+
config.api_base = 'http://test.example.com/'
|
|
39
|
+
config.api_key = 'test-key'
|
|
40
|
+
config.logger = Logger.new(nil) # Disable logging in tests
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class User < ApiResource
|
|
44
|
+
property :id
|
|
45
|
+
property :email
|
|
46
|
+
property :name
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Base test class
|
|
51
|
+
class ApiTestCase < Minitest::Test
|
|
52
|
+
include Resteze::Testing::Minitest
|
|
53
|
+
|
|
54
|
+
def setup
|
|
55
|
+
WebMock.disable_net_connect!(allow_localhost: true)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def teardown
|
|
59
|
+
WebMock.reset!
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def stub_api_request(method, path)
|
|
63
|
+
stub_request(method, "#{TestApi.api_base}#{path.sub(/^\//, '')}")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### RSpec Setup
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
# spec/spec_helper.rb
|
|
72
|
+
require 'rspec'
|
|
73
|
+
require 'webmock/rspec'
|
|
74
|
+
require 'vcr'
|
|
75
|
+
require 'resteze'
|
|
76
|
+
require 'resteze/testing/rspec'
|
|
77
|
+
|
|
78
|
+
RSpec.configure do |config|
|
|
79
|
+
config.include Resteze::Testing::RSpec
|
|
80
|
+
|
|
81
|
+
config.before(:each) do
|
|
82
|
+
WebMock.disable_net_connect!(allow_localhost: true)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
config.after(:each) do
|
|
86
|
+
WebMock.reset!
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Configure VCR
|
|
91
|
+
VCR.configure do |config|
|
|
92
|
+
config.cassette_library_dir = 'spec/fixtures/vcr_cassettes'
|
|
93
|
+
config.hook_into :webmock
|
|
94
|
+
config.configure_rspec_metadata!
|
|
95
|
+
config.filter_sensitive_data('<API_KEY>') { ENV['API_KEY'] }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Shared context for API testing
|
|
99
|
+
RSpec.shared_context 'api testing' do
|
|
100
|
+
let(:test_api) do
|
|
101
|
+
Module.new do
|
|
102
|
+
include Resteze
|
|
103
|
+
|
|
104
|
+
configure do |config|
|
|
105
|
+
config.api_base = 'http://test.example.com/'
|
|
106
|
+
config.api_key = 'test-key'
|
|
107
|
+
config.logger = Logger.new(nil)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def stub_api_request(method, path)
|
|
113
|
+
stub_request(method, "http://test.example.com#{path}")
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Unit Testing
|
|
119
|
+
|
|
120
|
+
### Testing Resources
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
# test/api/user_test.rb
|
|
124
|
+
class UserTest < ApiTestCase
|
|
125
|
+
def test_retrieve_user
|
|
126
|
+
stub_api_request(:get, '/users/123')
|
|
127
|
+
.to_return(
|
|
128
|
+
status: 200,
|
|
129
|
+
body: { id: 123, email: 'user@example.com', name: 'John Doe' }.to_json,
|
|
130
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
user = TestApi::User.retrieve('123')
|
|
134
|
+
|
|
135
|
+
assert_equal 123, user.id
|
|
136
|
+
assert_equal 'user@example.com', user.email
|
|
137
|
+
assert_equal 'John Doe', user.name
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def test_user_not_found
|
|
141
|
+
stub_api_request(:get, '/users/999')
|
|
142
|
+
.to_return(status: 404, body: { error: 'Not found' }.to_json)
|
|
143
|
+
|
|
144
|
+
assert_raises(TestApi::ResourceNotFound) do
|
|
145
|
+
TestApi::User.retrieve('999')
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def test_refresh_user
|
|
150
|
+
user = TestApi::User.new('123')
|
|
151
|
+
|
|
152
|
+
stub_api_request(:get, '/users/123')
|
|
153
|
+
.to_return(
|
|
154
|
+
status: 200,
|
|
155
|
+
body: { id: 123, email: 'updated@example.com' }.to_json
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
user.refresh
|
|
159
|
+
assert_equal 'updated@example.com', user.email
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Testing Lists
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
class UserListTest < ApiTestCase
|
|
168
|
+
def setup
|
|
169
|
+
super
|
|
170
|
+
@list_response = {
|
|
171
|
+
data: [
|
|
172
|
+
{ id: 1, email: 'user1@example.com' },
|
|
173
|
+
{ id: 2, email: 'user2@example.com' }
|
|
174
|
+
],
|
|
175
|
+
total: 2,
|
|
176
|
+
page: 1,
|
|
177
|
+
per_page: 10
|
|
178
|
+
}
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def test_list_users
|
|
182
|
+
stub_api_request(:get, '/users')
|
|
183
|
+
.with(query: { page: 1, per_page: 10 })
|
|
184
|
+
.to_return(
|
|
185
|
+
status: 200,
|
|
186
|
+
body: @list_response.to_json
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
users = TestApi::User.list(page: 1, per_page: 10)
|
|
190
|
+
|
|
191
|
+
assert_equal 2, users.count
|
|
192
|
+
assert_equal 'user1@example.com', users.first.email
|
|
193
|
+
assert_equal 2, users.metadata[:total]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def test_empty_list
|
|
197
|
+
stub_api_request(:get, '/users')
|
|
198
|
+
.to_return(
|
|
199
|
+
status: 200,
|
|
200
|
+
body: { data: [], total: 0 }.to_json
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
users = TestApi::User.list
|
|
204
|
+
assert_empty users
|
|
205
|
+
assert_equal 0, users.metadata[:total]
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Testing Save Operations
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
class UserSaveTest < ApiTestCase
|
|
214
|
+
def test_create_user
|
|
215
|
+
stub_api_request(:post, '/users')
|
|
216
|
+
.with(body: { email: 'new@example.com', name: 'New User' })
|
|
217
|
+
.to_return(
|
|
218
|
+
status: 201,
|
|
219
|
+
body: { id: 123, email: 'new@example.com', name: 'New User' }.to_json
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
user = TestApi::User.new
|
|
223
|
+
user.email = 'new@example.com'
|
|
224
|
+
user.name = 'New User'
|
|
225
|
+
|
|
226
|
+
refute user.persisted?
|
|
227
|
+
user.save
|
|
228
|
+
|
|
229
|
+
assert user.persisted?
|
|
230
|
+
assert_equal 123, user.id
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def test_update_user
|
|
234
|
+
user = TestApi::User.new('123', values: { email: 'old@example.com' })
|
|
235
|
+
|
|
236
|
+
stub_api_request(:patch, '/users/123')
|
|
237
|
+
.with(body: { email: 'updated@example.com' })
|
|
238
|
+
.to_return(
|
|
239
|
+
status: 200,
|
|
240
|
+
body: { id: 123, email: 'updated@example.com' }.to_json
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
user.email = 'updated@example.com'
|
|
244
|
+
user.save
|
|
245
|
+
|
|
246
|
+
assert_equal 'updated@example.com', user.email
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def test_validation_error
|
|
250
|
+
stub_api_request(:post, '/users')
|
|
251
|
+
.with(body: { email: 'invalid' })
|
|
252
|
+
.to_return(
|
|
253
|
+
status: 422,
|
|
254
|
+
body: {
|
|
255
|
+
error: 'Validation failed',
|
|
256
|
+
errors: { email: ['is invalid'] }
|
|
257
|
+
}.to_json
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
user = TestApi::User.new
|
|
261
|
+
user.email = 'invalid'
|
|
262
|
+
|
|
263
|
+
assert_raises(TestApi::UnprocessableEntityError) do
|
|
264
|
+
user.save
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## Integration Testing
|
|
271
|
+
|
|
272
|
+
### Full Request Cycle Testing
|
|
273
|
+
|
|
274
|
+
```ruby
|
|
275
|
+
class IntegrationTest < ApiTestCase
|
|
276
|
+
def test_complete_user_lifecycle
|
|
277
|
+
# Create user
|
|
278
|
+
stub_api_request(:post, '/users')
|
|
279
|
+
.to_return(
|
|
280
|
+
status: 201,
|
|
281
|
+
body: { id: 123, email: 'test@example.com' }.to_json
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
user = TestApi::User.new
|
|
285
|
+
user.email = 'test@example.com'
|
|
286
|
+
user.save
|
|
287
|
+
|
|
288
|
+
assert_equal 123, user.id
|
|
289
|
+
|
|
290
|
+
# Update user
|
|
291
|
+
stub_api_request(:patch, '/users/123')
|
|
292
|
+
.to_return(
|
|
293
|
+
status: 200,
|
|
294
|
+
body: { id: 123, email: 'updated@example.com' }.to_json
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
user.email = 'updated@example.com'
|
|
298
|
+
user.save
|
|
299
|
+
|
|
300
|
+
# Delete user
|
|
301
|
+
stub_api_request(:delete, '/users/123')
|
|
302
|
+
.to_return(status: 204)
|
|
303
|
+
|
|
304
|
+
response = TestApi::User.request(:delete, user.resource_path)
|
|
305
|
+
assert_equal 204, response.status
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def test_pagination_flow
|
|
309
|
+
# First page
|
|
310
|
+
stub_api_request(:get, '/users')
|
|
311
|
+
.with(query: { page: 1, per_page: 2 })
|
|
312
|
+
.to_return(
|
|
313
|
+
body: {
|
|
314
|
+
data: [{ id: 1 }, { id: 2 }],
|
|
315
|
+
has_more: true,
|
|
316
|
+
page: 1
|
|
317
|
+
}.to_json
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# Second page
|
|
321
|
+
stub_api_request(:get, '/users')
|
|
322
|
+
.with(query: { page: 2, per_page: 2 })
|
|
323
|
+
.to_return(
|
|
324
|
+
body: {
|
|
325
|
+
data: [{ id: 3 }],
|
|
326
|
+
has_more: false,
|
|
327
|
+
page: 2
|
|
328
|
+
}.to_json
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
all_users = []
|
|
332
|
+
page = 1
|
|
333
|
+
loop do
|
|
334
|
+
users = TestApi::User.list(page: page, per_page: 2)
|
|
335
|
+
all_users.concat(users)
|
|
336
|
+
break unless users.metadata[:has_more]
|
|
337
|
+
page += 1
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
assert_equal 3, all_users.count
|
|
341
|
+
assert_equal [1, 2, 3], all_users.map(&:id)
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### Testing Error Recovery
|
|
347
|
+
|
|
348
|
+
```ruby
|
|
349
|
+
class ErrorRecoveryTest < ApiTestCase
|
|
350
|
+
def test_retry_on_server_error
|
|
351
|
+
call_count = 0
|
|
352
|
+
|
|
353
|
+
stub_api_request(:get, '/users/123')
|
|
354
|
+
.to_return do |request|
|
|
355
|
+
call_count += 1
|
|
356
|
+
if call_count < 3
|
|
357
|
+
{ status: 503 }
|
|
358
|
+
else
|
|
359
|
+
{
|
|
360
|
+
status: 200,
|
|
361
|
+
body: { id: 123 }.to_json
|
|
362
|
+
}
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
user = RetryableRequest.execute { TestApi::User.retrieve('123') }
|
|
367
|
+
assert_equal 123, user.id
|
|
368
|
+
assert_equal 3, call_count
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def test_circuit_breaker
|
|
372
|
+
# Multiple failures trigger circuit breaker
|
|
373
|
+
5.times do
|
|
374
|
+
stub_api_request(:get, '/users/123').to_return(status: 503)
|
|
375
|
+
|
|
376
|
+
assert_raises(TestApi::ApiError) do
|
|
377
|
+
TestApi::User.retrieve('123')
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Circuit is now open
|
|
382
|
+
assert_raises(TestApi::ApiConnectionError) do
|
|
383
|
+
TestApi::User.retrieve('123')
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
## Mocking and Stubbing
|
|
390
|
+
|
|
391
|
+
### Stubbing HTTP Requests
|
|
392
|
+
|
|
393
|
+
```ruby
|
|
394
|
+
class StubExamplesTest < ApiTestCase
|
|
395
|
+
def test_stub_with_headers
|
|
396
|
+
stub_api_request(:get, '/users/123')
|
|
397
|
+
.with(headers: { 'Authorization' => 'Bearer test-key' })
|
|
398
|
+
.to_return(
|
|
399
|
+
status: 200,
|
|
400
|
+
body: { id: 123 }.to_json,
|
|
401
|
+
headers: { 'X-Request-Id' => 'abc123' }
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
user = TestApi::User.retrieve('123')
|
|
405
|
+
assert_equal 123, user.id
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def test_stub_with_query_params
|
|
409
|
+
stub_api_request(:get, '/users')
|
|
410
|
+
.with(query: hash_including({ status: 'active' }))
|
|
411
|
+
.to_return(body: { data: [] }.to_json)
|
|
412
|
+
|
|
413
|
+
TestApi::User.list(status: 'active', page: 1)
|
|
414
|
+
# Test passes if request matches
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def test_stub_sequence
|
|
418
|
+
stub_api_request(:get, '/users/123')
|
|
419
|
+
.to_return(status: 503)
|
|
420
|
+
.then.to_return(status: 200, body: { id: 123 }.to_json)
|
|
421
|
+
|
|
422
|
+
# First call fails
|
|
423
|
+
assert_raises(TestApi::ApiError) do
|
|
424
|
+
TestApi::User.retrieve('123')
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Second call succeeds
|
|
428
|
+
user = TestApi::User.retrieve('123')
|
|
429
|
+
assert_equal 123, user.id
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
### Mocking Client Behavior
|
|
435
|
+
|
|
436
|
+
```ruby
|
|
437
|
+
class ClientMockTest < ApiTestCase
|
|
438
|
+
def test_mock_client
|
|
439
|
+
mock_client = Minitest::Mock.new
|
|
440
|
+
mock_response = TestApi::Response.new(
|
|
441
|
+
status: 200,
|
|
442
|
+
body: { id: 123 }.to_json,
|
|
443
|
+
headers: {}
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
mock_client.expect(:execute_request, mock_response, [
|
|
447
|
+
:get, '/users/123', { params: {}, headers: {} }
|
|
448
|
+
])
|
|
449
|
+
|
|
450
|
+
TestApi::Client.stub :active_client, mock_client do
|
|
451
|
+
user = TestApi::User.retrieve('123')
|
|
452
|
+
assert_equal 123, user.id
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
mock_client.verify
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def test_mock_connection
|
|
459
|
+
mock_connection = Minitest::Mock.new
|
|
460
|
+
|
|
461
|
+
TestApi::Client.stub :default_connection, mock_connection do
|
|
462
|
+
client = TestApi::Client.new
|
|
463
|
+
assert_equal mock_connection, client.connection
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
## Testing with VCR
|
|
470
|
+
|
|
471
|
+
### Basic VCR Usage
|
|
472
|
+
|
|
473
|
+
```ruby
|
|
474
|
+
class VcrTest < ApiTestCase
|
|
475
|
+
def test_real_api_call
|
|
476
|
+
VCR.use_cassette('user_retrieve') do
|
|
477
|
+
user = TestApi::User.retrieve('123')
|
|
478
|
+
assert_equal 'John Doe', user.name
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def test_list_with_vcr
|
|
483
|
+
VCR.use_cassette('user_list', record: :new_episodes) do
|
|
484
|
+
users = TestApi::User.list
|
|
485
|
+
assert users.count > 0
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
### RSpec with VCR Metadata
|
|
492
|
+
|
|
493
|
+
```ruby
|
|
494
|
+
RSpec.describe TestApi::User, :vcr do
|
|
495
|
+
describe '.retrieve' do
|
|
496
|
+
it 'fetches user from API' do
|
|
497
|
+
# Automatically uses cassette named after test
|
|
498
|
+
user = described_class.retrieve('123')
|
|
499
|
+
expect(user.name).to eq('John Doe')
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
describe '.list', vcr: { cassette_name: 'custom_user_list' } do
|
|
504
|
+
it 'returns list of users' do
|
|
505
|
+
users = described_class.list
|
|
506
|
+
expect(users).not_to be_empty
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
### VCR Configuration for Different Environments
|
|
513
|
+
|
|
514
|
+
```ruby
|
|
515
|
+
VCR.configure do |config|
|
|
516
|
+
config.cassette_library_dir = 'test/fixtures/vcr_cassettes'
|
|
517
|
+
config.hook_into :webmock
|
|
518
|
+
|
|
519
|
+
# Filter sensitive data
|
|
520
|
+
config.filter_sensitive_data('<API_KEY>') { ENV['API_KEY'] }
|
|
521
|
+
config.filter_sensitive_data('<API_SECRET>') { ENV['API_SECRET'] }
|
|
522
|
+
|
|
523
|
+
# Custom matchers
|
|
524
|
+
config.default_cassette_options = {
|
|
525
|
+
match_requests_on: [:method, :uri, :body],
|
|
526
|
+
record: ENV['VCR_RECORD_MODE'] || :once,
|
|
527
|
+
allow_playback_repeats: true
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
# Ignore localhost
|
|
531
|
+
config.ignore_localhost = true
|
|
532
|
+
|
|
533
|
+
# Allow real requests in CI
|
|
534
|
+
config.allow_http_connections_when_no_cassette = ENV['CI'].nil?
|
|
535
|
+
|
|
536
|
+
# Re-record cassettes periodically
|
|
537
|
+
config.before_record do |interaction|
|
|
538
|
+
# Remove dynamic headers
|
|
539
|
+
interaction.request.headers.delete('X-Request-Id')
|
|
540
|
+
interaction.response.headers.delete('X-Request-Id')
|
|
541
|
+
|
|
542
|
+
# Expire cassettes after 7 days
|
|
543
|
+
interaction.response.headers['X-VCR-Recorded-At'] = Time.now.utc.to_s
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
## Testing Best Practices
|
|
549
|
+
|
|
550
|
+
### 1. Test Organization
|
|
551
|
+
|
|
552
|
+
```ruby
|
|
553
|
+
# test/unit/resources/user_test.rb
|
|
554
|
+
class UserResourceTest < ApiTestCase
|
|
555
|
+
# Test resource methods
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# test/unit/client_test.rb
|
|
559
|
+
class ClientTest < ApiTestCase
|
|
560
|
+
# Test client behavior
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# test/integration/user_workflow_test.rb
|
|
564
|
+
class UserWorkflowTest < ApiTestCase
|
|
565
|
+
# Test complete workflows
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
# test/performance/api_performance_test.rb
|
|
569
|
+
class ApiPerformanceTest < ApiTestCase
|
|
570
|
+
# Test performance characteristics
|
|
571
|
+
end
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
### 2. Test Helpers
|
|
575
|
+
|
|
576
|
+
```ruby
|
|
577
|
+
module ApiTestHelpers
|
|
578
|
+
def create_test_user(attributes = {})
|
|
579
|
+
default_attributes = {
|
|
580
|
+
id: rand(1000),
|
|
581
|
+
email: "test#{rand(1000)}@example.com",
|
|
582
|
+
name: 'Test User'
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
TestApi::User.new(
|
|
586
|
+
default_attributes[:id],
|
|
587
|
+
values: default_attributes.merge(attributes)
|
|
588
|
+
)
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def stub_successful_response(method, path, response_body)
|
|
592
|
+
stub_api_request(method, path).to_return(
|
|
593
|
+
status: 200,
|
|
594
|
+
body: response_body.to_json,
|
|
595
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
596
|
+
)
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def stub_error_response(method, path, status, error_message = nil)
|
|
600
|
+
body = error_message ? { error: error_message } : {}
|
|
601
|
+
stub_api_request(method, path).to_return(
|
|
602
|
+
status: status,
|
|
603
|
+
body: body.to_json
|
|
604
|
+
)
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
def assert_api_called(method, path, times: 1)
|
|
608
|
+
assert_requested(method, "#{TestApi.api_base}#{path.sub(/^\//, '')}", times: times)
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
class ApiTestCase < Minitest::Test
|
|
613
|
+
include ApiTestHelpers
|
|
614
|
+
end
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
### 3. Testing Custom Configurations
|
|
618
|
+
|
|
619
|
+
```ruby
|
|
620
|
+
class ConfigurationTest < ApiTestCase
|
|
621
|
+
def test_custom_configuration
|
|
622
|
+
original_base = TestApi.api_base
|
|
623
|
+
|
|
624
|
+
TestApi.configure do |config|
|
|
625
|
+
config.api_base = 'https://custom.example.com/'
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
assert_equal 'https://custom.example.com/', TestApi.api_base
|
|
629
|
+
ensure
|
|
630
|
+
TestApi.api_base = original_base
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def test_environment_specific_config
|
|
634
|
+
ENV['API_ENV'] = 'staging'
|
|
635
|
+
|
|
636
|
+
TestApi.configure do |config|
|
|
637
|
+
config.api_base = case ENV['API_ENV']
|
|
638
|
+
when 'staging' then 'https://staging.example.com/'
|
|
639
|
+
else 'https://prod.example.com/'
|
|
640
|
+
end
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
assert_equal 'https://staging.example.com/', TestApi.api_base
|
|
644
|
+
ensure
|
|
645
|
+
ENV.delete('API_ENV')
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
### 4. Testing Middleware
|
|
651
|
+
|
|
652
|
+
```ruby
|
|
653
|
+
class MiddlewareTest < ApiTestCase
|
|
654
|
+
def test_custom_middleware
|
|
655
|
+
TestApi::Client.class_eval do
|
|
656
|
+
def self.default_connection
|
|
657
|
+
@default_connection ||= Faraday.new do |conn|
|
|
658
|
+
conn.use TestMiddleware
|
|
659
|
+
conn.adapter :test do |stub|
|
|
660
|
+
stub.get('/test') { [200, {}, 'OK'] }
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
response = TestApi::Client.active_client.execute_request(:get, '/test')
|
|
667
|
+
assert_equal 'Modified by middleware', response.headers['X-Test-Header']
|
|
668
|
+
end
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
class TestMiddleware < Faraday::Middleware
|
|
672
|
+
def call(env)
|
|
673
|
+
@app.call(env).on_complete do |response_env|
|
|
674
|
+
response_env[:response_headers]['X-Test-Header'] = 'Modified by middleware'
|
|
675
|
+
end
|
|
676
|
+
end
|
|
677
|
+
end
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
## Continuous Integration
|
|
681
|
+
|
|
682
|
+
### GitHub Actions Configuration
|
|
683
|
+
|
|
684
|
+
```yaml
|
|
685
|
+
# .github/workflows/test.yml
|
|
686
|
+
name: Tests
|
|
687
|
+
|
|
688
|
+
on: [push, pull_request]
|
|
689
|
+
|
|
690
|
+
jobs:
|
|
691
|
+
test:
|
|
692
|
+
runs-on: ubuntu-latest
|
|
693
|
+
strategy:
|
|
694
|
+
matrix:
|
|
695
|
+
ruby-version: ['3.0', '3.1', '3.2', '3.3']
|
|
696
|
+
|
|
697
|
+
steps:
|
|
698
|
+
- uses: actions/checkout@v2
|
|
699
|
+
|
|
700
|
+
- name: Set up Ruby
|
|
701
|
+
uses: ruby/setup-ruby@v1
|
|
702
|
+
with:
|
|
703
|
+
ruby-version: ${{ matrix.ruby-version }}
|
|
704
|
+
bundler-cache: true
|
|
705
|
+
|
|
706
|
+
- name: Run tests
|
|
707
|
+
env:
|
|
708
|
+
API_KEY: test-key
|
|
709
|
+
API_SECRET: test-secret
|
|
710
|
+
run: |
|
|
711
|
+
bundle exec rake test
|
|
712
|
+
bundle exec rake test:integration
|
|
713
|
+
|
|
714
|
+
- name: Upload coverage
|
|
715
|
+
uses: codecov/codecov-action@v2
|
|
716
|
+
with:
|
|
717
|
+
files: ./coverage/coverage.xml
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
### Test Coverage
|
|
721
|
+
|
|
722
|
+
```ruby
|
|
723
|
+
# test/test_helper.rb
|
|
724
|
+
require 'simplecov'
|
|
725
|
+
SimpleCov.start do
|
|
726
|
+
add_filter '/test/'
|
|
727
|
+
add_filter '/spec/'
|
|
728
|
+
add_group 'Resources', 'lib/api/resources'
|
|
729
|
+
add_group 'Client', 'lib/api/client'
|
|
730
|
+
add_group 'Middleware', 'lib/api/middleware'
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
# Ensure minimum coverage
|
|
734
|
+
SimpleCov.minimum_coverage 90
|
|
735
|
+
SimpleCov.minimum_coverage_by_file 80
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
### Performance Testing
|
|
739
|
+
|
|
740
|
+
```ruby
|
|
741
|
+
require 'benchmark'
|
|
742
|
+
|
|
743
|
+
class PerformanceTest < ApiTestCase
|
|
744
|
+
def test_resource_creation_performance
|
|
745
|
+
time = Benchmark.realtime do
|
|
746
|
+
1000.times do
|
|
747
|
+
TestApi::User.new('123', values: { email: 'test@example.com' })
|
|
748
|
+
end
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
assert time < 1.0, "Resource creation took #{time}s, expected < 1s"
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
def test_parallel_requests
|
|
755
|
+
stub_api_request(:get, '/users/123')
|
|
756
|
+
.to_return(body: { id: 123 }.to_json)
|
|
757
|
+
|
|
758
|
+
time = Benchmark.realtime do
|
|
759
|
+
threads = 10.times.map do
|
|
760
|
+
Thread.new { TestApi::User.retrieve('123') }
|
|
761
|
+
end
|
|
762
|
+
threads.each(&:join)
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
assert time < 2.0, "Parallel requests took #{time}s"
|
|
766
|
+
end
|
|
767
|
+
end
|
|
768
|
+
```
|