Testing SAM Deployments with AWS SAM Tests and Smoke Tests#
Deploying an application is only half the battle. You can have a successful CloudFormation stack creation, green deployment status across the board, and still discover that your API returns garbage data or your Lambda function silently fails under real load. This is where testing your deployed SAM application becomes critical. Beyond validating that your infrastructure provisioned correctly, you need to verify that your application actually works the way you intended.
In this article, we’ll explore how to build comprehensive tests for your deployed Serverless Application Model applications. We’ll examine smoke testing strategies that go beyond CloudFormation stack status checks, learn how to assert behavior in your live endpoints, and discover how to integrate these tests into your CI/CD pipelines so that deployment failures are caught before they affect users.
Understanding the Testing Gap#
When you deploy a SAM application, the AWS CloudFormation service creates your resources and reports success when the stack reaches a CREATE_COMPLETE or UPDATE_COMPLETE state. But this tells you only that resources were provisioned—not that they work together correctly. Your Lambda function might be misconfigured, your API Gateway might have incorrect IAM permissions, or your database connection might be failing silently.
Consider a common scenario: you deploy a REST API backed by Lambda functions that read from DynamoDB. CloudFormation succeeds, all resources exist, but your function’s IAM role doesn’t actually have permissions to query the table. Without smoke tests, you’d only discover this when a user hits the endpoint in production.
Smoke tests fill this critical gap. They validate your deployed application’s behavior in its actual environment, using the real AWS services and configurations. They’re integration tests that run after deployment, confirming that your application not only exists but functions as intended.
What Makes an Effective Smoke Test?#
Smoke tests should be lightweight, focused, and fast. They’re not comprehensive end-to-end tests covering every edge case—that’s the job of your unit and integration tests. Instead, smoke tests verify the critical path: Can I call my API? Does it return the correct status code? Does the response have the expected structure? Can I invoke my Lambda function and observe its side effects?
A good smoke test typically exercises the happy path through your application, making a few key assertions about the result. It should run in seconds, not minutes, because you’re running it in your CI/CD pipeline after every deployment. And it should be resilient to temporary glitches while still catching real problems—if your function takes three seconds to initialize after deployment, your test shouldn’t fail because of a temporary timeout.
Setting Up Your Test Environment#
Before writing smoke tests, you need to know what you’re testing. Your SAM template defines your application’s outputs—API Gateway URLs, Lambda function names, and other deployed resource identifiers. You’ll want to expose these outputs so your tests can discover them.
In your template.yaml, define outputs for any resources your tests need to access:
Outputs:
GetUserApi:
Description: "API Gateway endpoint for GetUser function"
Value: !Sub "https://${ServerlessApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/users/"
CreateUserFunction:
Description: "Name of the CreateUser Lambda function"
Value: !Ref CreateUserFunction
UsersTable:
Description: "Name of the DynamoDB table"
Value: !Ref UsersTableWhen your SAM application deploys, CloudFormation stores these outputs in the stack. Your test suite can then query the stack to retrieve the actual deployed resource identifiers:
import boto3
import json
cloudformation = boto3.client('cloudformation')
def get_stack_outputs(stack_name):
"""Retrieve outputs from a deployed CloudFormation stack."""
response = cloudformation.describe_stacks(StackName=stack_name)
stack = response['Stacks'][0]
outputs = {}
for output in stack.get('Outputs', []):
outputs[output['OutputKey']] = output['OutputValue']
return outputs
# Usage
stack_name = 'my-serverless-app-prod'
outputs = get_stack_outputs(stack_name)
api_endpoint = outputs['GetUserApi']This approach makes your tests environment-agnostic. Whether you’re testing in development, staging, or production, your test code queries the actual deployed resources rather than assuming fixed names or endpoints.
Testing API Endpoints#
The most straightforward smoke test validates that your API endpoints respond correctly. You’ll typically make an HTTP request to your endpoint and assert on the response status code and body structure.
Here’s a practical example in Python using the requests library:
import requests
import json
import pytest
class TestUserApi:
@pytest.fixture(scope="module", autouse=True)
def setup(self):
"""Set up test fixtures with deployed resource identifiers."""
outputs = get_stack_outputs('my-serverless-app-prod')
self.api_endpoint = outputs['GetUserApi']
def test_get_user_success(self):
"""Verify that GetUser endpoint returns valid data."""
user_id = '12345'
response = requests.get(f'{self.api_endpoint}{user_id}')
assert response.status_code == 200
data = response.json()
assert 'userId' in data
assert 'name' in data
assert 'email' in data
assert data['userId'] == user_id
def test_get_user_not_found(self):
"""Verify that GetUser returns 404 for nonexistent user."""
response = requests.get(f'{self.api_endpoint}nonexistent-id')
assert response.status_code == 404
def test_create_user_validates_input(self):
"""Verify that CreateUser rejects invalid input."""
response = requests.post(
f'{self.api_endpoint}',
json={'name': 'John'} # Missing required 'email' field
)
assert response.status_code == 400
error = response.json()
assert 'email' in error['message'].lower()Notice that these tests validate not just happy-path behavior but also error cases. A deployed application should gracefully handle bad input, and smoke tests should confirm this.
The equivalent in Node.js using the axios library:
const axios = require('axios');
const AWS = require('aws-sdk');
const cloudformation = new AWS.CloudFormation();
async function getStackOutputs(stackName) {
const response = await cloudformation.describeStacks({ StackName: stackName }).promise();
const outputs = {};
response.Stacks[0].Outputs.forEach(output => {
outputs[output.OutputKey] = output.OutputValue;
});
return outputs;
}
describe('User API Smoke Tests', () => {
let apiEndpoint;
beforeAll(async () => {
const outputs = await getStackOutputs('my-serverless-app-prod');
apiEndpoint = outputs.GetUserApi;
});
test('GetUser endpoint returns valid user data', async () => {
const userId = '12345';
const response = await axios.get(`${apiEndpoint}${userId}`);
expect(response.status).toBe(200);
expect(response.data).toHaveProperty('userId', userId);
expect(response.data).toHaveProperty('name');
expect(response.data).toHaveProperty('email');
});
test('GetUser returns 404 for nonexistent user', async () => {
try {
await axios.get(`${apiEndpoint}nonexistent-id`);
fail('Expected 404 error');
} catch (error) {
expect(error.response.status).toBe(404);
}
});
test('CreateUser rejects invalid input', async () => {
try {
await axios.post(apiEndpoint, { name: 'John' });
fail('Expected 400 error');
} catch (error) {
expect(error.response.status).toBe(400);
}
});
});When writing API tests, be thoughtful about what data you use. For smoke tests, you’re typically working with real deployed infrastructure, so avoid creating test data that pollutes your production database. Instead, consider using a separate test user account or environment, or design your tests to be idempotent (safe to run multiple times).
Testing Lambda Function Behavior#
Not all serverless applications expose API endpoints. Some Lambda functions are triggered by events—SNS messages, S3 uploads, or scheduled events. For these, you’ll invoke the function directly using the AWS SDK and verify its behavior.
Here’s how to test a Lambda function that processes messages and writes results to DynamoDB:
import boto3
import json
import time
lambda_client = boto3.client('lambda')
dynamodb = boto3.resource('dynamodb')
def test_process_message_function():
"""Test that ProcessMessage Lambda correctly processes input."""
outputs = get_stack_outputs('my-serverless-app-prod')
function_name = outputs['ProcessMessageFunction']
table_name = outputs['ProcessedMessagesTable']
# Prepare test event
test_event = {
'messageId': 'test-msg-123',
'content': 'Hello, world!',
'timestamp': int(time.time())
}
# Invoke the function
response = lambda_client.invoke(
FunctionName=function_name,
InvocationType='RequestResponse',
Payload=json.dumps(test_event)
)
# Verify the Lambda response
assert response['StatusCode'] == 200
payload = json.loads(response['Payload'].read())
assert payload['statusCode'] == 200
assert 'messageId' in payload['body']
# Verify side effects (data written to DynamoDB)
time.sleep(1) # Allow time for write operation
table = dynamodb.Table(table_name)
item = table.get_item(Key={'messageId': 'test-msg-123'})
assert 'Item' in item
assert item['Item']['status'] == 'processed'In this test, we’re not just checking that the Lambda function returns successfully—we’re also verifying its side effects. The function should write to DynamoDB, and we confirm that the data actually appears in the table with the expected values.
The Node.js equivalent:
const AWS = require('aws-sdk');
const lambda = new AWS.Lambda();
const dynamodb = new AWS.DynamoDB.DocumentClient();
async function testProcessMessageFunction() {
const outputs = await getStackOutputs('my-serverless-app-prod');
const functionName = outputs.ProcessMessageFunction;
const tableName = outputs.ProcessedMessagesTable;
const testEvent = {
messageId: 'test-msg-123',
content: 'Hello, world!',
timestamp: Math.floor(Date.now() / 1000)
};
// Invoke the function
const response = await lambda.invoke({
FunctionName: functionName,
InvocationType: 'RequestResponse',
Payload: JSON.stringify(testEvent)
}).promise();
expect(response.StatusCode).toBe(200);
const payload = JSON.parse(response.Payload);
expect(payload.statusCode).toBe(200);
// Wait for eventual consistency and verify side effects
await new Promise(resolve => setTimeout(resolve, 1000));
const item = await dynamodb.get({
TableName: tableName,
Key: { messageId: 'test-msg-123' }
}).promise();
expect(item.Item).toBeDefined();
expect(item.Item.status).toBe('processed');
}When testing Lambda function side effects, be aware of eventual consistency. If your function writes to DynamoDB and you immediately query for the result, you might get a stale response. A brief pause (one to two seconds) usually suffices for smoke tests.
Verifying Resource Creation and Configuration#
Sometimes you want to verify that resources were created correctly and have the expected configuration. This goes beyond testing application behavior and into infrastructure validation.
For example, you might want to confirm that your Lambda function has specific environment variables set, that its timeout is configured correctly, or that it has the right IAM permissions:
import boto3
lambda_client = boto3.client('lambda')
iam_client = boto3.client('iam')
def test_lambda_configuration():
"""Verify Lambda function is configured correctly."""
outputs = get_stack_outputs('my-serverless-app-prod')
function_name = outputs['ProcessMessageFunction']
# Get function configuration
config = lambda_client.get_function_configuration(FunctionName=function_name)
# Verify timeout and memory
assert config['Timeout'] == 60, "Function should have 60 second timeout"
assert config['MemorySize'] == 512, "Function should have 512 MB memory"
# Verify environment variables
env_vars = config.get('Environment', {}).get('Variables', {})
assert env_vars.get('LOG_LEVEL') == 'INFO'
assert env_vars.get('TABLE_NAME') is not None
# Verify IAM role has DynamoDB permissions
role_name = config['Role'].split('/')[-1]
inline_policies = iam_client.list_role_policies(RoleName=role_name)
assert len(inline_policies['PolicyNames']) > 0, "Role should have policies attached"
# Get and verify a specific policy
policy = iam_client.get_role_policy(
RoleName=role_name,
PolicyName='DynamoDBAccess'
)
policy_doc = policy['PolicyDocument']
actions = policy_doc['Statement'][0]['Action']
assert 'dynamodb:GetItem' in actions
assert 'dynamodb:PutItem' in actionsAnd in Node.js:
async function testLambdaConfiguration() {
const outputs = await getStackOutputs('my-serverless-app-prod');
const functionName = outputs.ProcessMessageFunction;
const config = await lambda.getFunctionConfiguration({ FunctionName: functionName }).promise();
expect(config.Timeout).toBe(60);
expect(config.MemorySize).toBe(512);
const envVars = config.Environment?.Variables || {};
expect(envVars.LOG_LEVEL).toBe('INFO');
expect(envVars.TABLE_NAME).toBeDefined();
// Verify IAM role
const roleName = config.Role.split('/').pop();
const policies = await iam.listRolePolicies({ RoleName: roleName }).promise();
expect(policies.PolicyNames.length).toBeGreaterThan(0);
const policy = await iam.getRolePolicy({
RoleName: roleName,
PolicyName: 'DynamoDBAccess'
}).promise();
const actions = policy.PolicyDocument.Statement[0].Action;
expect(actions).toContain('dynamodb:GetItem');
expect(actions).toContain('dynamodb:PutItem');
}These configuration tests help catch deployment issues early. If your SAM template has a typo that results in a Lambda function without the required environment variable, you’ll know immediately rather than discovering it when the function fails at runtime.
Monitoring and Metrics Validation#
For critical functions, you might want to verify that CloudWatch metrics indicate healthy operation. This can catch issues that aren’t immediately obvious—a function that’s invoked successfully but takes unexpectedly long, or one that has a high error rate.
import boto3
from datetime import datetime, timedelta
cloudwatch = boto3.client('cloudwatch')
def test_lambda_metrics():
"""Verify Lambda function metrics indicate healthy operation."""
outputs = get_stack_outputs('my-serverless-app-prod')
function_name = outputs['ProcessMessageFunction']
# Query CloudWatch metrics for the last 5 minutes
end_time = datetime.utcnow()
start_time = end_time - timedelta(minutes=5)
# Check invocations
invocations = cloudwatch.get_metric_statistics(
Namespace='AWS/Lambda',
MetricName='Invocations',
Dimensions=[{'Name': 'FunctionName', 'Value': function_name}],
StartTime=start_time,
EndTime=end_time,
Period=300,
Statistics=['Sum']
)
assert len(invocations['Datapoints']) > 0, "Function should have been invoked"
total_invocations = invocations['Datapoints'][0]['Sum']
assert total_invocations > 0
# Check errors
errors = cloudwatch.get_metric_statistics(
Namespace='AWS/Lambda',
MetricName='Errors',
Dimensions=[{'Name': 'FunctionName', 'Value': function_name}],
StartTime=start_time,
EndTime=end_time,
Period=300,
Statistics=['Sum']
)
total_errors = errors['Datapoints'][0]['Sum'] if errors['Datapoints'] else 0
error_rate = (total_errors / total_invocations) * 100 if total_invocations > 0 else 0
assert error_rate < 5, f"Error rate is {error_rate}%, should be less than 5%"
# Check duration
duration = cloudwatch.get_metric_statistics(
Namespace='AWS/Lambda',
MetricName='Duration',
Dimensions=[{'Name': 'FunctionName', 'Value': function_name}],
StartTime=start_time,
EndTime=end_time,
Period=300,
Statistics=['Average']
)
if duration['Datapoints']:
avg_duration = duration['Datapoints'][0]['Average']
assert avg_duration < 5000, f"Average duration is {avg_duration}ms, should be less than 5 seconds"Metrics validation is particularly useful for smoke tests that run continuously in your environment. If performance degrades or error rates spike, these tests will catch it.
Integrating Smoke Tests into CI/CD Pipelines#
Smoke tests are most valuable when they run automatically after every deployment. Integration into your CI/CD pipeline ensures that bad deployments never go unnoticed.
Here’s how you might structure this in AWS CodePipeline with CodeBuild:
# In your template.yaml or buildspec.yml
phases:
post_build:
commands:
- echo "Running smoke tests against deployed application..."
- aws cloudformation describe-stacks --stack-name my-serverless-app-prod --query 'Stacks[0].Outputs' > /tmp/stack-outputs.json
- pip install -r tests/requirements.txt
- pytest tests/smoke/ -v --tb=short
- if [ $? -ne 0 ]; then echo "Smoke tests failed"; exit 1; fiFor a more sophisticated approach, consider a separate CodePipeline stage that runs only after successful deployment:
# In your pipeline configuration
Stages:
- Name: Deploy
Actions:
- Name: CreateChangeSet
ActionTypeId:
Category: Deploy
Owner: AWS
Provider: CloudFormation
Version: '1'
- Name: ExecuteChangeSet
ActionTypeId:
Category: Deploy
Owner: AWS
Provider: CloudFormation
Version: '1'
- Name: SmokeTests
Actions:
- Name: RunSmokeTests
ActionTypeId:
Category: Build
Owner: AWS
Provider: CodeBuild
Version: '1'
Configuration:
ProjectName: my-app-smoke-tests
EnvironmentVariablesOverride:
- name: STACK_NAME
value: my-serverless-app-prodIn your CodeBuild project, structure your test execution to fail the build if any smoke test fails:
#!/bin/bash
set -e
echo "Fetching stack outputs..."
OUTPUTS=$(aws cloudformation describe-stacks \
--stack-name ${STACK_NAME} \
--query 'Stacks[0].Outputs' \
--output json)
echo "Installing dependencies..."
pip install -r tests/requirements.txt
echo "Running smoke tests..."
pytest tests/smoke/ \
-v \
--tb=short \
--junit-xml=test-results.xml \
--cov=tests/smoke \
--cov-report=xml
echo "Smoke tests passed!"This approach ensures that if your smoke tests fail, the deployment pipeline stops and prevents the bad code from progressing to the next environment.
Best Practices for Reliable Smoke Tests#
As you develop your smoke tests, keep a few key principles in mind. First, tests should be deterministic—they should produce the same result every time, without relying on timing or random data. Use fixed test data or clean up resources after each test run.
Second, keep tests isolated. If you’re creating test data, make sure each test can run independently without depending on previous test runs. If tests need to run sequentially, make that explicit and document why.
Third, ensure tests are resilient to timing. Cloud operations aren’t instantaneous. When you invoke a Lambda function that writes to DynamoDB, a brief delay before querying the result is expected. Build in reasonable waits (typically one to two seconds) rather than assuming instant consistency.
Fourth, use appropriate assertions. Don’t just check that a request succeeded; verify the response structure and key data. This catches subtle bugs where the endpoint returns data but it’s malformed or incomplete.
Finally, keep smoke tests focused and fast. Each test should exercise a single logical path through your application. Run your entire smoke test suite in under a minute if possible. If tests take longer, they won’t run frequently enough to catch problems quickly.
Handling Different Environments#
As your application moves through development, staging, and production, smoke tests should adapt to each environment. Rather than hardcoding environment-specific details, parameterize your test suite:
import os
import pytest
@pytest.fixture(scope="session")
def stack_name():
"""Get the stack name from environment variable."""
env = os.getenv('ENVIRONMENT', 'dev')
return f'my-serverless-app-{env}'
@pytest.fixture(scope="session")
def outputs(stack_name):
"""Retrieve outputs for the target stack."""
return get_stack_outputs(stack_name)
def test_get_user_success(outputs):
"""Test that works with any deployed environment."""
api_endpoint = outputs['GetUserApi']
response = requests.get(f'{api_endpoint}test-user')
assert response.status_code == 200This way, the same test code works against development, staging, and production—you just change the environment variable. This reduces duplication and ensures consistency across environments.
Conclusion#
Smoke tests are an essential safety net for serverless applications. They bridge the gap between infrastructure provisioning and actual application behavior, catching deployment issues that CloudFormation status checks would miss. By testing your API endpoints, verifying Lambda function behavior, validating resource configuration, and monitoring metrics, you gain confidence that your deployed application actually works.
The key to effective smoke testing is automation. Integrate these tests into your CI/CD pipeline so they run after every deployment, providing immediate feedback. Use your test code to query deployed resources dynamically, avoiding hardcoded assumptions. Write tests that verify not just the happy path but also error conditions and edge cases. And keep tests focused, fast, and reliable so they provide genuine value rather than becoming a source of flaky failures.
With comprehensive smoke tests in place, you can deploy with confidence, knowing that bad deployments will be caught before they reach users.