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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
. ├── .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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
# 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
# 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):
1 2 3 4 5 6 7 8 |
# 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):
1 2 3 4 5 6 7 8 |
# 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:
1 2 3 4 |
--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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 |
# 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
$ 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
stage('test') { agent { docker { image 'ruby:2.5-alpine' reuseNode true } } steps { sh """ gem install bundler bundle install bundle exec rake """ } } |