Github Actions & End-to-End Testing with Testcontainers & LocalStack

Github Actions & End-to-End Testing with Testcontainers & LocalStack

·

3 min read

If you’re a developer dealing with multiple systems, you already know there’s no way around end-to-end testing. You need to make sure that all your pieces fit together constantly. And if you’re a business releasing new software features often, you need a CI/CD pipeline that builds, tests, and releases software the moment you push your code. A comprehensive automated test suite is not as straightforward and easy as unit testing but it is essential. So, how can we bring the simplicity and speed of unit tests into these integration tests? On top of that, we’d prefer live services over mocked behavior for testing, aiming to replicate production behavior during tests.

This is where Testcontainers and LocalStack work beautifully together to bring you the best of integration tests and cloud services on your machine and in your CI/CD pipeline.

Today, we’re discussing the ease of setting up a workflow that will always make sure our system behaves as expected. Even visually, in a diagram, beyond the bigger picture, we can see that two key areas go hand in hand: setting up the right infrastructure and holding that in check using tests.

You can find the full working example in the Stack Bytes repository, the workflow, as is standard, will be under .github/workflow, and the test examples sit in the github-actions-testcontainers folder.

When you make changes to your application's code and push it to GitHub, GitHub Actions automatically kicks in. It takes your code and starts testing it against different scenarios, just like trying different puzzle pieces. This is especially useful for end-to-end testing, where you want to see if the entire application works well together. In this case we’re interested in testing the Lambda functions, but there are so many other apps that we can plug in.

With Testcontainers, you will set up a "sandbox" environment to put the puzzle pieces together, in our case, the AWS services we need. GitHub Actions runs your tests, simulating real user interactions. It's like a rehearsal before the big show – making sure everything runs smoothly before it's in front of your users. Another great aspect of using Testcontainrs with LocalStack is that all the steps leading up to the tests are already taken care of: Testcontainers manages the lifecycle of LocalStack, while provisioning the infrastructure is the same as in production, weather it’s initialisation hooks, Terraform, CDK, or CLI.

Here are some of the things that will make your life easier when you’re using Testcontainers in CI or on your machine:

  • Use a waiter to make sure your Lambdas are ACTIVE and not just created:

      LambdaWaiter waiter = lambdaClient.waiter();
          GetFunctionRequest getFunctionRequest = GetFunctionRequest.builder()
              .functionName("create-quote")
              .build();
          WaiterResponse<GetFunctionResponse> waiterResponse = waiter.waitUntilFunctionActiveV2(
              getFunctionRequest);
          waiterResponse.matched().response().ifPresent(response -> LOGGER.info(response.toString()));
    
  • Use this nifty configuration to scan the LocalStack logs and make sure your instance is in the right state before the tests are allowed to start:

      protected static LocalStackContainer localStack =
            new LocalStackContainer(DockerImageName.parse("localstack/localstack-pro:2.2.0"))
      ........
      .waitingFor(Wait.forLogMessage(".*Finished creating resources.*\\n", 1));
    
  • It’s been recently discovered that some Lambda containers are not removed when the tests end. Don’t worry, a fix is on the way, but in the meantime, you can use a dedicated function to clean up at the end of the test suite (only use this on your machine):

      protected static void cleanLambdaContainers() {
          try {
            String scriptPath = "src/test/resources/delete_lambda_containers.sh";
            ProcessBuilder processBuilder = new ProcessBuilder(scriptPath);
            processBuilder.inheritIO();
            Process process = processBuilder.start();
            int exitCode = process.waitFor();
            System.out.println("Script exited with code: " + exitCode);
          } catch (IOException | InterruptedException e) {
            e.printStackTrace();
          }
        }
    
      #!/bin/bash
    
      # get a list of running container ids with the word "lambda" in their names
      container_ids=$(docker ps -q --filter name=lambda)
    
      # loop through the ids and stop and remove each container
      for id in $container_ids; do
          echo "Stopping and removing container: $id"
          docker stop $id
          docker rm $id
      done