DEV Community

Jordi Henrique Silva
Jordi Henrique Silva

Posted on

Evoluindo sua estratégia de Testes para FileUpload com Spring Test e Amazon S3

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.

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.


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");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

@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);
    }

}

Enter fullscreen mode Exit fullscreen mode

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:

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.

@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);
    }

}
Enter fullscreen mode Exit fullscreen mode

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.

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
Twitter
Instagram

Top comments (0)