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.
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":
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:
- Con @Value leggiamo le properties dall'application.properties e le iniettiamo nelle variabili della classe.
- I bean di AmazonDynamoDB e AWSCredentials permettono di collegarci a DynamoDB tramite le properties iniettate.
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:
- Con @DynamoDBTable(tableName = "Users"), stiamo dicendo che la classe User mappa la tabella Users di DynamoDB.
- 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.
- 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:
- Con @TestConfiguration indichiamo a Spring che questa è una classe di configurazione solo per lo scope di test.
- Con @TestPropertySource iniettiamo nel contesto di test di Spring, le properties indicate, che andranno a sovrascrivere quelle dell'application.properties.
- 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).
- Nel blocco static facciamo partire il container e dinamicamente valorizziamo la property amazon.dynamodb.endpoint (che cambierà a ogni nuova esecuzione del container).
- 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
.
Tutti i test hanno dato esito positivo!
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:
- Cloud Native Spring in Action: https://amzn.to/3xZFg1S
- Pro Spring 5 (Spring da zero a hero): https://amzn.to/3KvfWWO
- Pivotal Certified Professional Core Spring 5 Developer Exam: A Study Guide Using Spring Framework 5 (per certificazione Spring): https://amzn.to/3KxbJSC
- Pro Spring Boot 2: An Authoritative Guide to Building Microservices, Web and Enterprise Applications, and Best Practices (Spring Boot del dettaglio): https://amzn.to/3TrIZic
- Docker: Sviluppare e rilasciare software tramite container: https://amzn.to/3AZEGDI
- Kubernetes TakeAway: Implementa i cluster K8s come un professionista: https://amzn.to/3dVxxuP