Evoluindo sua estratégia de Testes para FileUpload com Spring Test e Amazon S3
Tags: testing, spring, aws, amazons3
Published: 2024-07-10 11:15:01

Em meu último artigo, discutimos como pode ser implementado um serviço de file upload utilizando Spring Boot e Amazon S3. Lá entendemos quais são as preocupações e ferramentas necessárias para permitir que você customize as regras de como gerenciar seus arquivos. Se você não leu ainda, recomendo que o faça, antes de ler este artigo.
 
{% embed https://dev.to/jordihofc/desvendando-o-segredo-como-implementar-file-upload-com-spring-boot-e-amazon-s3-1jd1 %}

Após publicar o artigo, percebi que diversas pessoas tinham dúvidas de quais os possíveis caminhos para escrever testes que tragam segurança e confiabilidade para o File Upload. 

Dado a isso, hoje vamos bater um papo sobre como podemos evoluir a estratégia de testes, partindo dos testes unitários até os queridos testes de integração. 

OBS: A escolha da estratégia de testes pode variar dado contexto, como, por exemplo, familiaridade da equipe com tecnologia, conhecimento técnico, limitação do ambiente, etc.

## Escrevendo Testes de Unidade

Essa é de longe a estratégia mais utilizada pelos Dev's e Empresas, e seu principal objetivo é validar se cada pedaço de código isolado funciona como esperado. Então é comum que nessa técnica as características estruturais do código sejam validadas, esta técnica é indicada para sistemas que possuem logicas complexas, como, por exemplo: cálculo de juros, folha de ponto, e qualquer outro sistema que possua logica com alto uso de estrutura de decisão e repetição.

Outro ponto interessante sobre os testes de unidade, é que como seu objetivo é validar a menor unidade de código possível, normalmente quando a classe ou método que esta sendo testado possui dependências internas ou externas costuma-se simular esse comportamento, e isso é o que chamamos de Mock's ou Dublê. Normalmente quando se escreve testes desta categoria, utilizamos ferramentas como JUnit, Hamcrast, assertJ e Mockito. 


### Vamos ver como funciona na prática

Antes de iniciar a construção do teste, vamos relembrar o código de produção, que foi divido em duas classes, a classe _FileUpload_ representa o arquivo que será armazenado no S3, e _FileStorageService_ que representa o serviço que se integrará com Amazon S3 e fará o upload dos arquivos.

```java

public record FileUpload(MultipartFile data){}

@Service
public class FileStorageService {
    @Autowired
    private S3Template template;

    private String bucket = "myBucketName";

    public String upload(FileUpload fileUpload) {

        try (var file = fileUpload.data().getInputStream()) {
            String key = UUID.randomUUID().toString();
            S3Resource uploaded = template.upload(bucket, key, file);
            return key;
        } catch (IOException ex) {
            throw new RuntimeException("Não foi possivel realizar o upload do documento");
        }
    }
}
```
O código acima pode ser resumido em, inicialmente, o método `upload()` recebe uma instância da classe _FileUpload_ que carrega um _MultipartFile_ recebido mediante uma chamada HTTP. Em seguida, o arquivo a ser salvo no bucket, é aberto dentro do _**try-with-resources**_, caso alguma exceção seja lançada na abertura do arquivo, a mesma é tratada, e a streaming de dados é fechada automaticamente, causando a interrupção da execução do método. Caso contrário é criado uma chave de acesso ao arquivo, e envia respectivamente o arquivo para o Amazon S3, finalizando o método retornando sua chave de acesso.

```java
@ExtendWith(MockitoExtension.class)
class FileStorageServiceUnitTest {
    @Mock
    private S3Template s3Template;
    @Mock
    private MultipartFile file;
    @Mock
    private S3Resource uploaded;
    private String bucketName = "myBucketName";

    @Test
    @DisplayName("Deve fazer o upload de um arquivo")
    void t0() {
        //cenario
        FileUpload fileUpload = new FileUpload(file);
        FileStorageService fileStorageService = new FileStorageService(s3Template, bucketName);
        when(s3Template.upload(any(), any(), any())).thenReturn(uploaded);

        //acao
        String acesseKeyFile = fileStorageService.upload(fileUpload);

        //validacao
        assertNotNull(acesseKeyFile);
    }

}
 
```

Iniciamos a escrita dos testes unitários, definindo a classe que será responsável por agrupar os casos de testes, a mesma deve ser preparada para oferecer suporte a execução do Mockito junto a ferramenta JUnit. Dado a isso, o primeiro passo será anotar a classe com `@ExtendWith(MockitoExtension.class)`, que permitirá a definição de dubles (_Mocks_) através das anotações `@Mock` e `@Spy`.  Em seguida cuidamos de provisionar as dependências do _S3Template_ e _MultipartFile_, como não iremos provisionar o contexto do Spring, precisaremos definir os comportamentos das mesmas, então definimos as mesmas como _Mocks_, anotando previamente cada atributo com `@Mock`. Também iremos precisar de simular a resposta do _S3Template_, que retorna um objeto do tipo _S3Resource_, então também declaramos um atributo previamente anotado com `@Mock`. 

O próximo passo é a definição do nosso caso de teste, então separamos o testes em 3 etapas, sendo elas: **cenário**, **ação** e **validação**. Na etapa **cenário** vamos definir as dependências que o teste precisa para executar, então é inicialmente criado uma instância do _FileUpload_, que recebe o duble do _MultipartFile_ que definimos anteriormente. Como o _FileStorageService_ executa o método `upload()` do `S3Template` precisamos definir este comportamento através da API de _When_  do Mockito, que cria um gatilho para que **Quando** o método `s3Template.upload()` seja chamado, com qualquer entrada, o objeto _S3Resource_ seja retornado na resposta. Por fim, instanciamos a _FileStorageService_ que recebe via construtor o nome do bucket e o duble do _S3Template_.  

Já na etapa de **ação**, é onde vamos executar o nosso código de produção para colher o resultado e aplicar uma validação de modo a garantir que o resultado é equivalente ao esperado. Então simplesmente invocamos o método `fileStorageService.upload()` e armazenamos sua resposta a fim de executar as validações na próxima etapa. Na etapa de **validação**, vamos validar se realmente existe uma chave de acesso ao recurso criado, e para isso utilizaremos o `assertNotNull` já que a criação da chave de acesso não está em nosso controle.

### Ai eu te pergunto, como esse teste me ajuda a garantir que o _FileUpload_ funciona como deveria?

Em suma, esse teste ajuda muito pouco a trazer segurança e qualidade ao desenvolvimento da funcionalidade, já que o mesmo, busca cobrir apenas características estruturais do código, como operadores If, Else, For, Try, entre outros. Sem falar que um teste de unidade, toda execução é feita em memória, o que torna o extremamente rápido, porém, como nem tudo são flores, deixamos de validar toda a configuração da integração com Spring Cloud AWS e Amazon S3. Sem falar que a integração propriamente dita nem é executada, podendo ocasionar em erros não esperados durante a execução em produção.

A solução para o problema descrito acima, é aproximar o cenário do teste ao ambiente de produção, e a boa notícia é que o Spring Test fornece uma ampla gama de ferramentas que permitem executar o contexto do framework e se integrar a containers e dependências externas, como eu disse em: 
 {% embed https://dev.to/jordihofc/3-dicas-para-criar-uma-estrategia-moderna-de-testes-para-microsservicos-spring-boot-49a5 %}

### Evoluindo para um Teste de Integração com Spring Test, Test Containers e LocalStack

Antes de sair modificando o teste, precisamos adequar nossas dependências, então já adicione em sua ferramenta de build, as dependências de Spring Test Containers, TestContainers JUnit e LocalStack.

```JAVA
@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
class FileStorageServiceTest {
    @Autowired
    private S3Template s3Template;
    @Value("${s3.bucket.name}")
    private String bucketName;
    @Value("classpath:uploads/file.txt")
    private Resource file;
    @Autowired
    private FileStorageService fileStorageService;

    @Container
    static LocalStackContainer LOCALSTACK_CONTAINER = new LocalStackContainer(
            DockerImageName.parse("localstack/localstack")
    ).withServices(S3);

    @DynamicPropertySource
    static void registerS3Properties(DynamicPropertyRegistry registry) {
        registry.add("spring.cloud.aws.endpoint",
                () -> LOCALSTACK_CONTAINER.getEndpointOverride(S3).toString());
        registry.add("spring.cloud.aws.region.static",
                () -> LOCALSTACK_CONTAINER.getRegion().toString());
        registry.add("spring.cloud.aws.credentials.access-key",
                () -> LOCALSTACK_CONTAINER.getAccessKey().toString());
        registry.add("spring.cloud.aws.credentials.secret-key",
                () -> LOCALSTACK_CONTAINER.getSecretKey().toString());
    }

    @BeforeEach
    void setUp() {
        s3Template.createBucket(bucketName);
    }

    @AfterEach
    void tearDown() {
        s3Template.deleteBucket(bucketName);
    }

    @Test
    @DisplayName("Deve fazer o upload de um arquivo")
    void t0() throws IOException {
        //cenario
        MockMultipartFile fileRequest = new MockMultipartFile("data", file.getInputStream());
        FileUpload fileUpload = new FileUpload(fileRequest);
        //acao
        String keyAcessResource = fileStorageService.upload(fileUpload);
        //validacao
        assertTrue(s3Template.objectExists(bucketName, keyAcessResource));
        s3Template.deleteObject(bucketName, keyAcessResource);
    }

}
```

A primeiro momento, precisamos adaptar nossa classe de testes, para suportar a inicialização do _ApplicationContext_ do Spring. Também é necessário que as configurações referentes ao ambiente de teste sejam aplicadas, então anotaremos a classe com `@SpringBootTest` e `@ActiveProfiles("test")`. Em seguida, vamos indicar para o JUnit que essa classe de teste faz o uso de containers gerenciados pelo TestContainers, então adicionamos a anotação `@Testcontainers` sobre a assinatura da classe.

O próximo passo, é indicar as dependências que utilizaremos durante a escrita do teste, para este caso, iremos precisar obter do _ApplicationContext_ instâncias do _S3Template_, _FileStorageService_, então utilizaremos a injeção de dependências via campo, anotando os atributos previamente com `@Autowired`. E para finalizar esta etapa, colheremos a propriedade: `s3.bucket.name` para obter o nome do bucket. 
E também iremos colher o arquivo que será enviado ao bucket, então anotaremos os campos, _file_ e _bucketName_ respectivamente com a anotação `@Value`.

Na etapa atual, nosso objetivo é criar a infraestrutura necessária para que o JUnit seja capaz de instanciar um container da LocalStack, expondo o serviço do Amazon S3, e em seguida integrando o mesmo no _ApplicationContext_. A primeiro momento iremos definir o container, através da abstração _LocalStackContainer_, como desejamos que o JUnit gerencie o ciclo de vida do container, anotaremos este atributo com `@Container`. Por fim, não menos importante, vamos conectar o S3 a Aplicação através do método `registerS3Properties()` que cuida, de atualizar os valores das properties de conexão com os dados do container.

Após preparar a aplicação para simular o cenário mais próximo de produção possível, podemos partir para escrita dos testes. Lembramos que uma característica essencial dos testes é que devem garantir o isolamento um dos outros. Então como boa prática, foi definido os métodos `setUp()` e `tearDown()`, que irão auxiliar na construção e destruição do ambiente para cada caso de teste. O método `setUp()` está previamente anotado com `@BeforeEach` para ser executado antes de cada teste e crie o bucket com nome definido. Já o método `tearDown()` está anotado com `@AfterEach` para ser executado ao fim de cada teste, destruindo o bucket criado anteriormente. 

Por fim, iniciaremos a construção do nosso teste de integração, então ao dividiremos o teste, nas queridas etapas de **cenário**, **ação** e **validação**. No **cenário**, criaremos um objeto do tipo _MockMultipartFile_ informando o nome do campo, e o file obtido no _classpath_ anteriormente. Por fim, instanciamos um _FileUpload_ recebendo o _MultipartFile_ criado. Na etapa de **ação**, invocamos o serviço de storage e realizamos o upload do arquivo, recebendo como retorno a sua chave de acesso. já na etapa de **validação**, podemos validar se o arquivo realmente existe no bucket, garantindo assim o comportamento esperado nosso serviço, através da validação do efeito colateral.  Você pode ter mais detalhes do código neste [repositório](https://github.com/JordiHOFC/sample-storage-file-with-spring-aws-s3).

## Conclusão

Durante este artigo podemos observar que existem diversas estratégias para escrita de testes em um sistema que realizar o upload de arquivos em um bucket do S3. E que ao fazer escolha de uma estratégia de testes, devemos avaliar antes, as limitações que o ambiente nós trazemos, o nível de competência da equipe com as ferramentas e até mesmo o tempo disponível para uma determinada entrega. 

Também foi discutido como os testes unitários favorecem uma cobertura de características relacionadas a estrutura do código, e são ótimos validadores da escrita de lógicas que se baseiam em estruturas de decisão, controle, repetição. Porém, são péssimos candidatos a cobertura de características que estejam relacionadas as operações de I/O, já que por natureza todo teste de unidade é rodado em memória e normalmente possuem alto uso de mocks, que evitam o comportamento real do sistema.

Sendo assim, uma estratégia interessante para contexto onde a maioria das operações são de I/O é o uso de testes de integração, já que os mesmos, se aproximam do cenário de produção, permitindo a antecipação de erros e bugs, que poderiam ser descobertos nas etapas de CI e CD e pelo usuário final do sistema.

Agora me diz você, qual estratégia faz mais sentido em seu contexto? Deixe nos comentários.

Não se esqueça de me seguir nas redes sociais para receber mais dicas sobre Engenharia de Software e Desenvolvimento Backend.

[Linkedin](https://www.linkedin.com/in/jordihofc)
[Twitter](https://twitter.com/JordihSilva)
[Instagram](https://www.instagram.com/silvah.jordi/)