Testing AWS Step Functions Locally with Java, Maven, TestContainers, LocalStack, and JUnit 5

Discover how aleph0 tests AWS applications locally using Java, Maven, TestContainers, LocalStack, and JUnit 5. Streamline your own development process without the cost and delay of staging environments.

AWS changed the game for making rich, resilient software quickly. However, testing applications that use Amazon services can be challenging, especially when the cost of Yet Another Staging Environment would be prohibitive. Fortunately, tools like Testcontainers and LocalStack offer an excellent alternative for developers. These tools allow you to simulate AWS services locally, making it possible to test your code thoroughly without breaking the bank.

In this post, we’ll explore a simple, self-contained, correct example of building and testing a simple AWS Step Functions State Machine in a Java project using Maven. By leveraging Testcontainers and LocalStack, we can test our AWS Step Functions locally, ensuring our state machine behaves as expected. The setup seamlessly integrates into the Maven build lifecycle, making the testing process automatic and consistent across different environments.

Our example project demonstrates how to configure TestContainers and LocalStack to run alongside your application with the same lifecycle as your tests. We use JUnit 5 as the testing framework, which allows us to define and execute test cases that interact with the simulated AWS services quickly and easily. This setup not only saves costs but also accelerates development by providing immediate feedback on the application’s behavior.

Here is a quick peek at how an integration test looks:

@Testcontainers
public class ExampleIT {
  /**
   * The LocalStack container is started before any test method is executed and stopped after all
   * test methods are executed.
   * 
   * @see <a href="https://www.testcontainers.org/test_framework_integration/junit_5/">JUnit 5</a>
   * @see <a href="https://docs.localstack.cloud/user-guide/integrations/testcontainers/">LocalStack
   *      Testcontainers</a>
   * @see <a href=
   *      "https://stackoverflow.com/questions/61625288/spring-boot-testcontainers-mapped-port-can-only-be-obtained-after-the-container">Spring
   *      Boot Testcontainers: Mapped port can only be obtained after the container</a>
   */
  @Container
  public static final LocalStackContainer localstack =
      new LocalStackContainer(DockerImageName.parse("localstack/localstack:3"))
          // .withEnv("DEBUG", "1").withEnv("LS_LOG", "trace") // Enable debug logging
          .withServices(Service.CLOUDFORMATION, Service.STEPFUNCTIONS, Service.IAM,
              LocalStackContainer.EnabledService.named("events"))
          .withLogConsumer(of -> {
            System.err.print(of.getUtf8String());
          });

  public CloudFormationClient cfn;
  public SfnClient sfn;
  public SfnAsyncClient sfnAsync;

  @BeforeEach
  public void setupExampleIT() {
    cfn = CloudFormationClient.builder().endpointOverride(localstack.getEndpoint())
        .credentialsProvider(StaticCredentialsProvider.create(
            AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey())))
        .region(Region.of(localstack.getRegion())).build();
    sfn = SfnClient.builder().endpointOverride(localstack.getEndpoint())
        .credentialsProvider(StaticCredentialsProvider.create(
            AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey())))
        .region(Region.of(localstack.getRegion())).build();
    sfnAsync = SfnAsyncClient.builder().endpointOverride(localstack.getEndpoint())
        .credentialsProvider(StaticCredentialsProvider.create(
            AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey())))
        .region(Region.of(localstack.getRegion()))
        // Override the default timeout of 30 seconds to 65 seconds since we'll be using it for
        // GetActivityTask, per the docs.
        // https://docs.aws.amazon.com/step-functions/latest/apireference/API_GetActivityTask.html
        .overrideConfiguration(
            ClientOverrideConfiguration.builder().apiCallTimeout(Duration.ofSeconds(65))
                .apiCallAttemptTimeout(Duration.ofSeconds(65)).build())
        .build();
  }

  @AfterEach
  public void teardownExampleIT() {
    cfn.close();
    sfn.close();
    sfnAsync.close();
  }

  @Test
  public void givenLocalStack_whenCallServices_thenServicesRespond() {
    // We don't care about the results. We just want to see if the services are running and we can
    // communicate with them using the clients.
    cfn.listStacks().stackSummaries();
    sfn.listStateMachines().stateMachines();
  }

  @Test
  public void givenLocalStack_whenDeployStack_thenStateMachineExists() throws Exception {
    final String templateBody;
    try (InputStream in = new FileInputStream("cfn-template.yml")) {
      templateBody = new String(in.readAllBytes(), StandardCharsets.UTF_8);
    }

    final String stackName = "test-stack";

    cfn.createStack(CreateStackRequest
        .builder().stackName(stackName).parameters(Parameter.builder()
            .parameterKey("StateMachineName").parameterValue("TheStateMachine").build())
        .templateBody(templateBody).build());

    Stack stackCreation = null;
    do {
      Thread.sleep(5000);
      stackCreation =
          cfn.describeStacks(DescribeStacksRequest.builder().stackName(stackName).build()).stacks()
              .get(0);
    } while (stackCreation.stackStatus() == StackStatus.CREATE_IN_PROGRESS);

    if (stackCreation.stackStatus() != StackStatus.CREATE_COMPLETE) {
      System.err.println("Stack status reason:");
      System.err.println(stackCreation.stackStatusReason());
      System.err.println();

      System.err.println("Stack events:");
      cfn.describeStackEventsPaginator(
          DescribeStackEventsRequest.builder().stackName(stackName).build()).stackEvents().stream()
          .forEach(System.err::println);
      System.err.println();
    }

    assertEquals(StackStatus.CREATE_COMPLETE, stackCreation.stackStatus());

    final String stateMachineArn = stackCreation.outputs().stream()
        .filter(output -> output.outputKey().equals("StateMachineArn")).map(Output::outputValue)
        .findFirst().orElseThrow(() -> new NoSuchElementException("StateMachineArn"));
    final String activityArn = stackCreation.outputs().stream()
        .filter(output -> output.outputKey().equals("ActivityArn")).map(Output::outputValue)
        .findFirst().orElseThrow(() -> new NoSuchElementException("StateMachineArn"));

    Thread activityThread =
        new Thread(new ActivityManager(sfnAsync, activityArn, HelloActivity::new));
    activityThread.start();
    try {
      StartExecutionResponse startExecutionResponse = sfn.startExecution(StartExecutionRequest
          .builder().stateMachineArn(stateMachineArn).input("{\"name\":\"World\"}").build());
      final String executionArn = startExecutionResponse.executionArn();

      DescribeExecutionResponse describeExecutionResponse;
      do {
        Thread.sleep(5000);
        describeExecutionResponse = sfn.describeExecution(
            DescribeExecutionRequest.builder().executionArn(executionArn).build());
      } while (describeExecutionResponse.status().equals(ExecutionStatus.RUNNING));

      assertEquals(ExecutionStatus.SUCCEEDED, describeExecutionResponse.status());
      assertEquals(describeExecutionResponse.output(), "{\"greeting\":\"Hello, World!\"}");
    } finally {
      activityThread.interrupt();
      activityThread.join();
    }

    cfn.deleteStack(DeleteStackRequest.builder().stackName(stackName).build());

    Stack stackDeletion = null;
    do {
      Thread.sleep(5000);
      try {
        stackDeletion =
            cfn.describeStacks(DescribeStacksRequest.builder().stackName(stackName).build())
                .stacks().get(0);
      } catch (CloudFormationException e) {
        if (e.statusCode() == 400) {
          // Stack does not exist
          stackDeletion = null;
        } else {
          throw e;
        }
      }
    } while (stackDeletion != null
        && stackDeletion.stackStatus() == StackStatus.DELETE_IN_PROGRESS);

    if (stackDeletion != null && stackDeletion.stackStatus() != StackStatus.DELETE_COMPLETE) {
      System.err.println("Stack status reason:");
      System.err.println(stackDeletion.stackStatusReason());
      System.err.println();

      System.err.println("Stack events:");
      cfn.describeStackEventsPaginator(
          DescribeStackEventsRequest.builder().stackName(stackName).build()).stackEvents().stream()
          .forEach(System.err::println);
      System.err.println();
    }
  }
}

Integrating these tools into your CI/CD pipeline can further streamline your workflow. By automating the testing process, you ensure that every change to your codebase is verified against a consistent environment. This approach minimizes the risk of unexpected issues in production, as each change is tested in a controlled, reproducible manner before deployment.

Overall, using TestContainers and LocalStack with Java and Maven offers a practical solution for testing AWS-based applications locally. It enables developers to maintain high-quality code while managing costs and infrastructure complexity. For a detailed walkthrough of the setup and configuration, refer to the example project on GitHub. This setup is a valuable addition to any developer’s toolkit, making testing AWS services accessible and efficient.

More blog posts

see all