1. 程式人生 > >Advanced client stubbing in the AWS SDK for Ruby Version 3

Advanced client stubbing in the AWS SDK for Ruby Version 3

The AWS SDK for Ruby provides a robust set of features for stubbing your clients, to make unit tests easier and less fragile. Many of you have used these features to stub your client calls. But in this post, we’re going to explore both a new stub feature in version 3 of the AWS SDK for Ruby, and some advanced stubbing techniques you can add to your testing toolbox.

How to stub a client

The Aws::ClientStubs documentation has several examples of how to write client stubs, but in the most basic form, you do the following:

  1. Set the :stub_responses parameter at client creation.
  2. Write out the stubbed responses you want, sequentially.

For a simple example of stubbing an operation, consider the following.

s3 = Aws::S3::Client.new(stub_responses: true)
s3.stub_responses(:list_buckets, { buckets: [{ name: "my-bucket" }] })
s3.list_buckets.buckets.map(&:name) #=> ['my-bucket']

You can also stub the same operation to provide different responses for sequential calls.

s3 = Aws::S3::Client.new(stub_responses: true)
s3.stub_responses(:get_object,
  'NoSuchKey',
  { body: "Hello!" }
)
begin
  s3.get_object(bucket: "test", key: "test")
rescue Aws::S3::Errors::NoSuchKey
  # You don't NEED to do this, but maybe your test function would be doing this.
  s3.put_object(bucket: "test", key: "test", body: "Doesn't matter")
end
s3.get_object(bucket: "test", key: "test").body.read #=> "Hello!"

This works pretty well for most test use cases, but it can be fragile in others. We’re expecting that API calls will come in a certain sequence, and for that #put_object call, the :body parameter value didn’t matter at all – the stub is fixed. In some cases, we want our stubs to have a bit of dynamic logic, and that’s where dynamic client stubbing is an option.

Dynamic client stubbing

The #stub_responses method accepts more than static response objects in sequence. You can also provide a Proc object, which is able to inspect the request context and dynamically determine a response. To take the previous Amazon S3 example, we could have an in-memory bucket that dynamically tracks objects in the database, and can even be pre-seeded.

bucket = {
  "SeededKey" => { body: "Hello!" }
}
s3 = Aws::S3::Client.new(stub_responses: true)
s3.stub_responses(:get_object, -> (context) {
  obj = bucket[context.params[:key]]
  if obj
    obj
  else
    'NoSuchKey'
  end
})
s3.stub_responses(:put_object, -> (context) {
  bucket[context.params[:key]] = { body: context.params[:body] }
  {}
})
begin
  s3.get_object(bucket: "test", key: "test")
rescue Aws::S3::Errors::NoSuchKey
  s3.put_object(bucket: "test", key: "test", body: "DOES matter!")
end
s3.get_object(bucket: "test", key: "test").body.read #=> "DOES matter!"
s3.get_object(bucket: "test", key: "SeededKey").body.read #=> "Hello!"

We’ll take this even further in the final example.

New feature: recorded API requests

While developing the aws-record gem, we discovered that we needed additional testing functionality around client calls. When creating Amazon DynamoDB tables from attribute and metadata specifications, the main thing we wanted to test was that the #create_table parameters exactly matched what we would have otherwise handcrafted. The stubbed response was mostly irrelevant.

To solve that problem, our tests added a lightweight “handler” that recorded the request parameters. These could then be inspected by our tests and compared to expectations. Starting with version 3.23.0 of aws-sdk-core, this is now a built-in feature of stubbed SDK clients!

You can access this set of API requests directly from your stubbed client, as follows.

require 'aws-sdk-s3'

s3 = Aws::S3::Client.new(stub_responses: true)
s3.create_bucket(bucket: "foo")
s3.api_requests.size # => 1
create_bucket_request = s3.api_requests.first
create_bucket_request[:params][:bucket] # => "foo"

To see how this is used, here is how we’d rewrite one of the Aws::Record::TableConfig unit tests and its prerequisites.

require 'rspec'
require 'aws-record'

class TestModel
  include Aws::Record

  string_attr :hk, hash_key: true
  string_attr :rk, range_key: true
end

module Aws
  module Record
    describe TableConfig do
      describe "#migrate!" do
        it 'will attempt to create the remote table if it does not exist' do
          cfg = TableConfig.define do |t|
            t.model_class(TestModel)
            t.read_capacity_units(1)
            t.write_capacity_units(1)
            t.client_options(stub_responses: true)
          end
          stub_client = cfg.client

          # Sequential responses are appropriate, so a proc stub is more than
          # we need.
          stub_client.stub_responses(
            :describe_table,
            'ResourceNotFoundException',
            { table: { table_status: "ACTIVE" } }
          )
          cfg.migrate!

          # We don't need to know how many calls happened around describing
          # state. We can find the call we care about.
          create_table_request = stub_client.api_requests.find do |req|
            req[:operation_name] == :create_table
          end

          # Parameters are separated into their own key. Full context is available
          # if you want it.
          expect(create_table_request[:params]).to eq(
            table_name: "TestModel",
            provisioned_throughput:
            {
              read_capacity_units: 1,
              write_capacity_units: 1
            },
            key_schema: [
              {
                attribute_name: "hk",
                key_type: "HASH"
              },
              {
                attribute_name: "rk",
                key_type: "RANGE"
              }
            ],
            attribute_definitions: [
              {
                attribute_name: "hk",
                attribute_type: "S"
              },
              {
                attribute_name: "rk",
                attribute_type: "S"
              }
            ]
          )
        end
      end
    end
  end
end

This is one way to make tests a little less fragile, and test both how you handle client responses (via stubbing) and how you form your requests (via the #api_requests log of requests made to the client).

Advanced stubbing test example

Let’s bring all of this together into a runnable test file.

Let’s say we’re testing a class that interacts with Amazon S3. We’re performing relatively basic operations around writing and retrieving objects, but don’t want to keep track of which fixed stubs go in which order.

The code below creates a very simple “Fake S3” with an in-memory hash, and implements the #create_bucket, #get_object, and #put_object APIs for their basic parameters. With this, we’re able to verify that our code makes the client calls we intend and handles the responses, without tracking API client call order details as the tests get more complex.

(A caveat: In reality, you likely wouldn’t be testing the client stubbing functionality directly. Instead, you’d be calling into your own functions and then making these checks. However, for example purposes, the file is standalone.)

require 'rspec'
require 'aws-sdk-s3'

describe "Enhanced Stubbing Example Tests" do
  let(:fake_s3) { {} }
  let(:client) do
    client = Aws::S3::Client.new(stub_responses: true)
    client.stub_responses(
      :create_bucket, ->(context) {
        name = context.params[:bucket]
        if fake_s3[name]
          'BucketAlreadyExists' # standalone strings are treated as exceptions
        else
          fake_s3[name] = {}
          {}
        end
      }
    )
    client.stub_responses(
      :get_object, ->(context) {
        bucket = context.params[:bucket]
        key = context.params[:key]
        b_contents = fake_s3[bucket]
        if b_contents
          obj = b_contents[key]
          if obj
            { body: obj }
          else
            'NoSuchKey'
          end
        else
          'NoSuchBucket'
        end
      }
    )
    client.stub_responses(
      :put_object, ->(context) {
        bucket = context.params[:bucket]
        key = context.params[:key]
        body = context.params[:body]
        b_contents = fake_s3[bucket]
        if b_contents
          b_contents[key] = body
          {}
        else
          'NoSuchBucket'
        end
      }
    )
    client
  end

  it "raises an exception when a bucket is created twice" do
    client.create_bucket(bucket: "foo")
    client.create_bucket(bucket: "bar")
    expect {
      client.create_bucket(bucket: "foo")
    }.to raise_error(Aws::S3::Errors::BucketAlreadyExists)
    expect(client.api_requests.size).to eq(3)
  end

  context "bucket operations" do
    before do
      client.create_bucket(bucket: "test")
    end

    it "can write and retrieve an object" do
      client.put_object(bucket: "test", key: "obj", body: "Hello!")
      obj = client.get_object(bucket: "test", key: "obj")
      expect(obj.body.read).to eq("Hello!")
      expect(client.api_requests.size).to eq(3)
      expect(client.api_requests.last[:params]).to eq(
        bucket: "test",
        key: "obj"
      )
    end

    it "raises the appropriate exception when a bucket doesn't exist" do
      expect {
        client.put_object(
          bucket: "sirnotappearinginthistest",
          key: "knight_sayings",
          body: "Ni!"
        )
      }.to raise_error(Aws::S3::Errors::NoSuchBucket)
      expect(client.api_requests.size).to eq(2)
    end

    it "raises the appropriate exception when an object doesn't exist" do
      expect {
        client.get_object(bucket: "test", key: "404NoSuchKey")
      }.to raise_error(Aws::S3::Errors::NoSuchKey)
      expect(client.api_requests.size).to eq(2)
    end
  end
end

Conclusion

Client stubs in the AWS SDK for Ruby are a powerful tool for unit testing. They provide a way to test without hitting the network, but allow your code to behave like it’s calling the AWS API clients without having to form mocks for full response objects. This can bring your testing closer to “the real thing” and help you develop code with the SDK with increased confidence.