DEV Community

Cover image for Automated Integration Tests for the Deployed Hello World API
Gloria for AWS Community Builders

Posted on

Automated Integration Tests for the Deployed Hello World API

In Part 1, we built our Hello World API and got it running locally. But writing code is only half the story — you also need to know it actually works, both before and after it leaves your machine.

In this part, we will pick up where we left off. We will run local integration tests to catch issues early, deploy the API to AWS, verify the live deployment by hand with a browser and curl, and then add automated integration tests against the real deployed stack — so you are never relying on manual checks alone.

Along the way, I share a few challenges I hit and how I worked through them, before wrapping up with final takeaways.

By the end, you will have a full picture of the workflow across three stages — local integration testing, manual verification of the deployed API, and automated integration testing against the live AWS stack. There is also a hands-on challenge at the end:

-Add a new /goodbye endpoint yourself and push it through all three test layers, from unit test to live deployment. That is your chance to practice the full workflow, not just read about it.

TL;DR — What We Built in Part 1

In Part 1 we built a serverless REST API from scratch using AWS SAM, Python 3.11, and API Gateway. Here is what we shipped:

  • A template.yaml that defines all infrastructure as code — API Gateway, Lambda function, three routes, CORS, throttling, and CloudWatch metrics
  • A Lambda function (app.py) with three endpoints: GET /, GET /hello, and GET /get-documentation
  • 15 unit tests using pytest that call Lambda directly — no network, no Docker, no AWS The unit tests all passed in 0.16 seconds. But, here is the question:

If your unit tests all pass, does that mean your API actually works?

Not necessarily. And Part 2 is the answer.


Introduction — What Part 2 Covers

Unit tests call your Lambda function directly in Python. They bypass API Gateway entirely. They never touch a network. They never check whether your template.yaml wires the routes correctly, or whether the deployed stack responds the way a real client expects.

Integration tests fix that. They go through the real path a request takes:

Client → API Gateway → Lambda → API Gateway → Client
Enter fullscreen mode Exit fullscreen mode

In this article you will:

  • ✅ Understand what integration testing is and why unit tests are not enough
  • ✅ Learn the 2 types of integration tests this project implements
  • ✅ Write and run local integration tests against sam local start-api
  • ✅ Deploy the API to AWS and manually verify all endpoints
  • ✅ Write and run deployed integration tests against your real AWS stack
  • ✅ Challenges and lessons learned
  • ✅ Hands-on challenge

What Is Integration Testing?

A unit test checks one small piece of your code in isolation. It calls a single function, passes in a fake input, and checks the output. Fast. No network. No infrastructure.

An integration test checks how multiple pieces work together. For a serverless API, that means:

  • Does API Gateway route the request to the right Lambda function?
  • Do the CORS headers actually arrive at a real HTTP client?
  • What does a client receive when they hit a path that does not exist?
  • Does the deployed stack behave the same as the local simulation? These questions cannot be answered by calling lambda_handler() directly. You need a real HTTP request going through the full stack.

Unit tests prove your logic. Integration tests prove your wiring. Both are necessary. Neither replaces the other.


The 2 Types of Integration Tests This Project Implements

This project implements integration testing at two levels, in a deliberate order:

Type 1 — Local integration tests

Target: sam local start-api running on localhost:3000

SAM runs a Docker container that simulates Lambda locally and routes requests through a local API Gateway. Your tests hit localhost:3000 over real HTTP — no mocking, no shortcuts — but nothing leaves your machine and you pay nothing.

Running these tests before deploying catches wiring problems early and gives you fast feedback.

Type 2 — Deployed integration tests

Target: your real AWS stack after sam deploy

These tests fetch the live API Gateway URL from CloudFormation and send real HTTP requests to your deployed Lambda function in AWS. This is as close as you can get to what a real user experiences.

Run these after deploying. They confirm your real AWS infrastructure works end-to-end.

Local integration Deployed integration
Target localhost:3000 Real AWS URL
Docker needed Yes No
AWS account needed No Yes
Cost Free Tiny (pay-per-request Lambda)
What it catches SAM wiring, routing, local behaviour Real deployment, real latency, real API Gateway

Prerequisites

You need everything from Part 1, plus:

  • requests installed: python -m pip install requests
  • boto3 installed: python -m pip install boto3

Note: Use python -m pip install instead of just pip install. This guarantees you are installing into the same Python environment that pytest uses. Using plain pip can silently install into a different environment — your tests will then fail with ModuleNotFoundError even though you think the package is installed.


Part 1: Local Integration Tests

Step 1: Create the Integration Test Folder

Your unit tests live in tests/unit/. Integration tests get their own folder so you can run each layer independently:

mkdir hello-world-api/tests/integration
touch hello-world-api/tests/integration/__init__.py
touch hello-world-api/tests/integration/test_api_gateway_local.py
Enter fullscreen mode Exit fullscreen mode

Your folder structure should now look like this:

hello-world-api/
├── hello_world/
│   └── app.py
├── template.yaml
└── tests/
    ├── __init__.py
    ├── unit/
    │   ├── __init__.py
    │   └── test_app.py
    └── integration/
        ├── __init__.py
        └── test_api_gateway_local.py   ← we are building this now
Enter fullscreen mode Exit fullscreen mode

Step 2: Write the Local Integration Tests

Open tests/integration/test_api_gateway_local.py and paste in the following:

import requests

BASE_URL = "http://localhost:3000"


def test_hello_default_name():
    response = requests.get(f"{BASE_URL}/hello")
    assert response.status_code == 200
    body = response.json()
    assert body["message"] == "Hello, World!"


def test_hello_with_name_param():
    response = requests.get(f"{BASE_URL}/hello", params={"name": "Gloria"})
    assert response.status_code == 200
    assert response.json()["message"] == "Hello, Gloria!"


def test_hello_response_headers():
    response = requests.get(f"{BASE_URL}/hello")
    assert response.headers["Content-Type"] == "application/json"
    assert response.headers["Access-Control-Allow-Origin"] == "*"


def test_documentation_endpoint_returns_html():
    response = requests.get(f"{BASE_URL}/get-documentation")
    assert response.status_code == 200
    assert response.headers["Content-Type"] == "text/html"
    assert "Hello API" in response.text


def test_unknown_path_returns_404():
    response = requests.get(f"{BASE_URL}/does-not-exist")
    assert response.status_code == 404
    assert response.json()["error"] == "Endpoint not found"


def test_post_request_rejected_by_api_gateway():
    response = requests.post(f"{BASE_URL}/hello")
    # API Gateway itself rejects this — your Lambda never runs
    assert response.status_code == 403
Enter fullscreen mode Exit fullscreen mode

Understanding the Test Code

No mocking. Compare this to your unit tests — there is no mock_event() helper anywhere. There is nothing to mock. The request really travels over localhost:3000, through SAM's local API Gateway simulation, into a Docker container running your Lambda function, and back.

BASE_URL = "http://localhost:3000" is hardcoded. This is intentional for local tests because SAM Local runs the API on localhost:3000 by default, so using a constant keeps the test simple and easy to understand. When we move to deployed tests, the URL lives in AWS — not on your machine. Rather than hardcoding it, boto3 fetches it directly from CloudFormation, and the fixture delivers it to every test that needs it.

These tests will fail with a ConnectionRefusedError if sam local start-api is not running. That is a feature, not a bug — it forces you to actually start the local API before the tests run, rather than silently testing nothing.

  • The six tests and what each one proves:

    • test_hello_default_name — sends GET /hello with no parameters and checks that the response is 200 and the message is "Hello, World!". This confirms the default name fallback works end-to-end through API Gateway, not just inside Lambda.
    • test_hello_with_name_param — sends GET /hello?name=Gloria and checks that the message becomes "Hello, Gloria!". This confirms query parameter extraction works through the full request path, not just in the unit test mock.
    • test_hello_response_headers — checks that Content-Type: application/json and Access-Control-Allow-Origin: * actually arrive at the HTTP client. Your unit tests could assert these headers exist in Lambda's return value, but only an integration test confirms they survive the trip through API Gateway and arrive in the real HTTP response.
    • test_documentation_endpoint_returns_html — sends GET /get-documentation and checks that the response is 200, the Content-Type is text/html, and the word "Hello API" appears in the body. This confirms Lambda can serve HTML through API Gateway, not just JSON.
    • test_unknown_path_returns_404 — sends GET /does-not-exist and checks for 404 status code and "Endpoint not found" in the error body. This feels natural coming from the unit tests — Lambda returns 404 for unknown paths, so 404 is what you expect. Write it this way first. It will fail, and that failure is the lesson. The full story is in the next section.
    • test_post_request_rejected_by_api_gateway — sends POST /hello and checks for 403. Only GET is configured in template.yaml, so API Gateway rejects the POST before Lambda runs. A unit test calling lambda_handler() directly would never see this 403 because Lambda was never invoked.

Step 3: Run the Local Integration Tests

You need two terminals open at the same time.

Terminal 1 — start the local API:

sam local start-api
Enter fullscreen mode Exit fullscreen mode

Wait until you see:

Mounting HelloWorldFunction at http://127.0.0.1:3000/ [GET]
Mounting HelloWorldFunction at http://127.0.0.1:3000/get-documentation [GET]
Mounting HelloWorldFunction at http://127.0.0.1:3000/hello [GET]
You can now browse to the above endpoints to invoke your functions.
Enter fullscreen mode Exit fullscreen mode

Terminal 2 — run the tests:

python -m pytest tests/integration/test_api_gateway_local.py -v
Enter fullscreen mode Exit fullscreen mode

What You Should See — and One Surprise

tests/integration/test_api_gateway_local.py::test_hello_default_name PASSED [ 16%]
tests/integration/test_api_gateway_local.py::test_hello_with_name_param PASSED [ 33%]
tests/integration/test_api_gateway_local.py::test_hello_response_headers PASSED [ 50%]
tests/integration/test_api_gateway_local.py::test_documentation_endpoint_returns_html PASSED [ 66%]
tests/integration/test_api_gateway_local.py::test_unknown_path_returns_404 FAILED [ 83%]
tests/integration/test_api_gateway_local.py::test_post_request_rejected_by_api_gateway PASSED [100%]

1 failed, 5 passed in 11.73s
Enter fullscreen mode Exit fullscreen mode

One test failed. Here is the full error:

 ================================== FAILURES ===================================
________________________ test_unknown_path_returns_404 ________________________

    def test_unknown_path_returns_404():
        response = requests.get(f"{BASE_URL}/does-not-exist")
>       assert response.status_code == 404
E       assert 403 == 404
E        +  where 403 = <Response [403]>.status_code

tests\integration\test_api_gateway_local.py:34: AssertionError
=========================== short test summary info ===========================
FAILED tests/integration/test_api_gateway_local.py::test_unknown_path_returns_404

Enter fullscreen mode Exit fullscreen mode

Discovery #1: Why 403 and Not 404?

Your first instinct might be to think you broke something. You did not. This is API Gateway teaching you something a unit test never could.

Here is what actually happens when you request /does-not-exist:

  1. The request hits API Gateway first.
  2. API Gateway checks template.yaml for a matching route.
  3. No matching route exists.
  4. API Gateway rejects the request itself, with 403 Missing Authentication Token.
  5. Lambda never runs. Compare that to your unit test, which calls lambda_handler() directly — skipping API Gateway entirely. Lambda runs, hits return error_response(404, "Endpoint not found"), and returns a clean 404. That is correct Lambda behaviour. It is just not what a real client ever sees.

This gap — between what your code does and what a client actually receives — is exactly what integration tests are for. Unit tests cannot catch it, because they never involve API Gateway at all.

The fix — update the test to match the real behaviour:

def test_unknown_path_returns_403():
    response = requests.get(f"{BASE_URL}/does-not-exist")
    assert response.status_code == 403
    assert response.json() == {"message": "Missing Authentication Token"}
Enter fullscreen mode Exit fullscreen mode

Run again after the fix:

6 passed in 10.54s
Enter fullscreen mode Exit fullscreen mode

The unit test still correctly asserts 404 — that is Lambda's behaviour when called directly. This integration test correctly asserts 403 — that is API Gateway's behaviour when a real request goes through the full stack. Both are right. They are just testing different layers.

In this API, 403 Missing Authentication Token on an unknown path means API Gateway could not match the request to a configured resource or method. It does not mean Lambda rejected the request or that your Lambda execution role lacks permission.

Remember this: whenever you see 403 Missing Authentication Token from API Gateway, check the route and HTTP method before assuming you have an IAM problem.


Part 2: Deploy the API to AWS

Before we can run deployed integration tests, we need a deployed stack.

Step 1: Build and Deploy

sam build
sam deploy --guided
Enter fullscreen mode Exit fullscreen mode

Follow the prompts. When asked for the stack name, enter hello-world-api. Accept the defaults for everything else.

Setting default arguments for 'sam deploy'
Stack Name [sam-app]: hello-world-api
AWS Region [us-east-1]:
#Shows you resources changes to be deployed and require a 'Y' to initiate deploy
Confirm changes before deploy [y/N]: y
#SAM needs permission to be able to create roles to connect to the resources in your template
Allow SAM CLI IAM role creation [Y/n]:
#Preserves the state of previously provisioned resources when an operation fails
Disable rollback [y/N]:
HelloWorldFunction has no authentication. Is this okay? [y/N]: y
HelloWorldFunction has no authentication. Is this okay? [y/N]: y
HelloWorldFunction has no authentication. Is this okay? [y/N]: y
Save arguments to configuration file [Y/n]: y
SAM configuration file [samconfig.toml]:
SAM configuration environment [default]:
Enter fullscreen mode Exit fullscreen mode

At the end of a successful deployment, CloudFormation prints your stack outputs:

CloudFormation outputs from deployed stack
---------------------------------------------------------------------------
Key                 HelloApiUrl
Description         Hello endpoint
Value               https://azmimofsv1.execute-api.us-east-1.amazonaws.com/Prod/hello

Key                 RootUrl
Description         Root URL - serves documentation page
Value               https://azmimofsv1.execute-api.us-east-1.amazonaws.com/Prod/

Key                 DocumentationUrl
Description         Documentation endpoint
Value               https://azmimofsv1.execute-api.us-east-1.amazonaws.com/Prod/get-documentation
--------------------------------------------------------------------------
Enter fullscreen mode Exit fullscreen mode

Note: The string azmimofsv1 in the URL is your API Gateway ID. AWS generates it randomly — yours will be different. Replace it with your own ID in all the commands below.


Part 3: Manually Test the Deployed API Endpoints

Before running any automated tests, verify the deployed endpoints are alive and responding correctly. Do not trust automation to confirm something works until you have seen it work yourself, at least once.

Test Using the Browser

Test GET /hello — default greeting:

Paste your HelloApiUrl into the browser address bar. You should see:

{
  "message": "Hello, World!",
  "timestamp": "2026-06-20T22:48:21.123456+00:00",
  "method": "GET",
  "path": "/hello",
  "version": "1.0"
}
Enter fullscreen mode Exit fullscreen mode

Test GET /hello with a name parameter:

Add ?name=YourName to the URL:

https://azmimofsv1.execute-api.us-east-1.amazonaws.com/Prod/hello?name=Gloria
Enter fullscreen mode Exit fullscreen mode

You should see:

{
  "message": "Hello, Gloria!",
  "timestamp": "2026-09-01T11:58:58.392697+00:00",
  "method": "GET",
  "path": "/hello",
  "version": "1.0"
}
Enter fullscreen mode Exit fullscreen mode

Test GET /get-documentation:

Paste your DocumentationUrl into a new browser tab:

https://azmimofsv1.execute-api.us-east-1.amazonaws.com/Prod/get-documentation
Enter fullscreen mode Exit fullscreen mode

You should see the styled HTML documentation page served directly from AWS Lambda.

Test GET / — root endpoint:

Paste your RootUrl into a new tab:

https://azmimofsv1.execute-api.us-east-1.amazonaws.com/Prod/
Enter fullscreen mode Exit fullscreen mode

This should also render the documentation page.

Using curl

# Test /hello
curl https://azmimofsv1.execute-api.us-east-1.amazonaws.com/Prod/hello

# Test /hello with name
curl https://azmimofsv1.execute-api.us-east-1.amazonaws.com/Prod/hello?name=Gloria

# Test /get-documentation
curl https://azmimofsv1.execute-api.us-east-1.amazonaws.com/Prod/get-documentation

# Test root
curl https://azmimofsv1.execute-api.us-east-1.amazonaws.com/Prod/
Enter fullscreen mode Exit fullscreen mode

Test non-GET methods — should be blocked by API Gateway:

# POST — should return 403
curl -X POST https://azmimofsv1.execute-api.us-east-1.amazonaws.com/Prod/hello

# PUT — should return 403
curl -X PUT https://azmimofsv1.execute-api.us-east-1.amazonaws.com/Prod/hello
Enter fullscreen mode Exit fullscreen mode

Test an unknown path:

curl https://azmimofsv1.execute-api.us-east-1.amazonaws.com/Prod/does-not-exist
Enter fullscreen mode Exit fullscreen mode

You should see {"message": "Missing Authentication Token"} with a 403 status code. Your Lambda never ran. This is the same 403 behaviour you discovered during local integration testing — now confirmed against the real deployed stack.

All four GET endpoints responding correctly. Good. Now we automate all of this.


Part 4: Automated Integration Tests for the Deployed Hello World API

Step 1: Create the Deployed Integration Test File

touch hello-world-api/tests/integration/test_api_gateway_deployed.py
Enter fullscreen mode Exit fullscreen mode

Your folder structure should now look like this:

hello-world-api/
├── hello_world/
│   └── app.py
├── template.yaml
└── tests/
    ├── __init__.py
    ├── unit/
    │   ├── __init__.py
    │   └── test_app.py
    └── integration/
        ├── __init__.py
        ├── test_api_gateway_local.py
        └── test_api_gateway_deployed.py  ← we are building this now
Enter fullscreen mode Exit fullscreen mode

Step 2: Write the Deployed Integration Tests

Open tests/integration/test_api_gateway_deployed.py and paste in the following code:

import os
import boto3
import pytest
import requests

"""
Make sure env variable AWS_SAM_STACK_NAME exists with the name of the stack we are going to test.
Run this before pytest:
    export AWS_SAM_STACK_NAME=hello-world-api
"""


# ── fixtures ──────────────────────────────────────────────────────────────────

@pytest.fixture()
def stack_urls():
    """Fetch all API URLs from the deployed CloudFormation stack outputs."""
    stack_name = os.environ.get("AWS_SAM_STACK_NAME")

    if stack_name is None:
        raise ValueError(
            "Please set the AWS_SAM_STACK_NAME environment variable.\n"
            "Run: export AWS_SAM_STACK_NAME=hello-world-api"
        )

    client = boto3.client("cloudformation")

    try:
        response = client.describe_stacks(StackName=stack_name)
    except Exception as e:
        raise Exception(
            f"Cannot find stack '{stack_name}'.\n"
            f"Make sure you have run 'sam deploy' before running these tests."
        ) from e

    outputs = response["Stacks"][0]["Outputs"]

    # Build a dictionary of all stack outputs keyed by OutputKey
    url_map = {item["OutputKey"]: item["OutputValue"] for item in outputs}

    # Confirm the keys we need are actually present
    required_keys = ["HelloApiUrl", "DocumentationUrl", "RootUrl"]
    for key in required_keys:
        if key not in url_map:
            raise KeyError(f"Expected output key '{key}' not found in stack '{stack_name}'")

    return url_map



# ── tests ─────────────────────────────────────────────────────────────────────

def test_hello_default_name(stack_urls):
    response = requests.get(stack_urls["HelloApiUrl"])
    assert response.status_code == 200
    body = response.json()
    assert body["message"] == "Hello, World!"
    assert body["version"] == "1.0"


def test_hello_with_name_param(stack_urls):
    response = requests.get(stack_urls["HelloApiUrl"], params={"name": "Gloria"})
    assert response.status_code == 200
    assert response.json()["message"] == "Hello, Gloria!"


def test_hello_response_headers(stack_urls):
    response = requests.get(stack_urls["HelloApiUrl"])
    assert "application/json" in response.headers["Content-Type"]
    assert response.headers["Access-Control-Allow-Origin"] == "*"


def test_documentation_endpoint_returns_html(stack_urls):
    response = requests.get(stack_urls["DocumentationUrl"])
    assert response.status_code == 200
    assert "text/html" in response.headers["Content-Type"]
    assert "Hello API" in response.text


def test_root_endpoint_returns_html(stack_urls):
    response = requests.get(stack_urls["RootUrl"])
    assert response.status_code == 200
    assert "text/html" in response.headers["Content-Type"]


def test_unknown_path_returns_403(stack_urls):
    # Extract base URL from HelloApiUrl
    # HelloApiUrl = https://xxx.execute-api.us-east-1.amazonaws.com/Prod/hello
    # Base URL    = https://xxx.execute-api.us-east-1.amazonaws.com/Prod
    base_url = stack_urls["HelloApiUrl"].rsplit("/hello", 1)[0]
    response = requests.get(f"{base_url}/does-not-exist")
    # API Gateway returns 403 for unknown paths — Lambda is never invoked
    assert response.status_code == 403


def test_post_request_rejected(stack_urls):
    response = requests.post(stack_urls["HelloApiUrl"])
    # API Gateway rejects POST — Lambda is never invoked
    assert response.status_code == 403
Enter fullscreen mode Exit fullscreen mode

Understanding the Test Code

The file has two parts: one fixture that uses boto3 to fetch the URLs from CloudFormation and delivers them to the tests, and seven tests that use them.

Imports and the environment variable

import os
import boto3
import pytest
import requests
Enter fullscreen mode Exit fullscreen mode
  • os: reads the shell environment variable.
  • boto3: talks to AWS.
  • requests: makes the HTTP calls.
  • pytest: provides the fixture system.

  • Fixtures
    The first thing the fixture does is read your stack name from the shell:

stack_name = os.environ.get("AWS_SAM_STACK_NAME")

if stack_name is None:
    raise ValueError(
        "Please set the AWS_SAM_STACK_NAME environment variable.
"
        "Run: export AWS_SAM_STACK_NAME=hello-world-api"
    )
Enter fullscreen mode Exit fullscreen mode

The stack name is not hardcoded in the file. It lives in a shell environment variable you set before running the tests. This keeps it out of your code so the same test file works for any stack name, any environment. The if stack_name is None check gives you a clear, actionable error if you forget to set it, instead of a confusing boto3 exception later.

  • The @pytest.fixture() decorator
@pytest.fixture()
def stack_urls():
Enter fullscreen mode Exit fullscreen mode

This decorator transforms stack_urls from a plain function into a pytest fixture — a piece of setup code that pytest runs automatically and injects into any test that lists stack_urls as a parameter. Pytest sees the parameter name in a test, finds the matching fixture, runs it, and passes the return value in.

The reason this is a fixture rather than a constant like BASE_URL = "http://localhost:3000" is that we do not want to hardcode environment-specific AWS URLs into our tests. The API Gateway URL is tied to the deployed API and stage, and we can retrieve the current value directly from CloudFormation. This makes the tests portable across environments and avoids manually updating URLs if the stack configuration changes.

  • boto3 fetches the URL from CloudFormation
client = boto3.client("cloudformation")

try:
    response = client.describe_stacks(StackName=stack_name)
except Exception as e:
    raise Exception(
        f"Cannot find stack '{stack_name}'.
"
        f"Make sure you have run 'sam deploy' before running these tests."
    ) from e
Enter fullscreen mode Exit fullscreen mode

When sam deploy completes, CloudFormation stores your stack output values — including all three API URLs you defined in template.yaml. boto3 reads those outputs here with describe_stacks.

  • The try/except block gives you a clear error message if the stack does not exist yet — which happens if you run the tests before deploying. Without it, you would get a raw boto3 exception that does not tell you what to do next.

  • Building the dictionary

outputs = response["Stacks"][0]["Outputs"]

url_map = {item["OutputKey"]: item["OutputValue"] for item in outputs}

required_keys = ["HelloApiUrl", "DocumentationUrl", "RootUrl"]
for key in required_keys:
    if key not in url_map:
        raise KeyError(f"Expected output key '{key}' not found in stack '{stack_name}'")

return url_map
Enter fullscreen mode Exit fullscreen mode
  • outputs is a list of dictionaries from CloudFormation — one per output key. The list comprehension converts it into a single flat dictionary:
{
    "HelloApiUrl": "https://azmimofsv1.execute-api.us-east-1.amazonaws.com/Prod/hello",
    "DocumentationUrl": "https://azmimofsv1.execute-api.us-east-1.amazonaws.com/Prod/get-documentation",
    "RootUrl": "https://azmimofsv1.execute-api.us-east-1.amazonaws.com/Prod/",
    "HelloWorldFunctionArn": "arn:aws:lambda:...",
    "HelloWorldFunctionIamRole": "arn:aws:iam:..."
}
Enter fullscreen mode Exit fullscreen mode

The fixture returns this whole dictionary rather than a single URL. The required_keys check fails fast with a clear message if your stack is missing an expected output key.

  • The seven tests
    Every test receives stack_urls as a parameter and uses it to get the URL it needs.

    • test_hello_default_name — sends GET /hello with no parameters and checks that the status is 200, the message is "Hello, World!", and the version is "1.0". This confirms the full deployed stack — API Gateway, Lambda, routing, and response formatting — is wired correctly end to end.
    • test_hello_with_name_param — sends GET /hello?name=Gloria and checks that the message becomes "Hello, Gloria!". This confirms query parameter extraction works through the real deployed API Gateway, not just locally.
    • test_hello_response_headers — checks that Content-Type contains application/json and Access-Control-Allow-Origin is *. Notice the assertion uses in instead of == for Content-Type. The deployed API Gateway adds charset=utf-8 — returning application/json; charset=utf-8 — that sam local does not. Using in means this test passes in both environments without any changes.
    • test_documentation_endpoint_returns_html — sends GET /get-documentation and checks for 200, text/html content type, and "Hello API" in the body. This confirms the HTML documentation endpoint works in the real deployed stack, served from Lambda through API Gateway.
    • test_root_endpoint_returns_html — sends GET / and checks for 200 and text/html. This confirms the root endpoint is correctly routed to the same documentation handler as /get-documentation.
    • test_unknown_path_returns_403 — there is no BaseUrl output key in your CloudFormation stack, so the test derives the base URL by stripping /hello from the end of HelloApiUrl:
base_url = stack_urls["HelloApiUrl"].rsplit("/hello", 1)[0]
response = requests.get(f"{base_url}/does-not-exist")
assert response.status_code == 403
Enter fullscreen mode Exit fullscreen mode

This is a small but deliberate choice — rather than adding another output to template.yaml, you compute what you need from what you already have.

  • test_post_request_rejected — sends POST /hello and checks for 403. Confirms that the deployed API Gateway rejects non-GET methods the same way the local simulation does. Lambda is never invoked.

Step 3: Set the Environment Variable

The test reads your stack name from a shell environment variable — not from a file inside your project. This keeps your stack name out of your code, meaning the same test file works for any environment by changing one variable.

export AWS_SAM_STACK_NAME=hello-world-api
Enter fullscreen mode Exit fullscreen mode

Note: This variable lives in your terminal session only. It disappears when you close the terminal. Set it again each time you open a new session before running deployed tests.

Step 4: Run the Deployed Integration Tests

python -m pytest tests/integration/test_api_gateway_deployed.py -v
Enter fullscreen mode Exit fullscreen mode

What You Should See

tests/integration/test_api_gateway_deployed.py::test_hello_default_name PASSED
tests/integration/test_api_gateway_deployed.py::test_hello_with_name_param PASSED
tests/integration/test_api_gateway_deployed.py::test_hello_response_headers PASSED
tests/integration/test_api_gateway_deployed.py::test_documentation_endpoint_returns_html PASSED
tests/integration/test_api_gateway_deployed.py::test_root_endpoint_returns_html PASSED
tests/integration/test_api_gateway_deployed.py::test_unknown_path_returns_403 PASSED
tests/integration/test_api_gateway_deployed.py::test_post_request_rejected PASSED

7 passed in 21.25s
Enter fullscreen mode Exit fullscreen mode

7 passed means your deployed stack is healthy.

Why 21 Seconds?

Your local integration tests ran in 11 seconds. Your deployed tests took 21 seconds. The difference comes from testing real infrastructure across the network, plus the additional processing involved in API Gateway and Lambda. That extra time is expected.


Discovery #2 — Empty Query Parameter Bug

Seven tests passed. I was almost ready to publish. Then I tested one more edge case.

I sent this request to the deployed API:

/hello?name=
Enter fullscreen mode Exit fullscreen mode

The response came back as:

{"message": "Hello, !", "timestamp": "2026-09-15T22:17:33.974145+00:00", "method": "GET", "path": "/hello", "version": "1.0"}
Enter fullscreen mode Exit fullscreen mode

"Hello, !" — the name is empty. The intended behaviour was "Hello, World!".

Could a unit test have caught this? Yes. If we had written a unit test for the specific case of an empty string, it could have caught the bug. The problem was not that unit testing was incapable of finding it. We simply had not thought to test this edge case. The deployed integration test gave me another opportunity to discover it because I was testing the API with a real HTTP request and real query-string input — like what happens when a user sends ?name= with nothing after the equals sign.

This is a real application bug, not an infrastructure problem. And I only found it because I was testing edge cases before publishing.

Why It Happened

The original Lambda code uses:

name = query_params.get("name", "World")
Enter fullscreen mode Exit fullscreen mode

This only falls back to "World" when the name key is completely absent from the query parameters. It handles:

  • /hello — no name key → falls back to "World"

But it does not handle:

  • /hello?name=name key exists, value is an empty string → returns ""

The key is present in the dictionary, so .get() returns the empty string instead of the default.

The Fix — Adding a Regression Test

Step 1: First, write a test that exposes the bug:

def test_hello_with_empty_name(stack_urls):
    response = requests.get(
        stack_urls["HelloApiUrl"],
        params={"name": ""}
    )
    assert response.status_code == 200
    assert response.json()["message"] == "Hello, World!"
Enter fullscreen mode Exit fullscreen mode

Step 2: Run it — it fails. That is the bug confirmed.

Step 3: Update the Lambda handler. Change:

name = query_params.get("name", "World")
Enter fullscreen mode Exit fullscreen mode

To:

name = query_params.get("name") or "World"
Enter fullscreen mode Exit fullscreen mode

The or operator returns "World" when name is either absent (None) or an empty string (""). Both cases now fall back to the default correctly.

Step 4: Add the regression test above to your deployed integration test file.

Step 5: Redeploy with sam build and sam deploy.

Step 6: Run the full test suite again and confirm all tests pass.

Once you apply the fix, keep this test in your suite permanently. It is now a regression test — its job is to make sure this specific bug never comes back. If someone later changes the Lambda handler and accidentally reintroduces the old query_params.get("name", "World") behaviour, this test fails immediately and catches it before it ships. Every bug you fix is an opportunity to write a regression test. Over time, your test suite becomes a record of every mistake you caught and fixed.

That is the real workflow in practice:

Discover
   ↓
Write a failing test
   ↓
Fix the code
   ↓
Deploy
   ↓
Run the tests
   ↓
Confirm everything passes
Enter fullscreen mode Exit fullscreen mode

What this discovery taught me: integration testing does not just verify infrastructure. It can also expose application behaviour bugs when we exercise the system with realistic inputs. This bug could have been caught by a unit test, but my existing unit tests did not include the empty-string case. Testing the deployed API with realistic inputs exposed application behaviour bugs that I did not think to cover in my original automated tests.

That is one of the reasons I now see testing as more than simply checking whether the code works. Different tests give you different opportunities to discover problems:

  • Unit tests verify individual pieces of application logic.
  • Local integration tests verify how those pieces work together through the local HTTP interface.
  • Deployed integration tests exercise the application through the real API Gateway, Lambda, and AWS infrastructure.
  • Manual verification gives you another opportunity to try realistic requests and edge cases before publishing. > The goal is not to choose one type of test. It is to build enough layers of testing that a bug has fewer places to hide.

Running All Three Layers Together

You now have all three test layers. Here is how to run each one:

# Unit tests — fastest, no Docker, no AWS needed
python -m pytest tests/unit/ -v

# Local integration — Docker needed, sam local start-api must be running
python -m pytest tests/integration/test_api_gateway_local.py -v

# Deployed integration — AWS deployment needed, set env variable first
export AWS_SAM_STACK_NAME=hello-world-api
python -m pytest tests/integration/test_api_gateway_deployed.py -v
Enter fullscreen mode Exit fullscreen mode

Your complete results across all layers:

tests/unit/test_app.py                                  15 passed    0.16s
tests/integration/test_api_gateway_local.py              6 passed   11.53s
tests/integration/test_api_gateway_deployed.py           7 passed   21.25s

28 passed
Enter fullscreen mode Exit fullscreen mode

28 tests across three layers!

If you followed Discovery #2 and applied the fix, your test counts will differ from the ones shown above. To properly cover the fix, you would add an empty-name test to your deployed integration test file, a matching test to your local integration test file, and a unit test for the fixed behaviour. This bring the totals to 16 unit tests, 7 local integration tests, and 8 deployed integration tests, for a total of 31 passing tests. That is a good thing. Every bug you find and add a test for makes your test suite stronger. Real test suites grow as you discover and fix real bugs. That is exactly what happened here.


Challenges

  1. The 403 vs 404 mismatch This one was subtler. My unit tests returned 404 for unknown paths, my integration tests returned 403 for unknown path, and both were "correct" — just testing different layers. The real lesson: API Gateway rejects unmatched routes before Lambda ever runs, so a unit test that calls lambda_handler() directly can never see that behavior. If you hit this, don't assume your code is broken — check whether the layer you're testing even reaches API Gateway or not.

  2. The empty-string edge case Seven tests passed, and I was ready to publish but, then I tried /hello?name= just to be sure, and got back "Hello, !" instead of "Hello, World!". The bug was one line: query_params.get("name", "World") only falls back to the default when name is missing entirely, not when it's present but empty. A unit test could have caught it. I just hadn't thought to write one for that case. Good reminder that passing tests only prove what you actually tested for.

  3. Understanding @pytest.fixture() took time. The decorator pattern — where a function is registered by name and its return value is injected into tests automatically — is not intuitive if you are used to calling functions directly. The key insight: by the time the fixture value arrives in your test, it is no longer a function. It is just a value — whatever the fixture returned.

  4. The python -m pip install vs pip install distinction caused a real failure. The error was ModuleNotFoundError: No module named 'requests' even after installing it. The Windows Store Python creates a separate environment that pip alone does not always target. Using python -m pip fixes it.

  5. The Content-Type header difference between sam local and the real API Gateway surprised me. Local returns application/json. Deployed returns application/json; charset=utf-8. Using in instead of == in the assertion handles both environments without duplicating tests.


What I Learned

  1. Unit tests prove your logic. Integration tests prove your wiring. A unit test that passes and an integration test that fails means your code is correct but your infrastructure is broken. Both layers are necessary.

  2. 403 Missing Authentication Token usually means a missing route, not a permissions problem. API Gateway returns this for any path not configured in template.yaml. Your Lambda's 404 only appears if Lambda actually runs — for unknown paths, it never does.

  3. Passing tests only prove what you thought to test. 28 green tests felt like proof the API was solid. The empty-string bug was sitting there the whole time, untouched by any of those 28 checks.

  4. Different test layers give you different chances to catch the same bug. The empty-string edge case could have been caught by a unit test, but it was not. It took a real HTTP request with a realistic, slightly unusual input to surface it. Testing the deployed API is not just about checking infrastructure — it is another opportunity to probe behaviour with inputs a real user might actually send.

  5. Never trust automation before you have verified manually. Running curl against your deployed URL before writing integration tests is not optional. It is how you confirm the deployment worked before asking a test suite to confirm it for you.


Conclusion

You've now tested this API three different ways: unit tests against the raw Lambda handler, integration tests against the local API Gateway emulator, and integration tests against the real, deployed stack — 28 passing tests total, proving the API actually works, not just that it runs.

More importantly, you now know why each layer exists. The 403 vs 404 discovery showed that API Gateway rejects unknown paths before Lambda ever runs. Your unit tests could never catch that because they call Lambda directly and bypass API Gateway entirely. Only a real integration test — one that sends an actual HTTP request through the full stack — could expose it.

Then there's the empty-string bug: /hello?name= returned "Hello, !" instead of "Hello, World!", even with seven deployed tests all green. Nothing infrastructural was wrong. I just hadn't thought to test that specific case. It's now a permanent regression test, and a reminder that "all green" is a checkpoint, not a finish line

The biggest takeaway isn't the tests themselves. Passing the planned tests does not necessarily mean the application is ready. Continuing to test with different and unexpected inputs can reveal behavior that the original test cases did not cover.


Call to Action — Your Turn

You have 28 passing tests. Now break something on purpose and fix it.

Add a new endpoint to the API:

  1. Add a GET /goodbye endpoint to app.py that returns {"message": "Goodbye, {name}!"}
  2. Add the route to template.yaml under Events
  3. Test it manually with sam local start-api and curl
  4. Add a unit test for it in test_app.py
  5. Add an integration test for it in test_api_gateway_local.py
  6. Deploy with sam deploy
  7. Test the deployed endpoint with curl
  8. Add a deployed integration test for it in test_api_gateway_deployed.py
  9. Run all 3 test layers and confirm everything passes

If you try it, drop a comment with your result — I would love to see what you build.

Connect with me on:

Top comments (0)