Java Serverless on AWS Lambda · Running Java Functions in Production

Testing Java Functions

Why handler code that builds its own clients is untestable, and how to test Java Lambda functions at every level.

Scenario

Example app: a Java order function builds its DynamoDB and SQS clients inside the handler and contains all business rules in handleRequest. The only tests deploy to AWS and run for 15 minutes.

What you see in production

Refactors regularly break edge cases (empty orders, missing optional fields) that nobody tested because the tests are slow. A permissions change broke production because integration tests run only weekly.

The naive code

public String handleRequest(SQSEvent event, Context ctx) {
    DynamoDbClient dynamo = DynamoDbClient.create();
    for (var msg : event.getRecords()) {
        Order order = new ObjectMapper().readValue(msg.getBody(), Order.class);   // checked exception ignored here
        if (order.lines().isEmpty()) continue;
        dynamo.putItem(toItem(order));
    }
    return "ok";
}

Reproduce it

Separating logic from AWS adapters makes behaviour testable in milliseconds:

Testing business logic with a fake repository
import java.util.*;

public class Main {
    record Line(int qty, double price) {}
    record Order(String id, List<Line> lines) {}
    interface OrderRepository { void save(String id, double total); }

    static final class OrderService {
        private final OrderRepository repo;
        OrderService(OrderRepository repo) { this.repo = repo; }
        String handle(Order order) {
            if (order.lines() == null || order.lines().isEmpty()) return "rejected: no lines";
            double total = order.lines().stream().mapToDouble(l -> l.qty() * l.price()).sum();
            repo.save(order.id(), total);
            return "saved " + order.id() + " total " + total;
        }
    }

    public static void main(String[] args) {
        Map<String, Double> saved = new LinkedHashMap<>();
        OrderService service = new OrderService(saved::put);          // fake repository
        Object[][] cases = {
            {"empty order", new Order("o1", List.of()), "rejected: no lines"},
            {"null lines", new Order("o2", null), "rejected: no lines"},
            {"valid order", new Order("o3", List.of(new Line(2, 10.0), new Line(1, 5.0))), "saved o3 total 25.0"},
        };
        for (Object[] c : cases) {
            String result = service.handle((Order) c[1]);
            System.out.println((result.equals(c[2]) ? "PASS " : "FAIL ") + c[0] + " -> " + result);
        }
        System.out.println("saved: " + saved);
    }
}

Output

PASS empty order -> rejected: no lines
PASS null lines -> rejected: no lines
PASS valid order -> saved o3 total 25.0
saved: {o3=25.0}

Run this on your computer — see “Get Started” for setup.

Root cause

Handlers that construct their own clients and contain all logic can only run against real AWS. Slow, environment-dependent tests run rarely, so regressions in logic and in configuration (permissions, triggers) slip through.

The fix

  • Keep the handler a thin adapter; inject dependencies (constructor injection, or a small factory) so business logic is plain Java.
  • JUnit 5 unit tests for logic with fakes or Mockito; aws-lambda-java-tests to load realistic events (@Event, @Events annotations) for handler tests.
  • Testcontainers with LocalStack (or DynamoDB Local) for integration tests of SDK code paths.
  • Deploy ephemeral stacks per branch for a few end-to-end tests covering IAM, triggers and limits; run them on every merge request.
  • sam local invoke for quick manual checks.
class OrderHandlerTest {
    @ParameterizedTest
    @Event(value = "events/sqs-order.json", type = SQSEvent.class)
    void savesValidOrders(SQSEvent event) {
        var repo = new InMemoryOrderRepository();
        var handler = new OrderHandler(new OrderService(repo));
        handler.handleRequest(event, new TestContext());
        assertThat(repo.saved()).containsKey("o-123");
    }
}

Trade-offs

  • Fakes can diverge from real services; keep integration tests for the contracts that matter.
  • Testcontainers needs Docker in CI and adds minutes; run them in a separate stage.
  • Ephemeral stacks cost money; clean them up automatically.

Detect it in production

  • Escaped defects categorised as logic versus permission/configuration problems.
  • CI duration and flakiness; how often integration tests actually run.
  • Coverage of event fixtures per event source.

Key points

  • Thin handlers, injected dependencies, plain-Java business logic.
  • JUnit + aws-lambda-java-tests for realistic events; Testcontainers/LocalStack for SDK paths.
  • Ephemeral cloud stacks for IAM and wiring.

Further reading: aws-lambda-java-tests on GitHub · Testcontainers: LocalStack module · Testing serverless functions

Practice questions

How do you make a Java Lambda handler unit-testable?

Answer

Make the handler a thin adapter that delegates to business classes receiving their dependencies (repositories, clients) through constructors, so tests can pass fakes or mocks and run without AWS.

What does aws-lambda-java-tests provide?

Answer

JUnit 5 extensions and annotations (such as @Event and @Events) that load JSON files into aws-lambda-java-events types, so handlers can be tested with realistic event payloads.

What is Testcontainers with LocalStack good for, and what can it not prove?

Answer

It runs emulated AWS services in Docker for integration tests of SDK code paths and data handling. It cannot prove real IAM permissions, service quotas or exact service behaviour; test those in ephemeral AWS stacks.

Open in the app to mark lessons complete, take notes and track your progress.