Criando Aplicações Testáveis com MicroProfile e CDI

No desenvolvimento de software, escrever código de teste é tão importante quanto escrever o código da aplicação em si. Uma aplicação sem testes rapidamente irá se transformar em um pesadelo para os mantenedores, pois a cada alteração necessária, por mais inofensiva que pareça ser, algo poderá parar de funcionar como deveria e, em muitas vezes, o problema somente será percebido quando a aplicação já estiver em produção. Além disso, haverá um significante ganho de produtividade ao executar milhares de testes em poucos segundos. E ao contrário do que pode parecer, algumas empresas de grande porte ainda testam manualmente suas aplicações.

Para que seja possível executar esses testes automaticamente, o código tem que estar preparado e um dos recursos que pode ser usado é a Injeção de Dependência. Neste tutorial, os desenvolvedores aprenderão a criar uma aplicação usando MicroProfile e a especificação JSR 365: Contexts and Dependency Injection for Java™ 2.0 (CDI) para escrever e manter um código testável e desacoplado.

A Aplicação

A aplicação consiste em um microsserviço que retorna o lucro de uma carteira de ações. A implementação do MicroProfile que será usada nesse exemplo é o Quarkus.

O código fonte da aplicação está no GitHub. No repositório existem dois diretórios: “initial”, que é a aplicação já funcionando, mas sem a refatoração necessária para o teste, e “complete”, que é o resultado final que teremos após a refatoração.

Para escrever a aplicação do zero, o seguinte comando pode ser usado:

mvn io.quarkus:quarkus-maven-plugin:1.7.0.Final:create -DprojectGroupId=com.hbelmiro -DprojectArtifactId=microprofile-cdi -DclassName="com.hbelmiro.microprofile.cdi.ProfitResource" -Dpath="/profit"

Então vamos criar uma classe chamada Portfolio que fará o cálculo do lucro da nossa carteira de ações. Para isso, serão necessários dois outros serviços: um para nos fornecer as cotações atuais das nossas ações (StocksService) e outro para nos fornecer nossa posição atual – a quantidade que temos de cada ação – (PositionsLoader).

package com.hbelmiro.microprofile.cdi;

import java.math.BigDecimal;

public class Portfolio {

    private final StocksService stocksService = new StocksService();

    private final PositionsLoader positionsLoader = new PositionsLoader();

    public BigDecimal computePortfolioProfit() {
        return this.positionsLoader.load().stream()
                                    .map(this::computePositionProfit)
                                    .reduce(BigDecimal::add)
                                    .orElse(BigDecimal.ZERO);
    }

    private BigDecimal computePositionProfit(Position position) {
        return this.stocksService.getCurrentValue(position.getTicker())
                                    .subtract(position.getAveragePrice())
                                    .multiply(position.getQuantity());
    }

}    

Para simular a oscilação das cotações e quantidades de ações da carteira, vamos implementar os serviços StocksService e PositionsLoader retornando valores aleatórios a cada chamada.

package com.hbelmiro.microprofile.cdi;

import java.math.BigDecimal;
import java.util.Random;

public class StocksService {

    public BigDecimal getCurrentValue(String ticker) {
        return BigDecimal.valueOf(new Random().nextInt(5000));
    }

}    
package com.hbelmiro.microprofile.cdi;

import java.math.BigDecimal;
import java.util.List;
import java.util.Random;

public class PositionsLoader {

    private static final Random RANDOM = new Random();

    public List<Position> load() {
        return List.of(
                new Position("AAPL", nextRandomNumber(), nextRandomNumber()),
                new Position("GOOG", nextRandomNumber(), nextRandomNumber()),
                new Position("AMZN", nextRandomNumber(), nextRandomNumber())
        );
    }

    private BigDecimal nextRandomNumber() {
        return BigDecimal.valueOf(RANDOM.nextInt(5000));
    }

} 
package com.hbelmiro.microprofile.cdi;

import java.math.BigDecimal;

public final class Position {

    private final String ticker;

    private final BigDecimal quantity;

    private final BigDecimal averagePrice;

    public Position(String ticker, BigDecimal quantity, BigDecimal averagePrice) {
        this.ticker = ticker;
        this.quantity = quantity;
        this.averagePrice = averagePrice;
    }

    public String getTicker() {
        return this.ticker;
    }

    public BigDecimal getQuantity() {
        return this.quantity;
    }

    public BigDecimal getAveragePrice() {
        return this.averagePrice;
    }

}    

Vamos então implementar nosso endpoint:

package com.hbelmiro.microprofile.cdi;

import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;

@Path("/profit")
@Produces(MediaType.TEXT_PLAIN)
public class ProfitResource {

    private final Portfolio portfolio = new Portfolio();

    @GET
    public String get() {
        return "Current profit is: " + this.portfolio.computePortfolioProfit();
    }

}    

Isso é tudo que precisamos para ter nosso microsserviço funcionando. Então vamos executá-lo usando o seguinte comando:

./mvnw compile quarkus:dev

Se você ver uma saída semelhante a seguinte, nossa aplicação está pronta para receber requisições.

__  ____  __  _____   ___  __ ____  ______ 
--/ __ \/ / / / _ | / _ \/ //_/ / / / __/ 
-/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \   
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/   
2020-08-19 21:07:14,601 INFO  [io.quarkus] (Quarkus Main Thread) microprofile-cdi 1.0-SNAPSHOT on JVM (powered by Quarkus 1.7.0.Final) started in 0.963s. Listening on: http://0.0.0.0:8080
2020-08-19 21:07:14,621 INFO  [io.quarkus] (Quarkus Main Thread) Profile dev activated. Live Coding activated.
2020-08-19 21:07:14,621 INFO  [io.quarkus] (Quarkus Main Thread) Installed features: [cdi, resteasy]

Podemos ver o lucro da nossa carteira de ações abrindo o endereço http://0.0.0.0:8080/profit no navegador ou usando o seguinte comando:

curl http://localhost:8080/profit

Note que a cada execução, o valor do lucro retornado é diferente.

Lucro retornado mudando a cada requisição

Mas como vamos saber se nosso cálculo está correto? Nesse caso, não podemos sequer testar manualmente, pois os valores das ações e a quantidade delas na nossa carteira são aleatórios.

Precisamos então criar um teste para a classe Portfolio, mas note que a classe depende de duas outras classes para realizar o cálculo do lucro. Essas outras duas classes são StocksService e PositionsLoader. Temos então que fazer com que essas duas dependências retornem sempre o mesmo valor para que consigamos dizer qual seria o lucro esperado a ser retornado pelo cálculo. Vamos fazer isso usando um padrão chamado Injeção de Dependência.

Injetando Dependências na Classe Portfolio

Vamos alterar a classe Portfolio para que ela não seja mais a responsável por criar suas dependências StocksService e PositionsLoader. Para isso, usaremos a JSR 365: CDI 2.0 que é uma das especificações do MicroProfile. Com ela, podemos anotar nossas dependências com @Inject e o Container de Injeção de Dependência se encarregará de fornecer uma instância. Com isso, poderemos pedir para o Container injetar instâncias que retornam sempre os mesmos valores no nosso teste. Além disso, nossa classe Portfolio ficará encarregada de fazer somente o que é de seu interesse, não tendo que saber como uma instância de StocksService ou PositionsLoader é criada.

Podemos fazer isso de duas formas: anotando com @Inject os atributos e removendo sua inicialização, ou anotando o construtor e recebendo as instâncias como argumento. Nesse caso, vamos usar a segunda opção. Nossa classe Portfolio ficará da seguinte forma:

package com.hbelmiro.microprofile.cdi;

import javax.inject.Inject;
import java.math.BigDecimal;

public class Portfolio {

    private final StocksService stocksService;

    private final PositionsLoader positionsLoader;

    @Inject
    public Portfolio(StocksService stocksService, PositionsLoader positionsLoader) {
        this.stocksService = stocksService;
        this.positionsLoader = positionsLoader;
    }

    public BigDecimal computePortfolioProfit() {
        return this.positionsLoader.load().stream()
                                    .map(this::computePositionProfit)
                                    .reduce(BigDecimal::add)
                                    .orElse(BigDecimal.ZERO);
    }

    private BigDecimal computePositionProfit(Position position) {
        return this.stocksService.getCurrentValue(position.getTicker())
                                    .subtract(position.getAveragePrice())
                                    .multiply(position.getQuantity());
    }

}    

Note que com isso, nossa classe ProfitResource ficou com um erro de compilação, já que ela criava uma instância de Portfolio usando o construtor default, que não existe mais. Essa é uma das desvantagens de uma classe saber como instanciar suas dependências. Agora que alteramos o construtor de Portfolio, teremos que alterar também todas as classes que usam esse construtor, o que não aconteceria se já estivéssemos usando a Injeção de Dependência.

Vamos alterar a classe ProfitResource para que também tenha suas dependências injetadas automaticamente. Para isso, removeremos a inicialização do atributo portfolio o anotaremos com @Inject. Note que agora não vamos receber a instância pelo construtor e o atributo não poderá mais ser final. Caso isso seja um problema, temos a alternativa de fazer a injeção usando o construtor, assim como fizemos na classe Portfolio. São duas formas diferentes de se injetar dependências, cada uma com suas vantagens e desvantagens.

package com.hbelmiro.microprofile.cdi;

import javax.inject.Inject;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;

@Path("/profit")
@Produces(MediaType.TEXT_PLAIN)
public class ProfitResource {

    @Inject
    Portfolio portfolio;

    @GET
    public String get() {
        return "Current profit is: " + this.portfolio.computePortfolioProfit();
    }

}

Repare também que o atributo deixou de ser private. Isso foi feito porque estamos usando o Quarkus como implementação do MicroProfile e o uso de reflection é uma limitação se quisermos usar imagens nativas. Você pode ler mais sobre isso na documentação do Quarkus.

A próxima coisa a ser feita é a definição do escopo de nossas dependências. Quando dizemos que um objeto deve ser injetado, temos que informar qual é o contexto em que esse objeto deve existir. Os escopos fornecidos pela especificação são os seguintes:

No nosso caso, as instâncias existirão no contexto da aplicação, ou seja, uma única instância do objeto será compartilhada em todo o ciclo de vida da aplicação, semelhante ao padrão de projeto Singleton. Vamos então anotar com @ApplicationScoped as classes ProfitResource, Portfolio, StocksService e PositionsLoader. Ao acessarmos novamente nosso endpoint, podemos ver que ele continua funcionando e agora sem que as nossas dependências tenham sido instanciadas manualmente.

Testando a Classe Portfolio

Agora que já alteramos nossas classes para que tenham suas dependências injetadas automaticamente, vamos criar a seguinte classe de teste:

package com.hbelmiro.microprofile.cdi;

import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;

import javax.inject.Inject;
import java.math.BigDecimal;

import static org.junit.jupiter.api.Assertions.assertEquals;

@QuarkusTest
class PortfolioTest {

    @Inject
    Portfolio portfolio;

    @Test
    void computePortfolioProfit() {
        assertEquals(BigDecimal.valueOf(520), portfolio.computePortfolioProfit());
    }

}

Se executarmos o teste, obviamente ele vai falhar, pois a cada execução o lucro retornado é diferente.

org.opentest4j.AssertionFailedError: 
Expected :520
Actual   :1235796

O que faremos então é pedir ao Container de Injeção de Dependência que injete serviços que retornem sempre os mesmos valores. Mas nossa classe Portfolio ainda tem um problema, ela depende diretamente de StocksService e PositionsLoader, que são implementações. Então vamos alterar a classe Portfolio para que dependa de interfaces ao invés de depender de implementações. Assim, podemos injetar qualquer serviço, desde que esse serviço implemente as interfaces das quais a classe Portfolio depende.

Então vamos alterar nossas classes StocksService e PositionsLoader para que sejam interfaces e mover as implementações para novas classes que implementem essas novas interfaces. Nossas novas classes e interfaces devem ficar como as seguintes:

package com.hbelmiro.microprofile.cdi;

import java.util.List;

public interface PositionsLoader {

    List<Position> load();

}
package com.hbelmiro.microprofile.cdi;

import javax.enterprise.context.ApplicationScoped;
import java.math.BigDecimal;
import java.util.List;
import java.util.Random;

@ApplicationScoped
public class PositionsLoaderImpl implements PositionsLoader {

    private static final Random RANDOM = new Random();

    @Override
    public List<Position> load() {
        return List.of(
                new Position("AAPL", nextRandomNumber(), nextRandomNumber()),
                new Position("GOOG", nextRandomNumber(), nextRandomNumber()),
                new Position("AMZN", nextRandomNumber(), nextRandomNumber())
        );
    }

    private BigDecimal nextRandomNumber() {
        return BigDecimal.valueOf(RANDOM.nextInt(5000));
    }

}    
package com.hbelmiro.microprofile.cdi;

import java.math.BigDecimal;

public interface StocksService {

    BigDecimal getCurrentValue(String ticker);

}    
package com.hbelmiro.microprofile.cdi;

import javax.enterprise.context.ApplicationScoped;
import java.math.BigDecimal;
import java.util.Random;

@ApplicationScoped
public class StocksServiceImpl implements StocksService {

    @Override
    public BigDecimal getCurrentValue(String ticker) {
        return BigDecimal.valueOf(new Random().nextInt(5000));
    }

}    

Agora nossa classe Portfolio não está mais acoplada a dependências concretas. Vamos então criar implementações de StocksService e PositionsLoader no módulo de testes que retornam sempre os mesmos valores. Assim poderemos injetá-las em Portfolio e saberemos qual será o lucro que deverá ser retornado.

package com.hbelmiro.microprofile.cdi;

import javax.enterprise.context.ApplicationScoped;
import java.math.BigDecimal;

@ApplicationScoped
public class FakeStocksService implements StocksService {

    @Override
    public BigDecimal getCurrentValue(String ticker) {
        return BigDecimal.valueOf(100);
    }

}    
package com.hbelmiro.microprofile.cdi;

import javax.enterprise.context.ApplicationScoped;
import java.math.BigDecimal;
import java.util.List;

@ApplicationScoped
public class FakePositionsLoader implements PositionsLoader {

    @Override
    public List<Position> load() {
        return List.of(
                new Position("AAPL", BigDecimal.TEN, BigDecimal.valueOf(60)),
                new Position("GOOG", BigDecimal.valueOf(3), BigDecimal.valueOf(110)),
                new Position("AMZN", BigDecimal.valueOf(2), BigDecimal.valueOf(25))
        );
    }

}    

Agora você deve estar se perguntando: Como o Container de Injeção de Dependência vai saber qual implementação deve ser injetada em Portfolio? Se executarmos nosso teste, é exatamente esse o motivo pelo qual ele irá falhar.

java.lang.RuntimeException: java.lang.RuntimeException: io.quarkus.builder.BuildException: Build failure: Build failed due to errors
    [error]: Build step io.quarkus.arc.deployment.ArcProcessor#validate threw an exception: javax.enterprise.inject.spi.DeploymentException: Found 2 deployment problems: 
[1] Ambiguous dependencies for type com.hbelmiro.microprofile.cdi.StocksService and qualifiers [@Default]
    - java member: com.hbelmiro.microprofile.cdi.Portfolio#()
    - declared on CLASS bean [types=[com.hbelmiro.microprofile.cdi.Portfolio, java.lang.Object], qualifiers=[@Default, @Any], target=com.hbelmiro.microprofile.cdi.Portfolio]
    - available beans:
        - CLASS bean [types=[com.hbelmiro.microprofile.cdi.FakeStocksService, com.hbelmiro.microprofile.cdi.StocksService, java.lang.Object], qualifiers=[@Default, @Any], target=com.hbelmiro.microprofile.cdi.FakeStocksService]
        - CLASS bean [types=[com.hbelmiro.microprofile.cdi.StocksServiceImpl, com.hbelmiro.microprofile.cdi.StocksService, java.lang.Object], qualifiers=[@Default, @Any], target=com.hbelmiro.microprofile.cdi.StocksServiceImpl]
[2] Ambiguous dependencies for type com.hbelmiro.microprofile.cdi.PositionsLoader and qualifiers [@Default]
    - java member: com.hbelmiro.microprofile.cdi.Portfolio#()
    - declared on CLASS bean [types=[com.hbelmiro.microprofile.cdi.Portfolio, java.lang.Object], qualifiers=[@Default, @Any], target=com.hbelmiro.microprofile.cdi.Portfolio]
    - available beans:
        - CLASS bean [types=[com.hbelmiro.microprofile.cdi.FakePositionsLoader, com.hbelmiro.microprofile.cdi.PositionsLoader, java.lang.Object], qualifiers=[@Default, @Any], target=com.hbelmiro.microprofile.cdi.FakePositionsLoader]
        - CLASS bean [types=[com.hbelmiro.microprofile.cdi.PositionsLoaderImpl, com.hbelmiro.microprofile.cdi.PositionsLoader, java.lang.Object], qualifiers=[@Default, @Any], target=com.hbelmiro.microprofile.cdi.PositionsLoaderImpl]

Temos dependências ambíguas para StocksService e PositionsLoader. Para cada uma delas, temos duas classes que podem ser injetadas. Então precisamos especificar qual implementação deve ser usada em nosso teste. Conseguimos fazer isso anotando nossas dependências com qualificadores, que são anotações criadas pelo desenvolvedor. Essas anotações devem ser anotadas por @Qualifier. Vamos então criar nosso qualificador, que chamaremos de TestMode.

package com.hbelmiro.microprofile.cdi;

import javax.inject.Qualifier;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Qualifier
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.TYPE})
public @interface TestMode {
}    

A seguir anotamos com @TestMode as implementações que queremos usar no nosso teste.

package com.hbelmiro.microprofile.cdi;

import javax.enterprise.context.ApplicationScoped;
import java.math.BigDecimal;

@ApplicationScoped
@TestMode
public class FakeStocksService implements StocksService {

    @Override
    public BigDecimal getCurrentValue(String ticker) {
        return BigDecimal.valueOf(100);
    }

}    
package com.hbelmiro.microprofile.cdi;

import javax.enterprise.context.ApplicationScoped;
import java.math.BigDecimal;
import java.util.List;

@ApplicationScoped
@TestMode
public class FakePositionsLoader implements PositionsLoader {

    @Override
    public List<Position> load() {
        return List.of(
                new Position("AAPL", BigDecimal.TEN, BigDecimal.valueOf(60)),
                new Position("GOOG", BigDecimal.valueOf(3), BigDecimal.valueOf(110)),
                new Position("AMZN", BigDecimal.valueOf(2), BigDecimal.valueOf(25))
        );
    }

}    

Também anotamos com @TestMode o atributo no nosso teste.

package com.hbelmiro.microprofile.cdi;

import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;

import javax.inject.Inject;
import java.math.BigDecimal;

import static org.junit.jupiter.api.Assertions.assertEquals;

@QuarkusTest
class PortfolioTest {

    @Inject
    @TestMode
    Portfolio portfolio;

    @Test
    void computePortfolioProfit() {
        assertEquals(BigDecimal.valueOf(520), portfolio.computePortfolioProfit());
    }

}

E por fim, criamos um produtor de Portfolio que será usado pelo Container de Injeção de Dependência para criar instâncias para dependências anotadas com @TestMode. Esse produtor usará dependências também anotadas com @TestMode para criar uma instância de Portfolio.

package com.hbelmiro.microprofile.cdi;

import javax.enterprise.inject.Produces;
import javax.inject.Inject;

public class TestModePortfolioFactory {

    @Inject
    @TestMode
    StocksService fakeStocksService;

    @Inject
    @TestMode
    PositionsLoader fakePositionsLoader;

    @Produces
    @TestMode
    public Portfolio createPortfolio() {
        return new Portfolio(this.fakeStocksService, this.fakePositionsLoader);
    }

}    

Se executarmos nosso teste agora, vamos ver que ele vai passar.

Conclusão

Na era dos microsserviços, o mercado está mudando cada vez mais rápido, exigindo agilidade de nossos sistemas para atender a essas mudanças em um tempo hábil. Isso requer que nossas aplicações sejam testadas rapidamente, com eficiência e os testes devem ser confiáveis. Os desenvolvedores precisam reduzir o acoplamento entre as classes da aplicação e a Injeção de Dependência permite alcançar esse baixo acoplamento. Um dos recursos que o MicroProfile nos fornece de forma agnóstica é justamente esse, através da JSR 365.

Se quiser saber mais sobre a JSR 365, você pode ler a documentação da especificação. Além do que vimos nesse post, a especificação fornece outros recursos interessantes como Decoradores, Interceptadores e Eventos.

Vale lembrar que a JSR 365 também faz parte do Jakarta® EE, então os conceitos que foram abordados nessa postagem também servem para aplicações que usam o Jakarta® EE.

Deixe uma resposta

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *