Using stubs for the AWS SDK for Ruby
If you plan to write, or have already written, Ruby code that leverages Amazon Web Services, you should consider using AWS Stubs. The ability to stub service responses allows you to develop robust code more quickly and enables you to test your code without incurring any cost. Amazon’s SDK engineers knew that as well, and have provided the capability since the Ruby SDK’s v1 release.
This post dives into two ways of using the AWS SDK for Ruby to stub responses from AWS clients. The first approach will utilize the global Aws.config, providing a set of stub responses for the classes and methods you specify. The second approach will combine AWS stubs with classic mocking, giving your test code more fine-grained control over client stubs and stub responses.
Both approaches have this in common: stubbed AWS clients/methods do not require AWS keys or credentials, and in fact don’t access the internet at all. Their behavior is designed to allow you to stub as much or as little of the AWS ecosystem as you need to.
Choosing a stubbing mechanism
The simpler of these two stubbing mechanisms is to use the global Aws.config. This snippet shows how to stub the list_buckets method for all S3 client instances. This approach works great for picking and choosing which classes to stub, and can be overridden for different cases. All non-stubbed classes will continue invoking HTTP requests to AWS (i.e., be live). Here we’re stubbing all subsequent instances of the `Aws::S3::Client` class:
Aws.config[:s3] = { stub_responses: { list_buckets: {} } }
The other approach supports stubbing clients on an instance-by-instance basis. This is done by constructing a client using the stub_responses argument, and requires some form of dependency injection for testing your code:
s3_client = Aws::S3::Client.new(stub_responses: true)
Regardless of the mechanism, the real benefit comes from stubbing the responses in such a way that best tests your code’s logic.
Knowing what to expect
Code that relies on API response objects need to know what data, if any, is returned from those API calls. While the Ruby SDK documentation is the most authoritative source, AWS client stubs provide a stub_data that returns the top level of the specified operation’s response topology.
stubbed_s3_client.stub_data(operation_name: ‘list_buckets’) #<struct Aws::S3::Types::ListBucketsOutput buckets=[], owner=#>
This highlights an important fact; the API responses are Ruby structs. This is significant because the stubbed response data you provide is mapped into response objects; unlike a Ruby hash, which can be extended by adding additional key/value pairs, structs are immutable with respect to their keys.
This snippet shows how to provide a stubbed response (and how not to):
# this hash will be mapped into the ListBucketsOutput struct Aws.config[:s3] = { stub_responses: { list_buckets: { buckets: [ { name: ‘your_bucket’ } ] } } Aws::S3::Client.new.list_buckets #<struct Aws::S3::Types::ListBucketsOutput buckets=[#], owner=nil> # a similar hash, with a typo, will not be Aws.config[:s3] = { stub_responses: { list_buckets: { bucketss: [ { name: ‘your_bucket’ } ] } } Aws::S3::Client.new.list_buckets ArgumentError: unexpected value at params[:bucketss] # you can also use the native structs to stub responses Aws.config[:s3] = { stub_responses: { list_buckets: { buckets: [ Aws::S3::Types::Bucket.new(name: ‘her_bucket’) ] } } Aws::S3::Client.new.list_buckets #<struct Aws::S3::Types::ListBucketsOutput buckets=[#], owner=nil>
Putting stubs to use
Unit testing often involves substituting consumable APIs with mock APIs that your code can safely interact with. Using Aws.config, instead of creating your own mock classes that mimic the hundreds of AWS service methods available, you’re substituting the behavior of those clients without having to provide mocks.
This RSpec snippet assumes we have a class instance (my_instance) and are testing its get_all_buckets method. The get_all_buckets code internally creates an S3 client and invokes the client’s list_buckets method to return an array of buckets. Here we’re verifying that our method returns a bucket array as expected.
describe '#get_all_buckets' do when 'stubbing s3’s “list_buckets” method' it 'returns an array of bucket' do # create the stub response data: # always respond with an object that contains the attribute 'buckets', where # ‘buckets’ is an array of objects, each containing the attribute 'name' buckets_as_hashes = [ { name: 'your-bucket' }, { name: 'her-bucket' } ] Aws.config[:s3] = { stub_responses: { list_buckets: { buckets: buckets_as_hashes } } } # compare what we stub to what we expect expect(custom_instance.get_all_buckets.first.name) .to eql(buckets_as_hashes.first[:name]) end end end
A more complex case
Application code is seldom limited to being just a wrapper around existing API calls. This code snippet demonstrates a common API feature: paginated responses. Here the code must make multiple list_stack_resources API calls to assemble the return value when a CloudFormation stack has generated more than 100 resources.
Let’s start with the application code:
def retrieve_resources(stack_name:, region: ‘us-west-2’) cfn_client = Aws::CloudFormation::Client.new(region: region) resource_summaries = Array.new next_token = nil loop do if next_token.nil? options = { stack_name: stack_name } else options = { stack_name: stack_name, next_token: next_token } end response = cfn_client.list_stack_resources(options) resource_summaries += response.stack_resource_summaries break if response.next_token.nil? next_token = response.next_token end resource_summaries end
In order to test the logic in our code, we can use a combination of mocking and stubs to exercise ‘retrieve_resources’. First, since we know that the CloudFormation client’s list_stack_resources method returns an array of StackResourceSummary objects, let’s provide a helper method to generate as many StackResourceSummary objects as we like.
def stubbed_stack_summaries(how_many: 1) stub_resource_summaries = [] (1..how_many).each { |i| stub_resource_summaries += [ Aws::CloudFormation::Types::StackResourceSummary.new( last_updated_timestamp: Time.new, logical_resource_id: "resourceid#{i}", physical_resource_id: "resourcename#{i}", resource_status: 'CREATE_COMPLETE', resource_status_reason: nil, resource_type: "Aws::Type::#{i}" ) ] } stub_resource_summaries end
Below, our RSpec code exercises the retrieve_resources method of our_class_instance that contains that method. Notice we’re stubbing multiple calls to the same method, and that those stubbed responses will be returned in the order listed, allowing us to simulate the looping condition.
The first call returns a struct with a non-null next_token value, triggering a second call that fetches the remaining 25 stack resources.
describe '#retrieve_resources and test paging logic' do # create a stubbed client instance (mock the one in our code), and stub data let(:stub_client) { Aws::CloudFormation::Client.new(stub_responses: true) } let(:stub_resource_summaries) { stubbed_stack_summaries(how_many: 125) } context 'when retrieving >100 resources for a large stack' do it 'makes multiple calls using next_token, and assembles the data internally' do # intercept the AWS client’s constructor and substitute our stub client expect(Aws::CloudFormation::Client).to receive(:new).and_return(stub_client) # mimic the condition that triggers > 1 call to the 'list_stack_resources' API: # all ‘live’ calls will return a maximum of 100 StackResourceSummary objects stub_client.stub_responses(:list_stack_resources, Aws::CloudFormation::Types::ListStackResourcesOutput.new( stack_resource_summaries: stub_resource_summaries[0..99], next_token: 'there-are-more' ), Aws::CloudFormation::Types::ListStackResourcesOutput.new( stack_resource_summaries: stub_resource_summaries[100..124], next_token: nil ) ) # We expect retrieve_resources will call the API twice in order to return # an array containing all the stubbed stack_resource_summaries expect(our_class_instance.retrieve_resources(stack_name: ‘anything’)) .to eql(stub_resource_summaries) end end end
Conclusion
Using AWS stubs is straightforward and allows your unit testing to cover more of your code in a more meaningful way. There are a few limitations to keep in mind, however:
- Client stubs do not respond differently to different arguments. Generally you can get around this limitation by predicting the order in which any AWS methods will occur and write your stubs accordingly. Helper methods go a long way toward creating stub data and allow you to create a stubbed ‘AWS ecosystem’.
- For deeply-nested response objects, I recommend stubbing using the native AWS structs instead of hashes. (In some cases, I encountered difficulties with equality matchers and resorted to a custom matcher that converted both the actual and expected values to JSON before comparing them.)
- Stubbed clients don’t *do* anything – they don’t magically add stub objects to stub buckets just because you’ve stubbed the API that does it in the real world!
Check out the GitHub repository accompanying this post. If you have VirtualBox and Vagrant installed, it’s up and running with ‘vagrant up’.
Stelligent Amazon Pollycast
|