Unit Testing with SAM
Complete the full lesson to earn 25 points
Work through each section, then tap “Mark as Complete” on the last one.
✦ Skip the page breaks and see fewer ads — read each lesson on a single page with Pro
Unit Testing with AWS Serverless Application Model (SAM)
Introduction: Why Unit Testing Matters in Serverless
When building applications on AWS using the Serverless Application Model (SAM), the development lifecycle shifts significantly compared to traditional monolithic architectures. In a serverless environment, your code is often fragmented into individual functions triggered by events. Because these functions are small and highly specialized, developers sometimes fall into the trap of thinking that unit testing is less necessary, or that they can rely entirely on integration testing by deploying to the cloud. This is a dangerous assumption.
Unit testing is the practice of testing the smallest testable parts of an application—usually individual functions or classes—in isolation from external dependencies like databases, APIs, or other AWS services. In the context of SAM, unit testing allows you to verify your business logic locally, rapidly, and without incurring costs or waiting for deployment cycles. By writing robust unit tests, you catch bugs early, document your code’s expected behavior, and ensure that refactoring does not break existing features.
This lesson explores how to approach unit testing for SAM-based applications. We will look at mocking dependencies, structuring your code for testability, and using local testing tools to maintain high velocity without compromising on quality. Whether you are building with Python, Node.js, or Java, the principles of isolation and predictability remain the same.
The Architecture of Testability
The biggest challenge in serverless unit testing is the tight coupling between business logic and the AWS environment. If your Lambda function code is filled with boto3 calls, direct event parsing, and environment variable lookups, it becomes nearly impossible to test without mocking the entire AWS SDK.
To write effective unit tests, you must adopt a pattern known as the "Hexagonal Architecture" or "Ports and Adapters" approach. The core idea is to separate your business logic from the infrastructure code. Your Lambda handler should ideally do nothing more than parse the incoming event, call a service layer that contains your business logic, and format the response.
Why Separate Logic from Handlers?
If your business logic lives inside the lambda_handler function, you are forced to construct complex event objects every time you want to test a simple calculation or data transformation. By moving that logic into separate modules or classes, you can instantiate those objects in your test suite and call their methods directly with standard data structures. This reduces the setup time for your tests from minutes to milliseconds.
Callout: The "Handler as a Gateway" Pattern Think of your Lambda handler as a gateway or a controller. Its only responsibility is to translate the incoming event into a format your business logic understands and translate the result back into a format that the API Gateway or EventBridge expects. If your handler is longer than 10-15 lines of code, you likely have business logic inside it that should be moved to a separate unit-testable module.
Setting Up Your Testing Environment
Before writing a single test, you need a testing framework. For Python, pytest is the industry standard due to its simplicity and powerful fixture system. For Node.js, Jest or Mocha are the preferred choices. Regardless of the language, your project structure should clearly separate application code from test code.
A recommended project layout looks like this:
my-sam-app/
├── template.yaml
├── src/
│ └── process_data/
│ ├── app.py # The Lambda handler
│ └── logic.py # Business logic
├── tests/
│ ├── unit/
│ │ └── test_logic.py # Unit tests for logic.py
│ └── integration/
│ └── test_api.py # Integration tests
└── requirements.txt
Installing Dependencies
To get started, ensure you have your testing framework installed in your local virtual environment or package.json. For a Python project, you would typically run:
pip install pytest pytest-mock
The pytest-mock library is essential for serverless development because it allows you to intercept calls to AWS services (like S3 or DynamoDB) and return canned responses without hitting the actual cloud infrastructure.
Writing Your First Unit Test: A Practical Example
Let’s look at a scenario where we have a Lambda function that processes a user registration. The function needs to check if a user exists in DynamoDB and then save their information.
The Business Logic (logic.py)
def validate_user_email(email):
if "@" not in email:
raise ValueError("Invalid email format")
return True
def register_user(db_client, user_data):
# Business logic: validate and save
validate_user_email(user_data['email'])
db_client.put_item(
TableName='UsersTable',
Item={'email': user_data['email'], 'name': user_data['name']}
)
return {"status": "success"}
The Test Suite (test_logic.py)
Here, we don't need a real DynamoDB table. We use a mock object to simulate the db_client.
import pytest
from unittest.mock import MagicMock
from src.process_data.logic import register_user
def test_register_user_success():
# Arrange
mock_db = MagicMock()
user_data = {"email": "[email protected]", "name": "John Doe"}
# Act
result = register_user(mock_db, user_data)
# Assert
assert result["status"] == "success"
mock_db.put_item.assert_called_once()
This test is fast, deterministic, and requires no network connectivity. If the put_item call fails in the future, you will know immediately because the mock verification will fail, not because the network timed out or the table was deleted.
Advanced Mocking Techniques
In complex serverless applications, you often interact with multiple AWS services. Mocking boto3 objects can become repetitive and error-prone. To handle this, use pytest fixtures to define common mock behaviors that can be reused across your test suite.
Using Fixtures for Mocking
Instead of creating a new mock inside every test function, define a fixture in your conftest.py file:
import pytest
from unittest.mock import MagicMock
@pytest.fixture
def mock_dynamodb():
client = MagicMock()
return client
Now, your test becomes even cleaner:
def test_register_user_with_fixture(mock_dynamodb):
user_data = {"email": "[email protected]", "name": "John Doe"}
result = register_user(mock_dynamodb, user_data)
assert result["status"] == "success"
Callout: Mocking vs. Emulation It is important to distinguish between mocking and emulating. Mocking involves replacing a real dependency with a controlled object that records calls. Emulating involves running a local service (like
LocalStack) that mimics the actual AWS API. For unit tests, use mocks; for integration tests, use emulators. Using emulators for unit tests is usually overkill and slows down your development cycle.
Handling Environment Variables
Lambda functions rely heavily on environment variables for configuration (e.g., table names, queue URLs). During local unit testing, these variables might not be set, leading to KeyError exceptions.
Best Practice: Dependency Injection
Do not read os.environ directly inside your business logic functions. Instead, pass the configuration as arguments to your functions.
Bad Practice:
def get_user():
table_name = os.environ['TABLE_NAME'] # Hard to test, requires patching
return db.get_item(TableName=table_name)
Good Practice:
def get_user(table_name, db_client):
return db_client.get_item(TableName=table_name)
By passing table_name as an argument, you can easily pass a dummy string like "TestTable" during your unit tests without needing to mock os.environ.
Step-by-Step: Testing a SAM Lambda Function
If you have a standard SAM project, follow these steps to integrate unit testing:
- Isolate Logic: Identify the core logic in your
app.py. Move it to a separate file (e.g.,services.py). - Create Test Directory: Create a
tests/unitfolder. - Setup Fixtures: Create a
conftest.pyin the tests directory to handle common mocks (Boto3 clients, API Gateway event structures). - Write Tests: Write tests for the
services.pylogic usingpytest. Focus on edge cases: null inputs, malformed JSON, and service errors. - Test the Handler: Write a single test for
app.pythat verifies the handler correctly calls the logic and returns the expected status code. - Automate: Add a test command to your
Makefileorpackage.jsonso you can runmake testornpm testbefore every commit.
Common Pitfalls and How to Avoid Them
1. Testing the Infrastructure
Developers often try to write unit tests that verify if the S3 bucket exists or if the IAM role is correct. These are not unit tests; these are infrastructure validation tests. Use sam validate or AWS CloudFormation Guard to verify your templates. Do not write code to test your infrastructure configuration.
2. Over-Mocking
If you find yourself writing more code to mock the Boto3 client than you are writing for the actual business logic, your function is likely doing too much. Break your function into smaller, more focused units. If a function is too hard to test, it is usually a sign that it violates the Single Responsibility Principle.
3. Ignoring Error Paths
Most developers test the "happy path" (where everything goes right). In serverless, error handling is critical. What happens if DynamoDB is throttled? What if the S3 file is empty? Use your unit tests to force these error conditions by configuring your mocks to raise exceptions.
Tip: Use
pytest.raisesto verify that your function correctly handles and re-raises or catches exceptions.def test_invalid_email_raises_error(): with pytest.raises(ValueError, match="Invalid email format"): validate_user_email("invalid-email")
4. Relying on "Sam Local Invoke" for Unit Testing
While sam local invoke is a powerful tool for testing the entire Lambda lifecycle, it is not a unit test tool. It is slow because it builds a Docker container and simulates the Lambda runtime. Use sam local invoke for final sanity checks, not for the iterative development of your business logic.
Comparison Table: Testing Strategies
| Strategy | Speed | Isolation | Fidelity | Use Case |
|---|---|---|---|---|
| Unit Tests | Extremely Fast | Complete | Low | Business logic validation |
| Local Emulation | Moderate | Partial | Medium | Verifying SDK usage |
| Integration Tests | Slow | None | High | Testing against real AWS resources |
| Smoke Tests | Slow | None | Very High | Post-deployment verification |
Best Practices for Serverless Testing
- Keep Tests Fast: If your test suite takes more than a few seconds to run, you will stop running it. Mock everything that involves network I/O.
- Use Descriptive Names: Name your tests after the scenario they are testing (e.g.,
test_process_order_fails_when_inventory_is_zero). - Run Tests in CI/CD: Integrate your unit tests into your CI/CD pipeline (e.g., GitHub Actions, AWS CodePipeline). If the tests fail, the build should fail.
- Avoid Global State: Do not rely on global variables or state that persists between tests. Each test should be entirely independent.
- Use Type Hinting: If using Python, use type hints. They help catch errors before the tests even run and make the code easier to read.
- Keep Logic Thin: If you are finding it hard to mock dependencies, it is a sign that your function is too large. Refactor it into smaller, decoupled pieces.
Handling Asynchronous Events
One of the most complex parts of SAM development is testing functions triggered by asynchronous events like SQS, SNS, or EventBridge. These events have complex JSON structures.
Instead of manually crafting these JSON objects in your tests, save representative examples of these events in a tests/events/ directory.
import json
def load_event(event_name):
with open(f"tests/events/{event_name}.json") as f:
return json.load(f)
def test_sqs_handler():
event = load_event("sqs_message")
# Call your handler with the loaded event
response = lambda_handler(event, None)
assert response['statusCode'] == 200
This approach allows you to maintain a library of test events that mirror the real traffic your Lambda will receive in production.
Debugging Failed Tests
When a unit test fails, the error message should be your first source of information. If you are using mocks, check the call logs. pytest-mock allows you to inspect the arguments passed to your mocked functions.
Example of inspecting a mock call:
def test_db_call_arguments(mock_dynamodb):
# Act
do_something(mock_dynamodb)
# Assert
args, kwargs = mock_dynamodb.put_item.call_args
assert kwargs['TableName'] == 'ExpectedTable'
If the error is cryptic, use a debugger like pdb (for Python) or the built-in debugger in VS Code. Set a breakpoint in your test code and step through the execution. Because unit tests are just standard code, they are fully compatible with your IDE's debugging tools.
The Role of Integration Testing
While this lesson focuses on unit testing, it is important to mention where integration testing fits. Once your unit tests pass, you should run integration tests against a "dev" environment in AWS.
Use the sam deploy --guided command to create a stack specifically for testing. Your integration tests should verify that:
- IAM roles have the correct permissions.
- The Lambda function has the correct environment variables.
- The function can actually communicate with other AWS services.
Do not use unit tests to verify IAM permissions; that is what integration tests are for.
Common Questions (FAQ)
Q: Should I mock every single function call? A: No. Only mock external dependencies (AWS services, third-party APIs). Mocking internal helper functions often leads to "brittle" tests that break every time you change the implementation details of your code.
Q: How do I handle testing with third-party libraries like requests?
A: Use the responses library in Python to mock outgoing HTTP calls. It allows you to define the response for a specific URL without actually making the network request.
Q: What if my code uses boto3.resource instead of boto3.client?
A: Both can be mocked using MagicMock. However, client is generally preferred in Lambda functions because it is a lower-level interface that is easier to mock than the high-level resource objects.
Q: Is it okay to use moto for testing?
A: Yes, moto is a fantastic library that provides a fake implementation of AWS services. It is more sophisticated than simple mocking but lighter than full emulation. It is highly recommended for unit testing code that interacts heavily with AWS services.
Key Takeaways
- Isolation is Key: Unit tests must be isolated from external systems. If your test requires an internet connection or a database, it is not a unit test.
- Architect for Testability: Decouple your business logic from the AWS Lambda handler. Keep the handler thin and the logic rich.
- Mocking is Essential: Use libraries like
unittest.mockormototo simulate AWS service responses. This ensures your tests are fast and deterministic. - Use Fixtures: Leverage testing framework features like
pytestfixtures to manage setup and teardown of mock objects efficiently. - Test the Error Paths: Don't just test success. Use your tests to verify that your code fails gracefully under unexpected conditions.
- Don't Over-Mock: Focus on mocking boundaries (the edge of your system). Avoid mocking your own internal functions to prevent brittle tests.
- Automate Everything: Integrate your tests into your development workflow. A test that isn't run is a test that doesn't exist.
By following these principles, you will transform your SAM development process from a cycle of "deploy-and-pray" to a disciplined, reliable engineering practice. Your code will be more maintainable, your bugs will be caught earlier, and your confidence in deploying to production will increase significantly. Remember, the goal of unit testing is not just to find bugs, but to provide a safety net that allows you to improve and evolve your serverless application with speed and precision.
Continue the course
Enjoying the courses?
Everything stays free. Pro shows fewer ads, doubles your daily points limit so you progress twice as fast, and lets you read each lesson on one page.
- ✓ Fewer advertisements
- ✓ 2× daily points limit
- ✓ Distraction-free lessons