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.
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
+ ```