My musings on technology, science, math, and more

RSpec Testing for Ruby AWS Lambda Functions

Recently, I wrote an AWS Lambda function at work in Ruby but I didn’t have a handy tool for creating a project skeleton like bundle gem does. That means nothing bootstrapped my testing for me. While copy+pasting code into pry proved that my simple function worked, that wasn’t quite good enough. What I really wanted was the ability to use RSpec with my Lambda code. After a cursory search of the Internet for some examples, I was left disappointed with how little I found. So, I rolled up my sleeves and figured it out.

First, let’s examine exactly how Ruby AWS Lambda functions work. You’ll probably find more complete guides out there, but I’ll try to go over enough to at least describe how it works from a Ruby perspective.

How Data Gets to Ruby

Ruby AWS Lambda functions require calling a method that takes two keyword arguments, one parameter called event and the other context. I tend to call this method lambda_handler(event:, context:). This is also the default name provided when you create a function via the Lambda Console UI.

When I’m using AWS API Gateway to invoke my functions, I use the Lambda proxy integration. This makes the event argument a Hash that contains information about the HTTP request. This hash contains top-level keys like queryStringParameters, headers, body, and httpMethod (plus many more), though most of these are optional, meaning you should verify they exist in your code before using them. The context argument always receives a Context object. I don’t tend to use this argument very often.

My Repository Layout

I tend to structure my repository such that my code is in src/function/. This usually includes a file called lambda_function.rb, my Gemfile, and the Gemfile.lock file (if I’m relying on external dependencies). I’m a fan of AWS SAM for deploying Lambda functions, so I put my [samconfig.toml])(https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-config.html) and template.yaml in src/.

As is typical for RSpec, I put my tests in spec/, with a spec_helper.rb in there and a lambda_function_spec.rb file.

In the root of the repository, I put a Rakefile so I can run rake, a Gemfile (and its associated Gemfile.lock) for testing dependencies, a README.md, and since I use Jenkins, a Jenkinsfile. If you use a different CI product or if you have a more complicated function, you might have different files.

When everything is present, it ends up looking something like this:

.
├── .rspec
├── Gemfile
├── Gemfile.lock
├── Jenkinsfile
├── README.md
├── Rakefile
├── spec
│   ├── lambda_function_spec.rb
│   └── spec_helper.rb
└── src
    ├── function
    │   ├── Gemfile
    |   ├── Gemfile.lock
    │   └── lambda_function.rb
    ├── samconfig.toml
    └── template.yaml

3 directories, 13 files

An Example Function

With this context out of the way, we should be able to get to work on some actual code. Let’s start with a simple function. Here is a function that takes hexadecimal input and converts it to base64:

# frozen_string_literal: true

require 'base64'
require 'json'

def input_from_event(event)
  if event['httpMethod'] == 'GET' && event['queryStringParameters']
    event['queryStringParameters']['hexdata']
  elsif event['httpMethod'] == 'POST' && event['body']
    event['body']
  end
end

def lambda_handler(event:, context:)
  input = input_from_event(event).to_s

  raise 'No input provided' unless input && !input.empty?

  # From Hex (via #pack) to Base64, then remove newlines
  result_data = Base64.encode64([input].pack('H*')).gsub("\n", '')

  {
    headers: { 'Content-Type' => 'text/plain; charset=us-ascii' },
    statusCode: 200,
    body: result_data
  }
rescue StandardError => e
  {
    headers: { 'Content-Type' => 'application/json' },
    statusCode: 422,
    body: JSON.generate(error: e.class.to_s, message: e.message)
  }
end

Hopefully that code isn’t too difficult to follow. It allows either a GET query string parameter (hexdata) or POST data and converts it to base64.

Writing Some Tests

Now to write some tests to verify that the function does what it is supposed to do. First, we’ll need to fill out our spec/spec_helper.rb file, which should look just like it always does:

# frozen_string_literal: true

# load the function code
require_relative '../src/function/lambda_function'

RSpec.configure do |config|
  # Enable flags like --only-failures and --next-failure
  config.example_status_persistence_file_path = '.rspec_status'

  # Disable RSpec exposing methods globally on `Module` and `main`
  config.disable_monkey_patching!

  config.expect_with :rspec do |c|
    c.syntax = :expect
  end
end

The only difference in that file is the use of a require_relative where we pull in the function code.

Let’s confirm that the Gemfile looks right (this is the Gemfile right at the root of the repo):

# frozen_string_literal: true

# Gemfile
source 'https://rubygems.org'

gem 'rake',    '~> 13.0'
gem 'rspec',   '~> 3.9'

And here’s what my Rakefile looks like (should be pretty typical):

# frozen_string_literal: true

require 'rspec/core/rake_task'

RSpec::Core::RakeTask.new(:spec)

task default: %i[spec]

Doesn’t hurt to make sure we have the .rspec file in the root of repository in good shape:

--format documentation
--color
--require spec_helper

Now, finally, we can write some actual tests. Here’s the start of a lambda_funtion_spec.rb file:

# frozen_string_literal: true

RSpec.describe 'Hex to Base64 Lambda Function' do
  # Let's setup some input values
  let(:hex_value_1) { 'ffff' }
  let(:hex_value_2) { '1234567890abcdef' * 4 }

  # The expected responses given the input
  let(:response1) do
    {
      headers: { 'Content-Type' => 'text/plain; charset=us-ascii' },
      statusCode: 200,
      body: '//8='
    }
  end

  let(:response2) do
    {
      headers: { 'Content-Type' => 'text/plain; charset=us-ascii' },
      statusCode: 200,
      body: 'EjRWeJCrze8SNFZ4kKvN7xI0VniQq83vEjRWeJCrze8='
    }
  end

  let(:error_response) do
    {
      headers: { 'Content-Type' => 'application/json' },
      statusCode: 422,
      body: JSON.generate(error: 'RuntimeError', message: 'No input provided')
    }
  end

  # This context is for POST requests
  context 'given POST data' do
    let(:event1) do
      { 'httpMethod' => 'POST', 'body' => hex_value_1 }
    end

    let(:event2) do
      { 'httpMethod' => 'POST', 'body' => hex_value_2 }
    end

    it 'provides expected base64 data' do
      expect(lambda_handler(event: event1, context: nil)).to eq(response1)
      expect(lambda_handler(event: event2, context: nil)).to eq(response2)
    end
  end

  # This context is for GET requests
  context 'given a hexdata GET param' do
    let(:event1) do
      {
        'httpMethod' => 'GET',
        'queryStringParameters' => {
          'hexdata' => hex_value_1
        }
      }
    end

    let(:event2) do
      {
        'httpMethod' => 'GET',
        'queryStringParameters' => {
          'hexdata' => hex_value_2
        }
      }
    end

    it 'provides expected base64 data' do
      expect(lambda_handler(event: event1, context: nil)).to eq(response1)
      expect(lambda_handler(event: event2, context: nil)).to eq(response2)
    end
  end

  # This context is for malformed (empty) requests
  context 'given no input' do
    let(:event1) do
      { 'httpMethod' => 'POST' }
    end

    let(:event2) do
      {
        'httpMethod' => 'GET',
        'queryStringParameters' => {}
      }
    end

    it 'provides expected base64 data' do
      expect(lambda_handler(event: event1, context: nil)).to eq(error_response)
      expect(lambda_handler(event: event2, context: nil)).to eq(error_response)
    end
  end
end

Notice that I setup a context for POST requests, GET requests, and for some bad requests. In each scenario, all I do is call the lambda_handler() method, passing in the constructed event Hash for that scenario.

With a little luck, we should be able to run our tests and get the expected results:

$ bundle install
$ bundle exec rake

Hex to Base64 Lambda Function
  given POST data
    provides expected base64 data
  given a hexdata GET param
    provides expected base64 data
  given no input
    provides expected base64 data

Finished in 0.0029 seconds (files took 0.08614 seconds to load)
3 examples, 0 failures

Integrating into CI

Here’s an example Jenkinsfile stage for executing these tests:

    stage('test') {
      agent {
        docker {
          image 'ruby:2.5-alpine'
          reuseNode true
        }
      }
      steps {
        sh """
        gem install bundler
        bundle install
        bundle exec rake
        """
      }
    }
Spread the love