Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Testing integrations - The Good, the Bad and th...

Julia López
September 30, 2024

Testing integrations - The Good, the Bad and the Ugly

Does your Rails app have integrations with third-party APIs? Perhaps your billing system relies on a subscription management tool, or you utilize email marketing applications to engage customers? Enhancing your features through external tools can be powerful, but testing these integrations could pose challenges or be cumbersome. Join me as we explore practical strategies for testing integrations, drawing from real-life experiences at Harvest, where we implemented several integrations – Braintree, Stripe, CustomerIO, Hubspot, Xero, QuickBooks, and more – and gain confidence in safeguarding your codebase during refactorings or API version changes.

Julia López

September 30, 2024
Tweet

More Decks by Julia López

Other Decks in Programming

Transcript

  1. Julia López From Barcelona ☀ ❤ Rails since 2011 Billing

    🤑 & Refactoring 🧹 & Upgrades ⏫ 👾 @yukideluxe
  2. https://www.workato.com/the-connector/software-integration/ “Software integration is the process of connecting one software

    application with another, typically through their Application Programming Interfaces"
  3. https://en.wikipedia.org/wiki/Test_automation “Test automation is the use of software separate from

    the software being tested to control the execution of tests and the comparison of actual outcomes with predicted outcomes”
  4. What is the particularity? Why am I focusing on testing

    external API integrations in this talk?
  5. We cannot control thirdparty APIs And there are lots of

    things we need to worry about… 🚩 🤹
  6. class StripeSubscriptionService def self.create(customer:, price:, quantity:) Stripe::Subscription.create( customer: customer, items:

    [{ price: price, quantity: quantity }], ) rescue Stripe::StripeError => e # handle errors here end end 🤨The Service
  7. class StripeSubscriptionService def self.create(customer:, price:, quantity:) Stripe::Subscription.create( customer: customer, items:

    [{ price: price, quantity: quantity }] ) rescue Stripe::StripeError => e # handle errors here end end The Service
  8. class StripeSubscriptionService def self.create(customer:, price:, quantity:) # use your favorite

    HTTP client here response = post( "https://api.stripe.com/v1/subscriptions", body: { customer: customer, items: [{ price: price, quantity: quantity }] }, headers: { "Authorization" => "Bearer #{ENV["STRIPE_SECRET_KEY"]}" } ) if response.success? JSON.parse(response.body, object_class: OpenStruct) else # handle errors here end end end The Service
  9. class StripeSubscriptionServiceTest < ActiveSupport::TestCase test ".create creates a Stripe subscription"

    do stripe_subscription = StripeSubscriptionService.create( customer: "cus_test_customer_id", price: "price_test_price_id", quantity: 10 ) stripe_subscription_item = stripe_subscription.items.first assert_equal "active", stripe_subscription.status assert_equal "cus_test_customer_id", stripe_subscription.customer assert_equal "price_test_price_id", stripe_subscription_item.price.id assert_equal 10, stripe_subscription_item.quantity end end The Test Hit the API
  10. class StripeSubscriptionServiceTest < ActiveSupport::TestCase test ".create creates a Stripe subscription"

    do stripe_subscription = StripeSubscriptionService.create( customer: "cus_test_customer_id", price: "price_test_price_id", quantity: 10, ) stripe_subscription_item = stripe_subscription.items.first assert_equal "active", stripe_subscription.status assert_equal "cus_test_customer_id", stripe_subscription.customer assert_equal "price_test_price_id", stripe_subscription_item.price.id assert_equal 10, stripe_subscription_item.quantity end end The Test Hit the API
  11. class StripeSubscriptionServiceTest < ActiveSupport::TestCase test ".create creates a Stripe subscription"

    do stripe_subscription = StripeSubscriptionService.create( customer: "cus_test_customer_id", price: "price_test_price_id", quantity: 10 ) stripe_subscription_item = stripe_subscription.items.first assert_equal "active", stripe_subscription.status assert_equal "cus_test_customer_id", stripe_subscription.customer assert_equal "price_test_price_id", stripe_subscription_item.price.id assert_equal 10, stripe_subscription_item.quantity end end The Test Hit the API
  12. class StripeSubscriptionServiceTest < ActiveSupport::TestCase test ".create creates a Stripe subscription"

    do stripe_subscription = StripeSubscriptionService.create( customer: "cus_test_customer_id", price: "price_test_price_id", quantity: 10 ) stripe_subscription_item = stripe_subscription.items.first assert_equal "active", stripe_subscription.status assert_equal "cus_test_customer_id", stripe_subscription.customer assert_equal "price_test_price_id", stripe_subscription_item.price.id assert_equal 10, stripe_subscription_item.quantity end end The Test Hit the API
  13. class StripeSubscriptionServiceTest < ActiveSupport::TestCase test ".create creates a Stripe subscription"

    do stripe_subscription = StripeSubscriptionService.create( customer: "cus_test_customer_id", price: "price_test_price_id", quantity: 10 ) stripe_subscription_item = stripe_subscription.items.first assert_equal "active", stripe_subscription.status assert_equal "cus_test_customer_id", stripe_subscription.customer assert_equal "price_test_price_id", stripe_subscription_item.price.id assert_equal 10, stripe_subscription_item.quantity end end The Test Hit the API
  14. bundle exec rails test test/**/billing/**/*_test.rb Run options: --seed 11798 #

    Running: ................................................................ ................................................................ ................................................................ ................................................................ ................................................................ ................................................................ ................................................................ ................................................................ ................................................................ ............... Finished in 186.088526s, 3.1759 runs/s, 38.0518 assertions/s. 591 runs, 7081 assertions, 0 failures, 0 errors, 0 skips
  15. class StripeSubscriptionServiceTest < ActiveSupport::TestCase test ".create creates a Stripe subscription"

    do stripe_subscription = StripeSubscriptionService.create( customer: "cus_test_customer_id", price: "price_test_price_id", quantity: 10 ) stripe_subscription_item = stripe_subscription.items.first assert_equal "active", stripe_subscription.status assert_equal "cus_test_customer_id", stripe_subscription.customer assert_equal "price_test_price_id", stripe_subscription_item.price.id assert_equal 10, stripe_subscription_item.quantity end end The Test Hit the API
  16. CI

  17. CI

  18. The Test Stubs / Mocks class StripeSubscriptionServiceTest < ActiveSupport::TestCase test

    ".create creates a Stripe subscription" do stripe_subscription = StripeSubscriptionService.create( customer: "cus_test_customer_id", price: "price_test_price_id", quantity: 10 ) stripe_subscription_item = stripe_subscription.items.first assert_equal "active", stripe_subscription.status assert_equal "cus_test_customer_id", stripe_subscription.customer assert_equal "price_test_price_id", stripe_subscription_item.price.id assert_equal 10, stripe_subscription_item.quantity end end
  19. class StripeSubscriptionServiceTest < ActiveSupport::TestCase test ".create creates a Stripe subscription"

    do ✨ YOUR STUBS AND MOCKS GO HERE ✨ stripe_subscription = StripeSubscriptionService.create( customer: "cus_test_customer_id", price: "price_test_price_id", quantity: 10, ) stripe_subscription_item = stripe_subscription.items.first assert_equal "active", stripe_subscription.status assert_equal "cus_test_customer_id", stripe_subscription.customer assert_equal "price_test_price_id", stripe_subscription_item.price.id assert_equal 10, stripe_subscription_item.quantity end end The Test Stubs / Mocks
  20. class StripeSubscriptionService def self.create(customer:, price:, quantity:) Stripe::Subscription.create( customer: customer, items:

    [{ price: price, quantity: quantity }] ) rescue Stripe::StripeError => e # handle errors here end end The Service
  21. stubbed_stripe_subscription = stub( status: "active", customer: "cus_test_customer_id", items: [stub(price: stub(id:

    "price_test_price_id"), quantity: 10)] ) Stripe::Subscription.expects(:create).with( customer: "cus_test_customer_id", items: [{ price: "price_test_price_id", quantity: 10 }] ).returns(stubbed_stripe_subscription) The Test Stubs / Mocks
  22. stubbed_stripe_subscription = stub( status: "active", customer: "cus_test_customer_id", items: [stub(price: stub(id:

    "price_test_price_id"), quantity: 10)] ) Stripe::Subscription.expects(:create).with( customer: "cus_test_customer_id", items: [{ price: "price_test_price_id", quantity: 10 }] ).returns(stubbed_stripe_subscription) The Test Stubs / Mocks
  23. stubbed_stripe_subscription = stub( status: "active", customer: "cus_test_customer_id", items: [stub(price: stub(id:

    "price_test_price_id"), quantity: 10)] ) Stripe::Subscription.expects(:create).with( customer: "cus_test_customer_id", items: [{ price: "price_test_price_id", quantity: 10 }] ).returns(stubbed_stripe_subscription) The Test Stubs / Mocks
  24. stubbed_stripe_subscription = stub( status: "active", customer: "cus_test_customer_id", items: [stub(price: stub(id:

    "price_test_price_id"), quantity: 10)] ) Stripe::Subscription.expects(:create).with( customer: "cus_test_customer_id", items: [{ price: "price_test_price_id", quantity: 10 }] ).returns(stubbed_stripe_subscription) The Test Stubs / Mocks
  25. stubbed_stripe_subscription = stub( status: "active", customer: "cus_test_customer_id", items: [stub(price: stub(id:

    "price_test_price_id"), quantity: 10)] ) Stripe::Subscription.expects(:create).with( customer: "cus_test_customer_id", items: [{ price: "price_test_price_id", quantity: 10 }] ).returns(stubbed_stripe_subscription) The Test Stubs / Mocks
  26. stubbed_stripe_subscription = stub( status: "active", customer: "cus_test_customer_id", items: [stub(price: stub(id:

    "price_test_price_id"), quantity: 10)] ) Stripe::Subscription.expects(:create).with( customer: "cus_test_customer_id", items: [{ price: "price_test_price_id", quantity: 10 }] ).returns(stubbed_stripe_subscription) The Test Stubs / Mocks
  27. class StripeSubscriptionService def self.create(customer:, price:, quantity:) # use your favorite

    HTTP client here response = post( "https://api.stripe.com/v1/subscriptions", body: { customer: customer, items: [{ price: price, quantity: quantity }] }, headers: { "Authorization" => "Bearer #{ENV["STRIPE_SECRET_KEY"]}" } ) if response.success? JSON.parse(response.body, object_class: OpenStruct) else # handle errors here end end end The Service
  28. stub_of_your_http_client_post_response = stub( success?: true, body: { status: "active", customer:

    "cus_test_customer_id", items: [{ price: { id: "price_test_price_id" }, quantity: 10 }] }.to_json ) YourFavoriteHttpClient.expects(:post).with( "https://api.stripe.com/v1/subscriptions", body: { customer: "cus_test_customer_id", items: [{ price: "price_test_price_id", quantity: 10 }] }, headers: { "Authorization" => "Bearer #{ENV['STRIPE_SECRET_KEY']}" }, ).returns(stub_of_your_http_client_post_response) The Test Stubs / Mocks
  29. stub_of_your_http_client_post_response = stub( success?: true, body: { status: "active", customer:

    "cus_test_customer_id", items: [{ price: { id: "price_test_price_id" }, quantity: 10 }] }.to_json ) YourFavoriteHttpClient.expects(:post).with( "https://api.stripe.com/v1/subscriptions", body: { customer: "cus_test_customer_id", items: [{ price: "price_test_price_id", quantity: 10 }] }, headers: { "Authorization" => "Bearer #{ENV['STRIPE_SECRET_KEY']}" }, ).returns(stub_of_your_http_client_post_response) The Test Stubs / Mocks
  30. stub_of_your_http_client_post_response = stub( success?: true, body: { status: "active", customer:

    "cus_test_customer_id", items: [{ price: { id: "price_test_price_id" }, quantity: 10 }] }.to_json ) YourFavoriteHttpClient.expects(:post).with( "https://api.stripe.com/v1/subscriptions", body: { customer: "cus_test_customer_id", items: [{ price: "price_test_price_id", quantity: 10 }] }, headers: { "Authorization" => "Bearer #{ENV['STRIPE_SECRET_KEY']}" }, ).returns(stub_of_your_http_client_post_response) The Test Stubs / Mocks
  31. stub_of_your_http_client_post_response = stub( success?: true, body: { status: "active", customer:

    "cus_test_customer_id", items: [{ price: { id: "price_test_price_id" }, quantity: 10 }] }.to_json ) YourFavoriteHttpClient.expects(:post).with( "https://api.stripe.com/v1/subscriptions", body: { customer: "cus_test_customer_id", items: [{ price: "price_test_price_id", quantity: 10 }] }, headers: { "Authorization" => "Bearer #{ENV['STRIPE_SECRET_KEY']}" } ).returns(stub_of_your_http_client_post_response) The Test Stubs / Mocks
  32. stub_of_your_http_client_post_response = stub( success?: true, body: { status: "active", customer:

    "cus_test_customer_id", items: [{ price: { id: "price_test_price_id" }, quantity: 10 }] }.to_json ) YourFavoriteHttpClient.expects(:post).with( "https://api.stripe.com/v1/subscriptions", body: { customer: "cus_test_customer_id", items: [{ price: "price_test_price_id", quantity: 10 }], }, headers: { "Authorization" => "Bearer #{ENV['STRIPE_SECRET_KEY']}" } ).returns(stub_of_your_http_client_post_response) The Test Stubs / Mocks
  33. stub_of_your_http_client_post_response = stub( success?: true, body: { status: "active", customer:

    "cus_test_customer_id", items: [{ price: { id: "price_test_price_id" }, quantity: 10 }] }.to_json ) YourFavoriteHttpClient.expects(:post).with( "https://api.stripe.com/v1/subscriptions", body: { customer: "cus_test_customer_id", items: [{ price: "price_test_price_id", quantity: 10 }] }, headers: { "Authorization" => "Bearer #{ENV['STRIPE_SECRET_KEY']}" } ).returns(stub_of_your_http_client_post_response) The Test Stubs / Mocks
  34. stub_request( :post, "https://api.stripe.com/v1/subscriptions" ).with( headers: { "Authorization" => "Bearer #{ENV["STRIPE_SECRET_KEY"]}"

    }, body: { customer: "cus_test_customer_id", items: [{ price: "price_test_price_id", quantity: 10 }] } ).to_return( status: 200, body: { status: "active", customer: "cus_test_customer_id", items: [{ price: { id: "price_test_price_id" }, quantity: 10 }] }.to_json ) The Test WebMock
  35. stub_request( :post, "https://api.stripe.com/v1/subscriptions" ).with( headers: { "Authorization" => "Bearer #{ENV["STRIPE_SECRET_KEY"]}"

    }, body: { customer: "cus_test_customer_id", items: [{ price: "price_test_price_id", quantity: 10 }] } ).to_return( status: 200, body: { status: "active", customer: "cus_test_customer_id", items: [{ price: { id: "price_test_price_id" }, quantity: 10 }] }.to_json ) The Test WebMock
  36. stub_request( :post, "https://api.stripe.com/v1/subscriptions" ).with( headers: { "Authorization" => "Bearer #{ENV["STRIPE_SECRET_KEY"]}"

    }, body: { customer: "cus_test_customer_id", items: [{ price: "price_test_price_id", quantity: 10 }] } ).to_return( status: 200, body: { status: "active", customer: "cus_test_customer_id", items: [{ price: { id: "price_test_price_id" }, quantity: 10 }] }.to_json ) The Test WebMock
  37. stub_request( :post, "https://api.stripe.com/v1/subscriptions" ).with( headers: { "Authorization" => "Bearer #{ENV["STRIPE_SECRET_KEY"]}"

    }, body: { customer: "cus_test_customer_id", items: [{ price: "price_test_price_id", quantity: 10 }] } ).to_return( status: 200, body: { status: "active", customer: "cus_test_customer_id", items: [{ price: { id: "price_test_price_id" }, quantity: 10 }] }.to_json ) The Test WebMock
  38. stub_request( :post, "https://api.stripe.com/v1/subscriptions" ).with( headers: { "Authorization" => "Bearer #{ENV["STRIPE_SECRET_KEY"]}"

    }, body: { customer: "cus_test_customer_id", items: [{ price: "price_test_price_id", quantity: 10 }] } ).to_return( status: 200, body: { status: "active", customer: "cus_test_customer_id", items: [{ price: { id: "price_test_price_id" }, quantity: 10 }] }.to_json ) The Test WebMock
  39. ❯ rails test test/services/stripe_subscription_service_test.rb E Error: StripeSubscriptionServiceTest#test_.create_creates_a_Stripe_subscription: WebMock::NetConnectNotAllowedError: Real HTTP

    connections are disabled. Unregistered request: POST https://api.stripe.com/v1/subscriptions with body ‘customer=cus_test_customer_id&items[0] [price]=price_test_price_id&items[0][quantity]=10' ... You can stub this request with the following snippet: stub_request(:post, "https://api.stripe.com/v1/subscriptions"). with( body: {"customer"=>"cus_test_customer_id", "items"=>[{"price"=>"price_test_price_id", "quantity"=>"10"}]}, headers: { 'Authorization'=>'Bearer OBFUSCATED', }). to_return(status: 200, body: "", headers: {}) ============================================================ app/services/stripe_subscription_service.rb:3:in `create' test/services/stripe_subscription_service_test.rb:5:in `block in <class:StripeSubscriptionServiceTest>' Con f iguration WebMock
  40. ❯ rails test test/services/stripe_subscription_service_test.rb E Error: StripeSubscriptionServiceTest#test_.create_creates_a_Stripe_subscription: WebMock::NetConnectNotAllowedError: Real HTTP

    connections are disabled. Unregistered request: POST https://api.stripe.com/v1/subscriptions with body ‘customer=cus_test_customer_id&items[0] [price]=price_test_price_id&items[0][quantity]=10' ... You can stub this request with the following snippet: stub_request(:post, "https://api.stripe.com/v1/subscriptions"). with( body: {"customer"=>"cus_test_customer_id", "items"=>[{"price"=>"price_test_price_id", "quantity"=>"10"}]}, headers: { 'Authorization'=>'Bearer OBFUSCATED', }). to_return(status: 200, body: "", headers: {}) ============================================================ app/services/stripe_subscription_service.rb:3:in `create' test/services/stripe_subscription_service_test.rb:5:in `block in <class:StripeSubscriptionServiceTest>' Con f iguration WebMock
  41. stub_request( :post, "https://api.stripe.com/api/v1/subscriptions" ).with( headers: { "Authorization" => "Bearer #{ENV["STRIPE_SECRET_KEY"]}"

    }, body: { customer: "cus_test_customer_id", items: [{ price: "price_test_price_id", quantity: 10 }] } ).to_return( status: 200, body: { status: "active", customer: "cus_test_customer_id", items: [{ price: "price_test_price_id", quantity: 10 }] }.to_json ) The Test WebMock
  42. You will need to f ine-tune those stubs & mocks

    And the responses will vary depending on what you are testing
  43. https://github.com/vcr/vcr “Record your test suite's HTTP interactions and replay them

    during future test runs for fast, deterministic, accurate tests."
  44. class StripeSubscriptionServiceTest < ActiveSupport::TestCase test ".create creates a Stripe subscription"

    do stripe_subscription = StripeSubscriptionService.create( customer: "cus_test_customer_id", price: "price_test_price_id", quantity: 10, ) stripe_subscription_item = stripe_subscription.items.first assert_equal "active", stripe_subscription.status assert_equal "cus_test_customer_id", stripe_subscription.customer assert_equal "price_test_price_id", stripe_subscription_item.price assert_equal 10, stripe_subscription_item.quantity end end The Test Hit the API
  45. class StripeSubscriptionServiceTest < ActiveSupport::TestCase test ".create creates a Stripe subscription"

    do VCR.use_cassette(“stripe_subscription_service_create”) do stripe_subscription = StripeSubscriptionService.create( customer: "cus_test_customer_id", price: "price_test_price_id", quantity: 10, ) stripe_subscription_item = stripe_subscription.items.first assert_equal "active", stripe_subscription.status assert_equal "cus_test_customer_id", stripe_subscription.customer assert_equal "price_test_price_id", stripe_subscription_item.price assert_equal 10, stripe_subscription_item.quantity end end end The Test VCR
  46. "body": { "encoding": "UTF-8", "string": "{\n \"id\": \"sub_PRIVATE\",\n \"object\": \"subscription\",\n

    \"application\": null,\n \"application_fee_percent\": null,\n \"automatic_tax\": {\n \"enabled\": false,\n \"liability\": null\n },\n \"billing_cycle_anchor\": 1725449818,\n \"billing_cycle_anchor_config\": null,\n \"billing_thresholds\": null,\n \"cancel_at\": null,\n \"cancel_at_period_end\": false,\n \"canceled_at\": null,\n \"cancellation_details\": {\n \"comment\": null,\n \"feedback\": null,\n \"reason\": null\n },\n \"collection_method\": \"charge_automatically\",\n \"created\": 1725449818,\n \"currency\": \"usd\",\n \"current_period_end\": 1756985818,\n \"current_period_start\": 1725449818,\n \"customer\": \"cus_PRIVATE\",\n \"days_until_due\": null,\n \"default_payment_method\": null,\n \"default_source\": null,\n \"default_tax_rates\": [],\n \"description\": null,\n \"discount\": null,\n \"discounts\": [],\n \"ended_at\": null,\n \"invoice_settings\": {\n \"account_tax_ids\": null,\n \"issuer\": {\n \"type\": \"self\"\n }\n },\n \"items\": {\n \"object\": \"list\",\n \"data\": [\n {\n \"id\": \"si_PRIVATE\",\n \"object\": \"subscription_item\",\n \"billing_thresholds\": null,\n \"created\": 1725449818,\n \"discounts\": [],\n \"metadata\": {},\n \"plan\": {\n \"id\": \"price_PRIVATE\",\n \"object\": \"plan\",\n \"active\": true,\n \"aggregate_usage\": null,\n \"amount\": 15828,\n \"amount_decimal\": \"15828\",\n \"billing_scheme\": \"per_unit\",\n \"created\": 1724866294,\n \"currency\": \"usd\",\n \"interval\": \"year\",\n \"interval_count\": 1,\n \"livemode\": false,\n \"metadata\": {},\n \"meter\": null,\n \"nickname\": \”Price NickName\",\n \"product\": \"prod_PRIVATE\",\n \"tiers_mode\": null,\n \"transform_usage\": null,\n \"trial_period_days\": null,\n \"usage_type\": \"licensed\"\n },\n \"price\": {\n \"id\": \"price_PRIVATE\",\n \"object\": \"price\",\n \"active\": true,\n \"billing_scheme\": \"per_unit\",\n \"created\": 1724866294,\n \"currency\": \"usd\",\n \"custom_unit_amount\": null,\n \"livemode\": false,\n \"lookup_key\": \”price_lookup_key\”,\n \"metadata\": {},\n \"nickname\": \”Price Nickname\",\n \"product\": \"prod_PRIVATE\",\n \"recurring\": {\n \"aggregate_usage\": null,\n \"interval\": \"year\",\n \"interval_count\": 1,\n \"meter\": null,\n \"trial_period_days\": null,\n \"usage_type\": \"licensed\"\n },\n \"tax_behavior\": \"exclusive\",\n \"tiers_mode\": null,\n \"transform_quantity\": null,\n \"type\": \"recurring\",\n \"unit_amount\": 15828,\n \"unit_amount_decimal\": \"15828\"\n },\n \"quantity\": 10,\n \"subscription\": \"sub_PRIVATE\",\n \"tax_rates\": []\n }\n ],\n \"has_more\": false,\n \"total_count\": 1,\n \"url\": \"/v1/subscription_items?subscription=sub_PRIVATE\"\n },\n \"latest_invoice\": \"in_PRIVATE\",\n \"livemode\": false,\n \"metadata\": {},\n \"next_pending_invoice_item_invoice\": null,\n \"on_behalf_of\": null,\n \"pause_collection\": null,\n \"payment_settings\": {\n \"payment_method_options\": null,\n \"payment_method_types\": null,\n \"save_default_payment_method\": \"off\"\n },\n \"pending_invoice_item_interval\": null,\n \"pending_setup_intent\": null,\n \"pending_update\": null,\n \"plan\": {\n \"id\": \"price_PRIVATE\",\n \"object\": \"plan\",\n \"active\": true,\n \"aggregate_usage\": null,\n \"amount\": 15828,\n \"amount_decimal\": \"15828\",\n \"billing_scheme\": \"per_unit\",\n \"created\": 1724866294,\n \"currency\": \"usd\",\n \"interval\": \"year\",\n \"interval_count\": 1,\n \"livemode\": false,\n \"metadata\": {},\n \"meter\": null,\n \"nickname\": \”Price nickname\",\n \"product\": \”prod_PRIVATE\",\n \"tiers_mode\": null,\n \"transform_usage\": null,\n \"trial_period_days\": null,\n \"usage_type\": \"licensed\"\n },\n \"quantity\": 10,\n \"schedule\": null,\n \"start_date\": 1725449818,\n \"status\": \"active\",\n \"test_clock\": \"clock_PRIVATE\",\n \"transfer_data\": null,\n \"trial_end\": null,\n \"trial_settings\": {\n \"end_behavior\": {\n \"missing_payment_method\": \"create_invoice\"\n }\n },\n \"trial_start\": null\n}" } The Test VCR
  47. class StripeSubscriptionServiceTest < ActiveSupport::TestCase test ".create creates a Stripe subscription"

    do VCR.use_cassette("stripe_subscription_service_create") do stripe_subscription = StripeSubscriptionService.create( customer: “cus_test_customer_id”, price: "price_test_price_id", quantity: 10, ) stripe_subscription_item = stripe_subscription.items.first assert_equal “active", stripe_subscription.status assert_equal "cus_test_customer_id", stripe_subscription.customer assert_equal "price_test_price_id", stripe_subscription_item.price assert_equal 10, stripe_subscription_item.quantity end end end The Test VCR
  48. # test/test_helper.rb require “vcr” VCR.configure do |c| c.cassette_library_dir = "test/vcr_cassettes"

    c.hook_into :webmock c.default_cassette_options = { record: ENV["CI"] ? :none : :once, serialize_with: :json, match_requests_on: [:method, :host, :path] } c.allow_http_connections_when_no_cassette = false # default ignored_hosts = [] ignored_hosts << "127.0.0.1" # Capybara c.ignore_hosts *ignored_hosts end Con f iguration VCR
  49. # test/test_helper.rb require “vcr" VCR.configure do |c| c.cassette_library_dir = "test/vcr_cassettes"

    c.hook_into :webmock c.default_cassette_options = { record: ENV["CI"] ? :none : :once, serialize_with: :json, match_requests_on: [:method, :host, :path] } c.allow_http_connections_when_no_cassette = false # default ignored_hosts = [] ignored_hosts << "127.0.0.1" # Capybara c.ignore_hosts *ignored_hosts end Con f iguration VCR
  50. # test/test_helper.rb require “vcr" VCR.configure do |c| c.cassette_library_dir = "test/vcr_cassettes"

    c.hook_into :webmock c.default_cassette_options = { record: ENV["CI"] ? :none : :once, serialize_with: :json, match_requests_on: [:method, :host, :path] } c.allow_http_connections_when_no_cassette = false # default ignored_hosts = [] ignored_hosts << "127.0.0.1" # Capybara c.ignore_hosts *ignored_hosts end Con f iguration VCR
  51. # test/test_helper.rb require “vcr" VCR.configure do |c| c.cassette_library_dir = "test/vcr_cassettes"

    c.hook_into :webmock c.default_cassette_options = { record: ENV["CI"] ? :none : :once, serialize_with: :json, match_requests_on: [:method, :host, :path] } c.allow_http_connections_when_no_cassette = false # default ignored_hosts = [] ignored_hosts << "127.0.0.1" # Capybara c.ignore_hosts *ignored_hosts end Con f iguration VCR
  52. # test/test_helper.rb require “vcr" VCR.configure do |c| c.cassette_library_dir = "test/vcr_cassettes"

    c.hook_into :webmock c.default_cassette_options = { record: ENV["CI"] ? :none : :once, serialize_with: :json, match_requests_on: [:method, :host, :path] } c.allow_http_connections_when_no_cassette = false # default ignored_hosts = [] ignored_hosts << "127.0.0.1" # Capybara c.ignore_hosts *ignored_hosts end Con f iguration VCR
  53. # test/test_helper.rb require “vcr” VCR.configure do |c| c.cassette_library_dir = "test/vcr_cassettes"

    c.hook_into :webmock c.default_cassette_options = { record: ENV["CI"] ? :none : :once, serialize_with: :json, match_requests_on: [:method, :host, :path] } c.allow_http_connections_when_no_cassette = false # default ignored_hosts = [] ignored_hosts << "127.0.0.1" # Capybara c.ignore_hosts *ignored_hosts end Con f iguration VCR
  54. # test/test_helper.rb require “vcr" VCR.configure do |c| c.cassette_library_dir = "test/vcr_cassettes"

    c.hook_into :webmock c.default_cassette_options = { record: ENV["CI"] ? :none : :once, serialize_with: :json, match_requests_on: [:method, :host, :path] } c.allow_http_connections_when_no_cassette = false # default ignored_hosts = [] ignored_hosts << "127.0.0.1" # Capybara c.ignore_hosts *ignored_hosts end Con f iguration VCR
  55. # test/test_helper.rb require “vcr" VCR.configure do |c| c.cassette_library_dir = "test/vcr_cassettes"

    c.hook_into :webmock c.default_cassette_options = { record: ENV["CI"] ? :none : :once, serialize_with: :json, match_requests_on: [:method, :host, :path] } c.allow_http_connections_when_no_cassette = false # default ignored_hosts = [] ignored_hosts << "127.0.0.1" # Capybara c.ignore_hosts *ignored_hosts end Con f iguration VCR
  56. Con f iguration ❯ rails test test/services/stripe_subscription_service_test.rb E Error: StripeSubscriptionServiceTest#test_.create_creates_a_Stripe_subscription:

    RuntimeError: Neutered Exception VCR::Errors::UnhandledHTTPRequestError: ================================================================================ An HTTP request has been made that VCR does not know how to handle: POST https://api.stripe.com/v1/subscriptions There is currently no cassette in use. There are a few ways you can configure VCR to handle this request: * If you're surprised VCR is raising this error and want insight about how VCR attempted to handle the request, you can use the debug_logger configuration option to log more details [1]. * If you want VCR to record this request and play it back during future test runs, you should wrap your test (or this portion of your test) in a `VCR.use_cassette` block [2]. * If you only want VCR to handle requests made while a cassette is in use, configure `allow_http_connections_when_no_cassette = true`. VCR will ignore this request since it is made when there is no cassette [3]. * If you want VCR to ignore this request (and others like it), you can set an `ignore_request` callback [4]. [1] https://benoittgt.github.io/vcr/?v=6-3-1#/configuration/debug_logging [2] https://benoittgt.github.io/vcr/?v=6-3-1#/getting_started [3] https://benoittgt.github.io/vcr/?v=6-3-1#/configuration/allow_http_connections_when_no_cassette [4] https://benoittgt.github.io/vcr/?v=6-3-1#/configuration/ignore_request ================================================================================ app/services/stripe_subscription_service.rb:3:in `create' test/services/stripe_subscription_service_test.rb:6:in `block in <class:StripeSubscriptionServiceTest>' VCR
  57. Con f iguration ❯ rails test test/services/stripe_subscription_service_test.rb E Error: StripeSubscriptionServiceTest#test_.create_creates_a_Stripe_subscription:

    RuntimeError: Neutered Exception VCR::Errors::UnhandledHTTPRequestError: ================================================================================ An HTTP request has been made that VCR does not know how to handle: POST https://api.stripe.com/v1/subscriptions There is currently no cassette in use. There are a few ways you can configure VCR to handle this request: * If you're surprised VCR is raising this error and want insight about how VCR attempted to handle the request, you can use the debug_logger configuration option to log more details [1]. * If you want VCR to record this request and play it back during future test runs, you should wrap your test (or this portion of your test) in a `VCR.use_cassette` block [2]. * If you only want VCR to handle requests made while a cassette is in use, configure `allow_http_connections_when_no_cassette = true`. VCR will ignore this request since it is made when there is no cassette [3]. * If you want VCR to ignore this request (and others like it), you can set an `ignore_request` callback [4]. [1] https://benoittgt.github.io/vcr/?v=6-3-1#/configuration/debug_logging [2] https://benoittgt.github.io/vcr/?v=6-3-1#/getting_started [3] https://benoittgt.github.io/vcr/?v=6-3-1#/configuration/allow_http_connections_when_no_cassette [4] https://benoittgt.github.io/vcr/?v=6-3-1#/configuration/ignore_request ================================================================================ app/services/stripe_subscription_service.rb:3:in `create' test/services/stripe_subscription_service_test.rb:6:in `block in <class:StripeSubscriptionServiceTest>' VCR
  58. # test/test_helper.rb require “vcr" VCR.configure do |c| c.cassette_library_dir = "test/vcr_cassettes"

    c.hook_into :webmock c.default_cassette_options = { record: ENV["CI"] ? :none : :once, serialize_with: :json, match_requests_on: [:method, :host, :path], } c.allow_http_connections_when_no_cassette = false # default ignored_hosts = [] ignored_hosts << "127.0.0.1" # Capybara c.ignore_hosts *ignored_hosts end Con f iguration VCR
  59. Extra tooling # test/support/vcr.rb ActiveSupport::TestCase.class_eval do setup do if ENV["VCR_REFRESH"]

    == "true" Dir["#{VCR.configuration.cassette_library_dir}/#{vcr_filename}.json"].each do |file| puts "VCR: deleting recorded cassette: #{file}" FileUtils.rm_f(file) end end end def use_test_named_cassette(vcr_options = {}) VCR.use_cassette(vcr_filename, vcr_options) do |cassette| yield cassette end end def vcr_filename test_name_match = name.match(/(test_)(.+)/) test_description = test_name_match[2] filename = "#{vcr_prefix}#{test_description}".gsub(/[^\w-]+/, "_") # Avoids hitting the filesystem's maximum filename if filename.length > 248 # 255 - 1 (_) - 6 (MD5 hash length) filename = filename[0..247] end
  60. Extra tooling # test/support/vcr.rb ActiveSupport::TestCase.class_eval do setup do if ENV["VCR_REFRESH"]

    == "true" Dir["#{VCR.configuration.cassette_library_dir}/#{vcr_filename}.json"].each do |file| puts "VCR: deleting recorded cassette: #{file}" FileUtils.rm_f(file) end end end def use_test_named_cassette(vcr_options = {}) VCR.use_cassette(vcr_filename, vcr_options) do |cassette| yield cassette end end def vcr_filename test_name_match = name.match(/(test_)(.+)/) test_description = test_name_match[2] filename = "#{vcr_prefix}#{test_description}".gsub(/[^\w-]+/, "_") # Avoids hitting the filesystem's maximum filename if filename.length > 248 # 255 - 1 (_) - 6 (MD5 hash length) filename = filename[0..247] end
  61. end def use_test_named_cassette(vcr_options = {}) VCR.use_cassette(vcr_filename, vcr_options) do |cassette| yield

    cassette end end def vcr_filename test_name_match = name.match(/(test_)(.+)/) test_description = test_name_match[2] filename = "#{vcr_prefix}#{test_description}".gsub(/[^\w-]+/, "_") # Avoids hitting the filesystem's maximum filename if filename.length > 248 # 255 - 1 (_) - 6 (MD5 hash length) filename = filename[0..247] end "#{filename}_#{Digest::MD5.hexdigest(test_description)[0..5]}" end def vcr_prefix prefix_match = self.class.name.match(/(.+)Test/) prefix = prefix_match[1].underscore.gsub("/", "__") "#{prefix}__" end def vcr_recorded_at VCR.current_cassette&.originally_recorded_at || Time.now Extra tooling
  62. Hit the API On our local setup # db/seeds.rb require

    “webmock" WebMock.enable! WebMock.disable_net_connect!(allow: “api.stripe.com") require_relative "seeds/stripe_billing"
  63. Hit the API On our system tests # test/system/billing/upgrade_test.rb test

    "trial to paid" do with_stripe_customer(company: @company) do |subscription| visit company_account_path click_on "Upgrade plan" fill_in_credit_card_details fill_in_billing_address ... assert_text "Harvest Monthly with 1 seat" ... click_on "Upgrade" assert_text "Thanks for signing up!" subscription.reload assert_equal "active", subscription.status assert subscription.credit_card end end
  64. Hit the API On our system tests # test/system/billing/upgrade_test.rb test

    "trial to paid" do with_stripe_customer(company: @company) do |subscription| visit company_account_path click_on "Upgrade plan" fill_in_credit_card_details fill_in_billing_address ... assert_text "Harvest Monthly with 1 seat" ... click_on "Upgrade" assert_text "Thanks for signing up!" subscription.reload assert_equal "active", subscription.status assert subscription.credit_card end end
  65. https://github.com/oesmith/pu ff ing-billy “A rewriting web proxy for testing interactions

    between your browser and external sites. Works with ruby + rspec."
  66. # test/controllers/sign_ups_controller_test.rb test "GET /xero_sign_up if error" do XeroRuby::ApiClient .any_instance

    .stubs(:get_token_set_from_callback) .raises(XeroRuby::ApiError.new(code: 400)) get xero_sign_up_path assert_response :not_found end Stubs / Mocks External gems
  67. # test/controllers/sign_ups_controller_test.rb test "GET /xero_sign_up if error" do XeroRuby::ApiClient .any_instance

    .stubs(:get_token_set_from_callback) .raises(XeroRuby::ApiError.new(code: 400)) get xero_sign_up_path assert_response :not_found end Stubs / Mocks External gems
  68. # test/services/zendesk/wrapper_test.rb def stub_faraday_connection conn = Faraday.new do |builder| stubs

    = Faraday::Adapter::Test::Stubs.new yield stubs builder.adapter :test, stubs end Faraday.expects(:new).at_least_once.returns(conn) end Stubs / Mocks Http client
  69. # test/services/zendesk/wrapper_test.rb def stub_faraday_connection conn = Faraday.new do |builder| stubs

    = Faraday::Adapter::Test::Stubs.new yield stubs builder.adapter :test, stubs end Faraday.expects(:new).at_least_once.returns(conn) end Stubs / Mocks Http client
  70. Stubs / Mocks WebMock # test/lib/forecast/account_test.rb test ".find returns Forecast::Account

    if account exists in Forecast" do forecast_account_request = stub_request(:get, forecast_account_url).to_return(forecast_account_response) forecast_account = Forecast::Account.find(1) assert_equal 1, forecast_account.id assert_equal "Harvest", forecast_account.name assert_requested forecast_account_request end
  71. Stubs / Mocks WebMock # test/lib/forecast/account_test.rb test ".find returns Forecast::Account

    if account exists in Forecast" do forecast_account_request = stub_request(:get, forecast_account_url).to_return(forecast_account_response) forecast_account = Forecast::Account.find(1) assert_equal 1, forecast_account.id assert_equal "Harvest", forecast_account.name assert_requested forecast_account_request end
  72. Stubs / Mocks VCR # test/models/billing/taxjar_test.rb test "#create_order" do use_test_named_cassette

    do vcr_recorded_date = vcr_recorded_at.to_date transaction_id = vcr_recorded_at.to_i.to_s response = Taxjar.create_order( transaction_id: transaction_id, transaction_date: vcr_recorded_date, ... ) assert_equal transaction_id, response["order"]["transaction_id"] assert_equal vcr_recorded_date.to_s, response["order"]["transaction_date"] end end
  73. Stubs / Mocks VCR # test/models/billing/taxjar_test.rb test "#create_order" do use_test_named_cassette

    do vcr_recorded_date = vcr_recorded_at.to_date transaction_id = vcr_recorded_at.to_i.to_s response = Taxjar.create_order( transaction_id: transaction_id, transaction_date: vcr_recorded_date, ... ) assert_equal transaction_id, response["order"]["transaction_id"] assert_equal vcr_recorded_date.to_s, response["order"]["transaction_date"] end end
  74. # lib/tasks/test/xero.rake namespace :test do namespace :xero do desc "Connects

    to Xero via OAuth v2 and writes to … keys that are usable for 30 minutes" task authorize: :environment do ... end end end AuthN & AuthZ Extra tooling
  75. # test/billing/coupon_test.rb test ".current_coupons returns list of available coupons" do

    use_test_named_cassette do current_coupons = Coupon.current_coupons assert current_coupons.count > 0 end end Fluctuating data Soft assertions
  76. # test/billing/coupon_test.rb test ".current_coupons returns list of available coupons" do

    use_test_named_cassette do current_coupons = Coupon.current_coupons assert current_coupons.count > 0 end end Fluctuating data Soft assertions