I gave a presentation titled "Testable Design in Python Lambda and Utilization of AI Assistance" at DevelopersIO 2026

I gave a presentation titled "Testable Design in Python Lambda and Utilization of AI Assistance" at DevelopersIO 2026

DevelopersIO 2026 session article. Covers thin Python Lambda handlers, extracting business rules as pure functions, 3 test boundaries, and AI-generated tests showing humans judge specs more easily.
2026.10.26

This page has been translated by machine translation. View original

Introduction

Hello, I'm Kitano from the Cloud Business Division.

At "DevelopersIO 2026 Tokyo" held on October 24, 2026, I gave a presentation titled "Testable Design in Python Lambda and the Use of AI Assistance."

This article introduces the content of that presentation with code and diagrams.
I've also written in detail about topics I didn't have time to cover during the talk.

Slides

Conclusion First

In software design, maintain a state where humans can judge the specifications (programs and tests)

AI will write programs and tests, but it won't decide whether those specifications are correct. That's why we keep code organized in a form where humans can read the tests and judge the specifications. This was the most important message I wanted to convey in the presentation.

Source Blog

The code in this article is an excerpt highlighting key points, with imports and helper function definitions omitted.
For those who want more detail or information on basic usage of pytest and moto, please refer to this blog and sample code.

https://dev.classmethod.jp/articles/introduction-python-lambda-testing/

The handler and domain in the presentation are called controller and service respectively in that article. In the presentation, I use "handler" to match Lambda's terminology, and "domain" so the name alone makes it clear it's the business rules layer.

1. Are You Writing Tests Like This?

Subject: A Lambda That Accepts and Saves Orders

Architecture diagram showing a Lambda receiving an order event and saving the order to DynamoDB

  • Input: An event with an order ID and line items (unit price × quantity)
  • Business rule: A 10% discount if the total is 10,000 yen or more. Fractions are truncated
  • Output: Save the order to DynamoDB and return the total amount

I've limited the business rules to just one discount, treating it as the representative "specification." Including validation and external APIs would obscure the discussion of where the specification lives.

A Certain Lambda Handler

import json
import os
import boto3

dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(os.environ["TABLE_NAME"])

def handler(event, context):
    items = event.get("items", [])
    subtotal = sum(i["price"] * i["quantity"] for i in items)
    total = subtotal * 9 // 10 if subtotal >= 10000 else subtotal
    table.put_item(Item={
        "order_id": event["order_id"],
        "items": items,
        "total": total,
    })
    return {"statusCode": 200, "body": json.dumps({"data": {"total": total}})}

It's short and functional code, but business rules (total and discount), AWS calls (put_item), and response assembly are all in one function. The boto3 resource is initialized at the top of the module. What happens when we try to test this Lambda?

A Certain Test

@patch.dict("os.environ", {"TABLE_NAME": "orders"})
@patch("boto3.resource")
def test_handler(mock_resource):
    mock_table = MagicMock()
    mock_resource.return_value.Table.return_value = mock_table
    import handler as mod
    importlib.reload(mod)

    event = {"order_id": "o-1", "items": [{"price": 6000, "quantity": 2}]}
    result = mod.handler(event, {})
    body = json.loads(result["body"])

    assert body["data"]["total"] == 10800
    mock_table.put_item.assert_called_once_with(Item={
        "order_id": "o-1", "items": event["items"], "total": 10800,
    })

Seven lines of setup, two lines of what we actually want to verify. We patch environment variables and boto3.resource, and since the module is initialized at the top, we even have to use importlib.reload. Can you tell at a glance what this test is trying to verify?

What's Wrong with This Test?

  • Business specifications are buried in mock setup
  • SDK calls and business logic are mixed into a single assert
  • Changing put_item to batch_writer alone breaks all the tests
  • Humans need to verify whether the mocks are correct

Diagram showing the test tightly coupled to boto3 call signatures through the handler

The biggest issue is the first one. The test only has a case for 12,000 yen, so we can't tell whether exactly 10,000 yen qualifies for a discount.

The remaining three issues stem from the test looking at "how put_item was called" rather than "what was saved." Business logic and calling conventions are mixed into a single assert, and it breaks just by switching to batch_writer. Since MagicMock succeeds with any arguments, we also can't tell whether the values are acceptable to DynamoDB.

Before even bringing in AI, humans are already in a state where they can't read the specifications. The cause lies in the design, not in how the tests are written.

2. Make the Handler Thin and Separate Dependencies

The reason the tests in Chapter 1 were hard to read was that business rules and AWS calls were in the same function. Even though we just wanted to verify the discount calculation, put_item inside the same function would run together, so the test had no choice but to start with a DynamoDB mock.

So we place business rules and AWS calls in separate locations. The goal is to consolidate the specifications that humans need to read into one place. If there's a function that only contains discount rules, reading that function's tests alone reveals the specification, making it possible for humans to judge whether "this specification is really correct."

The handler becomes a thin function that only handles entry and exit processing, with its contents separated by responsibility.

Dividing into 4 Layers

Diagram of 4 layers: handler, usecase, domain, repository, and an assembler role

  • handler: Receives events and calls usecases. Makes no business decisions
  • usecase: Assembles the business flow
  • domain: The business rules themselves. Pure functions that don't know about AWS, serving as the home of specifications
  • repository: AWS calls. Doesn't know business rules

The layer names don't follow any specific school of thought like Clean Architecture or DDD; they were chosen to convey responsibility. The only point is to "place business rules and AWS calls in separate locations." AWS's Lambda testing guide also recommends the same approach.

Simplify your tests and Lambda functions by separating Lambda-specific code from your core business logic.

The Handler Only Handles Entry and Exit

import json

from app.dependencies import get_order_repository
from app.models.order import OrderItem
from app.usecases.order_usecase import create_order

def handler(event, context):
    order_id = event["order_id"]
    items = [OrderItem(**item) for item in event.get("items", [])]
    order = create_order(items, order_id, get_order_repository())
    return {"statusCode": 200, "body": json.dumps({"data": {"total": order.total}})}

All the handler knows about is the shape of Lambda events and responses. There's no discount determination or put_item.

The Usecase Is the Flow

# app/usecases/order_usecase.py
def create_order(items, order_id, repository: OrderRepository) -> Order:
    total = calculate_total(items)
    order = Order(order_id=order_id, items=items, total=total)
    repository.save(order)
    return order

The key point is that create_order receives the repository as an argument.

The original handler created a boto3 resource itself, so testing required patching boto3.resource and reloading. By receiving it from outside, we simply pass the real thing in production and a Mock in tests. This is dependency injection (DI).

The Domain Contains Rules

# app/domain/order_domain.py
def calculate_total(items: list[OrderItem]) -> int:
    """10% discount for 10,000 yen or more"""
    subtotal = sum(item.price * item.quantity for item in items)
    if subtotal >= 10000:
        return subtotal * 9 // 10
    return subtotal

The rule "10% discount for 10,000 yen or more, fractions truncated" is written only in domain's calculate_total.

The Repository Calls AWS

class OrderRepository:
    def __init__(self, dynamodb, table_name: str):
        self._table = dynamodb.Table(table_name)

    def save(self, order: Order) -> None:
        try:
            self._table.put_item(Item=to_item(order))
        except ClientError as e:
            raise OrderSaveError(order.order_id) from e

@cache
def get_order_repository() -> OrderRepository:
    dynamodb = boto3.resource("dynamodb")
    return OrderRepository(dynamodb, os.environ["TABLE_NAME"])

boto3 is only touched inside this class. to_item is a function that converts an order into DynamoDB item format. Since the dynamodb resource is received as an argument, Chapter 3 only needs to pass a moto resource or MagicMock to test it. boto3's ClientError is converted to OrderSaveError so layers above usecase don't need to know about boto3 exceptions.

get_order_repository is the assembler, creating a repository from the real boto3 resource and passing it to the handler. Even if you want to change put_item to batch_writer, the change is contained within this class.

What Changes When You Separate Them?

With the fat handler, one change rippled through all the tests. After separating, when discount rules change only domain tests are affected, and when DynamoDB call methods change only repository tests are affected. Since each layer has a defined responsibility, the placement of tests is also defined.

3. Using Three Test Boundaries

What to Protect Where

Diagram overlaying 3 test boundaries on the 4-layer diagram

  • Business logic (pytest unit): Are business specifications correct? Humans make the judgment
  • AWS boundary (moto): Can the repository write as expected?
  • Testing in the cloud (real AWS): Are IAM, environment variables, etc. connected properly?

All three are essential, and there are moments in each where humans must read and judge. However, what's being judged differs. In business logic tests, you judge "whether the specification is acceptable." In AWS boundary tests, you verify whether the storage format is as intended. In cloud tests, you confirm environment settings are correct. The difference is that the place where you read and question business specifications concentrates in the business logic tests.

Business Logic Tests (pytest unit)

def test_no_discount():
    assert calculate_total([OrderItem(price=3000, quantity=2)]) == 6000

def test_exact_threshold():
    assert calculate_total([OrderItem(price=10000, quantity=1)]) == 9000

def test_truncation():
    """Fractions after discount are truncated"""
    assert calculate_total([OrderItem(price=10001, quantity=1)]) == 9000

Without mocks, you can add boundary values one line at a time. Execution finishes in milliseconds. Just reading the tests tells you the specifications "exactly 10,000 yen qualifies for a discount" and "fractions are truncated," so humans can ask "is that really right?" The tests become the specification document.

AWS Boundary Tests (moto)

@pytest.fixture
def dynamodb_table():
    with mock_aws():
        dynamodb = boto3.resource("dynamodb", region_name="ap-northeast-1")
        create_orders_table(dynamodb)
        yield dynamodb

def test_save(dynamodb_table):
    order = Order(order_id="o-1", items=[OrderItem(price=10000, quantity=1)], total=9000)
    repo = OrderRepository(dynamodb_table, "orders")
    repo.save(order)

    table = dynamodb_table.Table("orders")
    saved = table.get_item(Key={"order_id": "o-1"})["Item"]
    assert saved["total"] == 9000

moto is a library that receives AWS API calls from boto3 with an in-memory mock implementation. The fixture's create_orders_table is a helper function that creates the orders table on moto.

This test verifies the result read back with get_item, not how put_item was called. Unlike the test in Chapter 1, it passes if the same value is saved even after switching to batch_writer. However, IAM permission checks and real operational failures are not reproduced.

Failure Cases That Don't Naturally Occur with moto—Use MagicMock

mock_dynamodb = MagicMock()
mock_table = mock_dynamodb.Table.return_value
mock_table.put_item.side_effect = ClientError(
    {"Error": {"Code": "ProvisionedThroughputExceededException"}}, "PutItem"
)
repo = OrderRepository(mock_dynamodb, "orders")
order = Order(order_id="o-1", items=[OrderItem(price=10000, quantity=1)], total=9000)

with pytest.raises(OrderSaveError):
    repo.save(order)

Real operational failures like throughput exceeded don't naturally occur on moto. So we use side_effect to raise a ClientError and verify that the repository converts it to OrderSaveError.

We're using MagicMock here too, but the injection point differs from the test in Chapter 1. In Chapter 1, we patched boto3.resource and went through the entire handler. Here, we just pass it to the repository constructor, and the test is self-contained within the repository.

Testing in the Cloud (Real AWS)

This is the type of test called "Testing in the cloud" in AWS's testing guide. It finds failures that only become apparent after deployment.

IAM, environment variables, and the contents of the deployment package are not checked in local tests. So all three of these would pass with moto.

The test simply invokes the deployed Lambda and reads back from DynamoDB with get_item. It's sufficient to verify just a few happy-path read/write cases. Boundary values for business logic are not repeated here. Use pytest markers to separate these from regular tests and run them only after deployment.

Test Pyramid

Test pyramid of business logic, AWS boundary, and cloud tests

The number of tests is greatest for business logic tests and fewest for cloud tests. What matters in the test pyramid isn't the names of the layers but the following thinking:

Write lots of small and fast unit tests. Write some more coarse-grained tests and very few high-level tests that test your application from end to end.

The fat handler test in Chapter 1 was a state where the bottom two layers were combined into one. Since handler and usecase don't make business decisions, their tests can be thin.

4. AI Writes Tests. Humans Retain Judgment Over Specifications

Up to this point, I've been discussing under the assumption that humans write tests. However, lately there are increasing opportunities to ask AI to "enumerate test cases" or "draft test code."
So, does the design we've built so far have value even when having AI write tests? Or would AI write readable tests even for a fat handler? I ran an experiment to find out.

Experiment: Submitting the Same Request to Two Designs

I prepared two pieces of code:

  • Fat handler: Same as Chapter 1—a Lambda with totaling, discounts, and saving all in one function
  • 4-layer version: A Lambda separated into handler, usecase, domain, and repository as in Chapter 2

I submitted the same request to each using Claude Code (version 2.1.296, model claude-opus-5-5) three times each. The request had two stages:

  1. Enumerate necessary test cases for the code under src
  2. Write test code for the enumerated test cases and run with pytest

However, since the experiment was only 3 runs with a single model, please treat this as indicative trends only.

Words like moto, patch, and layer were not included in the request. The AI reads the code itself and decides where to write what tests. Here is a summary of the results by perspective:

Perspective Fat Handler 4-Layer Version
Boundary values Came up all 3 times Same. No difference
Discount tests Via handler. With DB setup Directly calls domain
Review Fidelity of fakes Whether it fits the layer's responsibility

The first row of the table shows the content of tests written by AI. Boundary values such as whether exactly 10,000 yen gets a discount or what happens with fractions came up all 3 times in both designs, and all written tests passed. Low-value tests appeared in both as well. Tests that brute-force float errors in large loops, and tests that just verify dataclass defaults. Both the good and bad points were roughly equal in quantity. The test content didn't differ by design.

The difference appeared in the second and third rows of the table—the effort for humans to review those tests. With the fat handler, before judging whether the expected value for a single discount case matches the specification, you need to verify whether the DynamoDB setup and reimport fixtures are correct. That's because specification judgment and mock verification are mixed into one test. In the 4-layer version, reading one line of a domain test lets you judge only the specification. I'll look at this in detail in the next section.

What Happens When You Leave Tests to AI

With a fat handler, business rule tests also go through the handler.

What was interesting was that AI created its own fixture to swap out the table with a fake. AI tried to separate responsibilities within the tests that weren't separated in the source code. However, whether that fake is correct can only be verified by humans.

With the 4-layer version, boundary value tests were placed one line at a time in domain's pure functions, and even with over 10 domain tests, they finished in 0.02 seconds. Usecase tests simply pass a Mock repository. The AI agent places tests in locations that match the design.

On the other hand, tests that fixed unspecified behavior into expected values appeared in both designs in roughly equal numbers. Separating the design doesn't change this. Whether those expected values are acceptable is a part that only humans can judge.

Review Perspectives by Layer

Test What to Focus on in Review
domain (pytest unit) Whether expected values match business specifications
usecase (Mock) Order of processing and handling of failures
repository (moto) Whether saved values can be read back
handler Shape of input and output

The four in the table fall into two types:

  • domain and handler: Humans judge whether values or responses are correct as specifications
  • usecase and repository: Verify whether behavior matches defined specifications. Check whether processing order and storage format are as intended, but there are few occasions to question the specifications themselves

Humans spend time carefully reading and questioning specifications primarily in domain and handler tests. With a fat handler, all of these perspectives are mixed into one test, so there's no clear place to focus your skepticism.

The experiment also showed a difference here. I divided the tests written by AI into those where you can judge whether the expected value is appropriate just by reading the test body, and those where you need to first verify the contents of fixtures (how the table is created or the fake's behavior) before judging. The former numbered 0–1 per trial run for the fat handler, and 19–23 for the 4-layer version. That's because domain and usecase tests in the 4-layer version are self-contained with just arguments and expected values.

Don't Make AI the Main Actor

We don't separate the design in order to have AI write tests. We separate it so humans can understand specifications and make changes and reviews with confidence. We leave test case enumeration and implementation to AI, while humans review the validity of expected values and specifications.

Throwing AI at code with poor design only results in unreadable tests accumulating faster. When the design is properly separated, tests written by AI are also placed where humans can read them.

Summary

  • Fat handler tests bury specifications in mocks
  • Fixing roles by separating into layers narrows down where humans need to read
  • Protect business specifications with pure functions, AWS boundaries with moto, and environment settings with real AWS
  • AI writes tests. Humans judge the validity of specifications

You don't have to jump straight to 4 layers. As a first step, simply extracting business rules from the handler into pure functions lets you write business specification tests in a few lines and brings specifications back to a place where humans can read them.

Closing

In the experiment, even without well-organized design, AI properly identified boundary values and wrote working tests.
The difference appeared in the effort for humans to judge specifications from those tests. Precisely because we're in an era where AI writes tests, I feel the value of human-readable design is increasing.

Topics not covered in the presentation, such as input validation and external API mocks, are summarized in the source article.

I hope this blog is helpful to someone.

Share this article

AWSのお困り事はクラスメソッドへ