Slide 1

Slide 1 text

Testing Integrations Julia López – Rails World 2024 The Good, the Bad, and the Ugly

Slide 2

Slide 2 text

Julia López From Barcelona ☀ ❤ Rails since 2011 Billing 🤑 & Refactoring 🧹 & Upgrades ⏫ 👾 @yukideluxe

Slide 3

Slide 3 text

No content

Slide 4

Slide 4 text

No content

Slide 5

Slide 5 text

What can you expect? 🧐

Slide 6

Slide 6 text

Integrations What do I mean by integrations? ⚙

Slide 7

Slide 7 text

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"

Slide 8

Slide 8 text

External/thirdparty APIs 🗺

Slide 9

Slide 9 text

Testing What kind of testing? ✅

Slide 10

Slide 10 text

https://en.wikipedia.org/wiki/Software_testing “Software testing is the act of checking whether software satis f ies expectations."

Slide 11

Slide 11 text

Automated testing 🤖

Slide 12

Slide 12 text

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”

Slide 13

Slide 13 text

What is the particularity? Why am I focusing on testing external API integrations in this talk?

Slide 14

Slide 14 text

We cannot control thirdparty APIs And there are lots of things we need to worry about… 🚩 🤹

Slide 15

Slide 15 text

Billing Integration Simpli f ied version POST https://api.stripe.com/v1/subscriptions StripeSubscriptionService { "id": "sub_ID", "over_40_more_attributes": "💵" } CREATE

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

Different approaches When it comes to test API integrations

Slide 20

Slide 20 text

HIT the API In every test run 👊

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

The Test Reality

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

Slow API calls 🐌

Slide 29

Slide 29 text

Benchmark.ms do StripeSubscriptionService.create( customer: "cus_test_customer_id", price: "price_test_price_id", quantity: 10, ) end => 391.109999967739

Slide 30

Slide 30 text

No content

Slide 31

Slide 31 text

API Rate limits 🙅

Slide 32

Slide 32 text

https://docs.stripe.com/rate-limits

Slide 33

Slide 33 text

No content

Slide 34

Slide 34 text

Network conditions ⛓💥

Slide 35

Slide 35 text

No content

Slide 36

Slide 36 text

Fluctuating data 📝

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

AuthN & AuthZ 🔐

Slide 39

Slide 39 text

No content

Slide 40

Slide 40 text

Extra costs 💰

Slide 41

Slide 41 text

💸

Slide 42

Slide 42 text

CI

Slide 43

Slide 43 text

CI

Slide 44

Slide 44 text

No content

Slide 45

Slide 45 text

Stubs / Mocks For methods that hit the API

Slide 46

Slide 46 text

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

Slide 47

Slide 47 text

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

Slide 48

Slide 48 text

Mocha https://github.com/freerange/mocha ☕ ☕

Slide 49

Slide 49 text

https://github.com/freerange/mocha “A Ruby library for mocking and stubbing.”

Slide 50

Slide 50 text

No content

Slide 51

Slide 51 text

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

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

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

Slide 55

Slide 55 text

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

Slide 56

Slide 56 text

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

Slide 57

Slide 57 text

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

Slide 58

Slide 58 text

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

Slide 59

Slide 59 text

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

Slide 60

Slide 60 text

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

Slide 61

Slide 61 text

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

Slide 62

Slide 62 text

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

Slide 63

Slide 63 text

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

Slide 64

Slide 64 text

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

Slide 65

Slide 65 text

WebMock https://github.com/bblimke/webmock 😜

Slide 66

Slide 66 text

https://github.com/bblimke/webmock “Library for stubbing and setting expectations on HTTP requests in Ruby."

Slide 67

Slide 67 text

No content

Slide 68

Slide 68 text

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

Slide 69

Slide 69 text

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

Slide 70

Slide 70 text

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

Slide 71

Slide 71 text

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

Slide 72

Slide 72 text

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

Slide 73

Slide 73 text

stubbed_request = stub_request(...) # Run your test here assert_requested stubbed_request The Test WebMock

Slide 74

Slide 74 text

stubbed_request = stub_request(...) # Run your test here assert_requested stubbed_request The Test WebMock

Slide 75

Slide 75 text

# test/test_helper.rb ActiveSupport::TestCase.class_eval do setup do ... WebMock.disable_net_connect! ... end end Con f iguration WebMock

Slide 76

Slide 76 text

❯ 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 ' Con f iguration WebMock

Slide 77

Slide 77 text

❯ 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 ' Con f iguration WebMock

Slide 78

Slide 78 text

That’s it? We f ixed all the problems? 😍

Slide 79

Slide 79 text

Billing Integration Simpli f ied version POST https://api.stripe.com/v1/subscriptions StripeSubscriptionService { "id": "sub_ID", "over_40_more_attributes": "💵" } CREATE

Slide 80

Slide 80 text

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

Slide 81

Slide 81 text

https://docs.stripe.com/api/subscriptions/object

Slide 82

Slide 82 text

https://docs.stripe.com/changelog

Slide 83

Slide 83 text

You will need to f ine-tune those stubs & mocks And the responses will vary depending on what you are testing

Slide 84

Slide 84 text

VCR https://github.com/vcr/vcr 📼

Slide 85

Slide 85 text

https://github.com/vcr/vcr “Record your test suite's HTTP interactions and replay them during future test runs for fast, deterministic, accurate tests."

Slide 86

Slide 86 text

No content

Slide 87

Slide 87 text

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

Slide 88

Slide 88 text

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

Slide 89

Slide 89 text

The Test VCR

Slide 90

Slide 90 text

"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

Slide 91

Slide 91 text

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

Slide 92

Slide 92 text

VCR WebMock Those tools work pretty well together ❤

Slide 93

Slide 93 text

How we test integrations? “Real life” at Harvest

Slide 94

Slide 94 text

# 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

Slide 95

Slide 95 text

# 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

Slide 96

Slide 96 text

# 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

Slide 97

Slide 97 text

# 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

Slide 98

Slide 98 text

# 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

Slide 99

Slide 99 text

# 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

Slide 100

Slide 100 text

# 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

Slide 101

Slide 101 text

# 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

Slide 102

Slide 102 text

# test/test_helper.rb ActiveSupport::TestCase.class_eval do setup do ... WebMock.disable_net_connect! ... end end Con f iguration WebMock

Slide 103

Slide 103 text

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 ' VCR

Slide 104

Slide 104 text

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 ' VCR

Slide 105

Slide 105 text

# 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

Slide 106

Slide 106 text

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

Slide 107

Slide 107 text

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

Slide 108

Slide 108 text

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

Slide 109

Slide 109 text

Show me the tests! 🙌

Slide 110

Slide 110 text

We use all approaches For real

Slide 111

Slide 111 text

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"

Slide 112

Slide 112 text

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

Slide 113

Slide 113 text

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

Slide 114

Slide 114 text

Puf f ingBilly https://github.com/oesmith/pu ff ing-billy 🚂

Slide 115

Slide 115 text

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."

Slide 116

Slide 116 text

No content

Slide 117

Slide 117 text

# 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

Slide 118

Slide 118 text

# 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

Slide 119

Slide 119 text

# 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

Slide 120

Slide 120 text

# 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

Slide 121

Slide 121 text

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

Slide 122

Slide 122 text

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

Slide 123

Slide 123 text

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

Slide 124

Slide 124 text

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

Slide 125

Slide 125 text

Do we have all the problems?

Slide 126

Slide 126 text

Network conditions Retries set up in CI

Slide 127

Slide 127 text

# 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

Slide 128

Slide 128 text

# test/support/stripe_billing_test_helper.rb module StripeBillingTestHelper def with_stripe_customer(...) end def with_stripe_active_subscription(...) end end Fluctuating data Extra tooling

Slide 129

Slide 129 text

# 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

Slide 130

Slide 130 text

# 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

Slide 131

Slide 131 text

No content

Slide 132

Slide 132 text

✨ VCR ✨

Slide 133

Slide 133 text

The “Ugly” 👹

Slide 134

Slide 134 text

No content

Slide 135

Slide 135 text

No content

Slide 136

Slide 136 text

The Bad 😈

Slide 137

Slide 137 text

Extra tooling 🛠

Slide 138

Slide 138 text

Extra documentation 📝

Slide 139

Slide 139 text

Not always failproof 😅

Slide 140

Slide 140 text

The Good 😇

Slide 141

Slide 141 text

Full visibility on HTTP requests & responses 🕵

Slide 142

Slide 142 text

Know whether you are doing extra API requests 🔁

Slide 143

Slide 143 text

Not manually created stubs 💾

Slide 144

Slide 144 text

Makes easier upgrading, extending functionality and refactoring 🤩

Slide 145

Slide 145 text

No content

Slide 146

Slide 146 text

Better test suite 🤞 😌

Slide 147

Slide 147 text

THANK YOU 🥰 Any questions? Code review? Find me around