Cucumber + Junit5 + Localstack


Background


What are the options to test an Amazon lambda that interacts with Dynamo DB locally? To be honest, there are several options, such as using Mockito, using stubs, swapping Dynamo DB with Mongo DB, however none of these truly replicate the amazon lambda and dynamo db ecosystem.

Enter Localstack  (https://github.com/localstack/localstack), open source project that aims to emulate a local AWS cloud stack. With its docker version, we could spin up localstack and push our lambda and create our table in the dynamodb. To further increase its testing prowess, Localstack comes up with utils to integrate with Junit 4 and 5. Now we have the tools to create a BDD test using Cucumber, JUnit5 and Localstack as you can see below. Do note that due to the open source nature of localstack, it has several bugs that we manage to get around

Prereq:


  • Docker images: localstack/localstack:0.9.1
Note that the latest version of localstack docker image has bugs with the service port numbers
  • localstack lib: cloud.localstack:localstack-utils:0.1.15
Note that localstack 0.9.1 tested to work with 0.1.15
  • cucumber: cucumber-* 4.3.0
  • junit5: junit-jupiter-api:5.4.2
  • aws lib: aws-java-sdk-*:1.11.567
  • spring: spring-*:5.1.5.RELEASE
Spring is needed to provide cucumber with dependency injection for World object, alternatively you could use singleton class
  • shadowJar 5.0.0
ShadowJar is needed to explode all jars into a single uber jar during build time since localstack only works with an uber jar



Explanation, Tips and Tricks

  • Localstack only works with uber jar, creating an uber jar using shadow jar causes complication with jackson library, therefore we need to relocate the jackson libraries
  • Localstack always expect a json object as request and could not handle native variable or object, therefore the parameter to handleRequest() method in the class that implements RequestHandler will have to be a POJO
  • Localstack always default to region of "us-east-1", it doesn't care about credentials but something must exists and endpoint always be localhost with specific port for the service
  • Pay close attention on CucumberLocalstackTestRunner.java, it uses JUnit5 annotations, and invoke cucumber cli as an ordinary test, a workaround on using JUnit5 with cucumber until someone create cucumber-junit5 integration.
  • In addition, there is a fix using reflection to fix defect in localstack utils code which shall be explained below

Further Explanation

Cucumber with JUnit5
At the moment there is no cucumber junit5 integration library, therefore one way we can get away is to call cucumber cli in a JUnit5 ordinary test and check for failure:

@Testpublic void cucumberTest() {
  String[] argv = new String[]{"--plugin", "pretty", "--glue", "com.sevnis.localstackcucumberjunit5demo",      "src/test/resources/cucumber.feature"};  ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();  byte result = Main.run(argv, contextClassLoader);  assertTrue(result == 0);}


LocalstackUtils Hack
LocalstackUtils try to obtain the service to port map from docker directly, however to some environment the docker exec command would return nothing and therefore ends up in empty map and eventually failure, to fix it we force check and update the port to map using java reflection

private static void extendLocalstack() throws Exception {
  Field serviceToPortMapField = LocalstackDocker.INSTANCE.getClass()
    .getDeclaredField("serviceToPortMap");  
    serviceToPortMapField.setAccessible(true); 
 
    Map<String, Integer> serviceToPortMap = (Map<String, Integer>) serviceToPortMapField
       .get(LocalstackDocker.INSTANCE);

  if (serviceToPortMap == null || serviceToPortMap.isEmpty()) {

    Field containerIdField = LocalstackDocker.INSTANCE.getLocalStackContainer().getClass()
       .getDeclaredField("containerId");    
    containerIdField.setAccessible(true);  
  
    String containerId = (String) containerIdField.get(LocalstackDocker.INSTANCE
       .getLocalStackContainer());

    String localStackPortConfig = new DockerExe().execute(Arrays.asList("exec", "-i", 
       containerId, "cat", "/opt/code/localstack/.venv/lib/python2.7/site-packages/" + 
       "localstack_client/config.py"));

    Map<String, Integer> ports = new RegexStream(
        Pattern.compile("'(\\w+)'\\Q: '{proto}://{host}:\\E(\\d+)'")
        .matcher(localStackPortConfig)).stream()
        .collect(Collectors.toMap(match -> match.group(1),            
            match -> Integer.parseInt(match.group(2))));

    serviceToPortMapField.set(LocalstackDocker.INSTANCE, 
    Collections.unmodifiableMap(ports));  
  }
}


Latest Localstack Docker 
Localstack Docker version 0.9.1 works with LocalstackUtils 0.1.15 however the runner only run with latest version and will check and download the latest localstack if it couldn't find one. In order to go around this issue do the following:
  • pull docker image for localstack/localstack:0.9.1 and then tag it to localstack/localstack:latest
  • set pullNewImage = false in the LocalstackDockerProperties
@LocalstackDockerProperties(pullNewImage = false, services = {"lambda", "dynamodb"})
public class CucumberLocalstackTestRunner {


References






Comments

Popular posts from this blog

Spring Boot 2: Parallelism with Spring WebFlux