Block Image

Il problema

In questo periodo sto lavorando con progetti che utilizzano molti servizi di AWS, come DynamoDB, SQS, S3, etc.
Uno dei problemi che ho incontrato lavorando su tecnologie Cloud è quello di testare questi servizi anche in locale.
Fortunatamente, esistono tool come Testcontainers e LocalStack che permettono di aggirare facilmente questo problema.
Ho pensato quindi di scrivere un articolo a riguardo!

Cos'è Testcontainers

Spesso, negli anni passati, quando avevamo necessità di fare dei test d'integrazione, dovevamo utilizzare delle librerie che simulassero i tool che avremmo poi usato in produzione (come database, web server, etc).
Ad esempio, per i test su database, abbiamo utilizzato spesso database in-memory come H2.
Sebbene questo ci permetteva di utilizzare un database durante i test, questi ultimi però non erano del tutto "veritieri" in quanto in produzione avremmo utilizzato sicuramente un database diverso!

Grazie alla diffusione della tecnologia a container e in particolare di Docker, si sono sviluppate delle librerie che permettono di creare, durante la fase di testing, i container dei tool che utilizzeremo in produzione, rendendo i test più veritieri.
Testcontainers è una libreria Java che permette proprio questo: utilizza Docker per creare i container durante i test e poi li distrugge una volta terminati questi ultimi.

Testcontainers ha già delle integrazioni con i tool più utilizzati (database, code di messaggi e altro ancora).
Tutto quello che dobbiamo fare è importare nel nostro progetto la libreria di Testcontainers adatta al tool che vogliamo utilizzare.

Cos'è LocalStack

LocalStack è un tool che, grazie alla tecnologia a container, permette di emulare in locale i servizi di AWS, come AWS Lambda, S3, DynamoDB, Kinesis, SQS, SNS.

In questo articolo ti mostrerò come testare il servizio di DynamoDB grazie a LocalStack e Testcontainers.

Nota: Nell'articolo utilizzerò Spring Boot come framework di Dependency Injection, ma non è fondamentale. Puoi utilizzare qualsiasi altro framework. Inoltre utilizzeremo l'SDK Java di AWS DynamoDB (nessuna integrazione nativa con Spring Boot).
Per eseguire i test, l'unico prerequisito è aver installato Docker sulla propria macchina e che quest'ultimo sia up & running.

Primo passo: inizializzazione del progetto

In questo esempio utilizzeremo il sito di Spring Initializr per creare lo scheletro del progetto. Se non utilizzi Spring Boot, sentiti libero di saltare questo passaggio.

Utilizziamo la seguente configurazione e poi clicchiamo su "Generate":

Block Image

Unzippiamo il progetto appena scaricato e apriamolo con un IDE.
Nel pom.xml dobbiamo aggiungere le dipendenze delle librerie dell'SDK di DynamoDB e di LocalStack:

<dependency>
    <groupId>com.amazonaws</groupId>
    <artifactId>aws-java-sdk-dynamodb</artifactId>
    <version>1.12.468</version>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>localstack</artifactId>
    <scope>test</scope>
</dependency>

Secondo passo: creiamo la configurazione di DynamoDB

Nel file application.properties, dentro src/main/resources, aggiungere le seguenti properties:

amazon.dynamodb.endpoint=http://localhost:4566/
amazon.aws.accesskey=key
amazon.aws.secretkey=key2

Creiamo un sotto-package config e scriviamo adesso una classe di configurazione per connetterci a DynamoDB con le properties lette dal file application.properties:

@Configuration
public class DynamoDBConfig {

    @Value("${amazon.dynamodb.endpoint}")
    private String amazonDynamoDBEndpoint;

    @Value("${amazon.aws.accesskey}")
    private String amazonAWSAccessKey;

    @Value("${amazon.aws.secretkey}")
    private String amazonAWSSecretKey;

    @Bean
    AmazonDynamoDB amazonDynamoDB() {
        AwsClientBuilder.EndpointConfiguration endpointConfiguration = new AwsClientBuilder.EndpointConfiguration(amazonDynamoDBEndpoint, "");

        return AmazonDynamoDBClientBuilder.standard()
                .withEndpointConfiguration(endpointConfiguration)
                .build();

    }

    @Bean
    AWSCredentials amazonAWSCredentials() {
        return new BasicAWSCredentials(
                amazonAWSAccessKey, amazonAWSSecretKey);

    }

}

Analizziamo il codice:

  1. Con @Value leggiamo le properties dall'application.properties e le iniettiamo nelle variabili della classe.
  2. I bean di AmazonDynamoDB e AWSCredentials permettono di collegarci a DynamoDB tramite le properties iniettate.
Nota: Il progetto ha il solo scopo di eseguire i test su DynamoDB, non di far collegare un reale DynamoDB. Comunque, potete modificare l'URL di Dynamo per far collegare quest'ultimo al vostro progetto, se volete.

Terzo passo: scriviamo un model User

Creiamo un sotto-package model e scriviamo una classe User:

@DynamoDBTable(tableName = "Users")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {

    private String id;
    private String firstname;
    private String surname;

    @DynamoDBHashKey(attributeName = "Id")
    @DynamoDBAutoGeneratedKey
    public String getId() {
        return id;
    }

    @DynamoDBAttribute(attributeName = "FirstName")
    public String getFirstname() {
        return firstname;
    }

    @DynamoDBAttribute(attributeName = "Surname")
    public String getSurname() {
        return surname;
    }

}

In questa classe le uniche cose rilevanti sono le annotazioni per mappare quest'ultima ad una tabella di DynamoDB.
In particolare:

  1. Con @DynamoDBTable(tableName = "Users"), stiamo dicendo che la classe User mappa la tabella Users di DynamoDB.
  2. Con @DynamoDBHashKey(attributeName = "Id") stiamo dicendo che il campo id è la primary key della tabella. Inoltre, con @DynamoDBAutoGeneratedKey stiamo dicendo che la chiave deve essere auto-generata da DynamoDB.
  3. Con @DynamoDBAttribute elenchiamo gli altri attributi della tabella.

Quarto passo: scriviamo il DAO di User

Creiamo il sotto-package dao e scriviamo la seguente interfaccia DAO:

public interface GenericDao<T, ID> {

    List<T> findAll();
    Optional<T> findById(ID id);
    T save(T entity);
    void delete(T entity);
    void deleteAll();
}

Questa interfaccia contiene i metodi comuni di un DAO.
Creiamo ora l'interfaccia UserDao:

public interface UserDao extends GenericDao<User, String> {
}

Per i nostri scopi, non abbiamo bisogno di metodi al di fuori di quelli standard, per cui basta che questa interfaccia estenda la GenericDao.

Possiamo anche generalizzare le operazioni DAO effettuate su DynamoDB, al di là del tipo di entità. Creiamo quindi una classe astratta DynamoDBDao:

@Slf4j
public abstract class DynamoDBDao<T, ID> implements GenericDao<T, ID> {

    protected final AmazonDynamoDB amazonDynamoDB;
    
    private final Class<T> instanceClass;
    
    private final Environment env;
    
    public DynamoDBDao(AmazonDynamoDB amazonDynamoDB, Class<T> instanceClass, Environment env) {
        this.amazonDynamoDB = amazonDynamoDB;
        this.instanceClass = instanceClass;
        this.env = env;
    }

    @Override
    public List<T> findAll() {
        DynamoDBMapper mapper = new DynamoDBMapper(amazonDynamoDB);
        return mapper.scan(instanceClass, new DynamoDBScanExpression());
    }

    @Override
    public Optional<T> findById(ID id) {
        DynamoDBMapper mapper = new DynamoDBMapper(amazonDynamoDB);

        T entity = mapper.load(instanceClass, id);

        if(entity != null) {
            return Optional.of(entity);
        }
        return Optional.empty();
    }

    @Override
    public T save(T entity) {
        DynamoDBMapper mapper = new DynamoDBMapper(amazonDynamoDB);
        mapper.save(entity);
        return entity;
    }

    @Override
    public void delete(T entity) {
        DynamoDBMapper mapper= new DynamoDBMapper(amazonDynamoDB);
        mapper.delete(entity);
    }


    public void deleteAll() {
        List<String> activeProfiles = Arrays.stream(env.getActiveProfiles()).toList();
        if(activeProfiles.contains("test")) {
            List<T> entities = findAll();
            if(! CollectionUtils.isEmpty(entities)) {
                log.info("Cleaning entity table");
                entities.forEach(this::delete);
            }
        }
    }
}

Grazie all'uso dei Generics di Java e al campo instanceClass, siamo in grado di generalizzare tutti i metodi DAO standard.
Il campo env serve per capire se si sta utilizzando l'applicativo in un contesto di Test, perché non vogliamo che anche in produzione si possa utilizzare una deleteAll, ma questo metodo può essere utile durante i test, per pulire i dati.

Creiamo adesso il DAO dedicato al model User:

@Repository
@Slf4j
public class UserDynamoDBDao extends DynamoDBDao<User, String> implements UserDao {

    public UserDynamoDBDao(AmazonDynamoDB amazonDynamoDB, Environment env) {
        super(amazonDynamoDB, User.class, env);
    }

}

È una classe vuota che estende DynamoDBDao e implementa UserDao. Così potremmo iniettare questa implementazione di UserDao nella nostra business logic senza legare il DAO a DynamoDB.

Quinto passo: scriviamo la classe di configurazione di LocalStack

È ora di testare il DAO tramite dei test d'integrazione! Per farlo, creiamo innanzitutto, dentro il package di test, una classe di configurazione di LocalStack, in modo tale che tutte le classi di test possano importarla:

@TestConfiguration
@TestPropertySource(properties = {
        "amazon.aws.accesskey=test1",
        "amazon.aws.secretkey=test231"
})
public class LocalStackConfiguration {

    @Autowired
    AmazonDynamoDB amazonDynamoDB;

    static LocalStackContainer localStack =
            new LocalStackContainer(DockerImageName.parse("localstack/localstack:1.0.4.1.nodejs18"))
                    .withServices(DYNAMODB)
                    .withNetworkAliases("localstack")
                    .withNetwork(Network.builder().createNetworkCmdModifier(cmd -> cmd.withName("test-net")).build());

    static {
        localStack.start();
        System.setProperty("amazon.dynamodb.endpoint", localStack.getEndpointOverride(DYNAMODB).toString());
    }

    @PostConstruct
    public void init() {
        DynamoDBMapper dynamoDBMapper = new DynamoDBMapper(amazonDynamoDB);

        CreateTableRequest tableUserRequest = dynamoDBMapper
                .generateCreateTableRequest(User.class);
        tableUserRequest.setProvisionedThroughput(
                new ProvisionedThroughput(1L, 1L));
        amazonDynamoDB.createTable(tableUserRequest);
    }
}

Analizziamo il codice:

  1. Con @TestConfiguration indichiamo a Spring che questa è una classe di configurazione solo per lo scope di test.
  2. Con @TestPropertySource iniettiamo nel contesto di test di Spring, le properties indicate, che andranno a sovrascrivere quelle dell'application.properties.
  3. La variabile di tipo LocalStackContainer rappresenta il container di LocalStack.
    • Con withServices stiamo indicando a LocalStack quali servizi di AWS ci interessano (in questo caso solo DynamoDB).
    • Con withNetworkAliases stiamo dando un alias di rete al container (non stiamo indicando il nome del container!).
    • Con withNetwork stiamo dicendo a Testcontainers che il container creato deve far parte di una network custom.
    • Rendendo questa variabile statica, il container sarà condiviso da tutti i metodi della classe di Test.
    • Inoltre, essendo dentro una classe @TestConfiguration, questo container non solo sarà condiviso tra metodi di test di una stessa classe, ma anche da tutte le classi di test (che importano questa classi di configurazione).
  4. Nel blocco static facciamo partire il container e dinamicamente valorizziamo la property amazon.dynamodb.endpoint (che cambierà a ogni nuova esecuzione del container).
  5. Inoltre dopo il blocco static, verrà invocato il metodo annotato con @PostConstruct, dove creiamo la tabella Users di DynamoDB.

Sesto passo: scriviamo la classe di test per UsedDao

Scriviamo la classe di test per UserDao:

@SpringBootTest
@Import(LocalStackConfiguration.class)
@ActiveProfiles("test")
class UserDaoTests {

   @Autowired
   private UserDao userDao;

   @BeforeEach
   public void setUp() {
      userDao.deleteAll();
   }


   @Test
   void saveNewElementTest() {
      var userOne = new User(null, "Vincenzo", "Racca");

      userDao.save(userOne);

      List<User> retrievedUsers = userDao.findAll();
      assertThat(retrievedUsers).hasSize(1);
      assertThat(retrievedUsers.get(0)).isEqualTo(userOne);
   }

   @Test
   void saveAnExistingElementTest() {
      var userOne = new User(null, "Vincenzo", "Racca");

      userDao.save(userOne);

      List<User> retrievedUsers = userDao.findAll();
      assertThat(retrievedUsers).hasSize(1);
      assertThat(retrievedUsers.get(0)).isEqualTo(userOne);
      assertThat(retrievedUsers.get(0).getFirstname()).isEqualTo("Vincenzo");

      userOne.setFirstname("Enzo");
      userDao.save(userOne);

      retrievedUsers = userDao.findAll();
      assertThat(retrievedUsers).hasSize(1);
      assertThat(retrievedUsers.get(0)).isEqualTo(userOne);
      assertThat(retrievedUsers.get(0).getFirstname()).isEqualTo("Enzo");
   }

   @Test
   void deleteTest() {
      var userOne = new User(null, "Vincenzo", "Racca");

      userDao.save(userOne);
      String id = userOne.getId();
      assertThat(id).isNotNull();

      Optional<User> retrievedUser = userDao.findById(id);
      assertThat(retrievedUser.isPresent()).isTrue();
      assertThat(retrievedUser.get()).isEqualTo(userOne);

      userDao.delete(userOne);
      Optional<User> userNotRetrieved = userDao.findById(id);
      assertThat(userNotRetrieved.isPresent()).isFalse();

   }
}

Qui la cosa rilevante è che utilizziamo l'annotation @Import per importare la classe di configurazione di LocalStack che abbiamo scritto in precedenza.
Inoltre attiviamo il profilo di test con @ActiveProfiles("test") in modo tale da poter utlizzare il metodo deleteAll.

Ora fate partite tutti i test utilizzando il vostro IDE oppure utilizzando, dalla root del progetto, il comando:
mvnw clean test.

Block Image

Tutti i test hanno dato esito positivo!

Nota: Nell'immagine sopra vedete anche la classe di test CarDaoTests, che è disponibile sul mio repo di GitHub. In questo modo potete verificare che il container è condiviso anche tra diverse classi di test.

Conclusioni

In questo articolo abbiamo visto come creare facilmente dei test d'integrazione "veritieri" utilizzando Testcontainers e LocalStack, in modo tale da poter utilizzare un container di DynamoDB per i test.
Trovate il codice completo sul mio repo di GitHub al seguente link:
github.com/vincenzo-racca/testcontainers.

Altri articoli su Spring: Spring.
Articoli su Docker: Docker.

Libri consigliati su Spring, Docker e Kubernetes: