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