Reflexões sobre testes corporativos eficazes

Olá Habr!

Recentemente, voltamos a um estudo aprofundado do tópico de teste e, nos planos previsíveis, temos até um excelente livro sobre Teste de Unidade. Ao mesmo tempo, acreditamos que o contexto é importante neste tópico e em nenhum outro lugar; portanto, hoje oferecemos uma tradução de duas publicações (combinadas em uma) publicadas no blog de um destacado especialista em Java EE, Sebastian Dashner - ou seja, 1/6 e 2/6 da série " Pensamentos sobre testes empresariais eficientes ".

O teste corporativo é um tópico que ainda não foi examinado com tantos detalhes quanto gostaríamos. Leva muito tempo e esforço para escrever e, especialmente, para suportar testes, no entanto, tentar economizar tempo abandonando os testes não é uma opção. Quais volumes de tarefas, abordagens e tecnologias de teste valem a pena explorar para aumentar a eficácia dos testes?

Introdução


Independentemente dos diferentes tipos de testes e de seu escopo, o objetivo de preparar um conjunto de testes é garantir neste material que, em produção, nosso aplicativo funcionará exatamente como o esperado. Essa motivação deve ser a principal quando se verifica se o sistema cumpre a tarefa, se considerarmos esse sistema do ponto de vista do usuário.

Como o tempo de atenção e a alternância de contexto são coisas a serem consideradas, devemos garantir que nossos testes não sejam executados e testados por um longo tempo e que os resultados sejam previsíveis. Ao escrever um código, a verificação rápida do código (possível em um segundo) é crucial - isso garante alta produtividade e foco durante o trabalho.
Por outro lado, devemos garantir o suporte ao teste. O software muda com muita frequência e, com uma cobertura substancial do código com testes funcionais, cada alteração funcional no código de produção exigirá uma alteração no nível do teste. Idealmente, o código de teste deve mudar apenas quando a funcionalidade, ou seja, lógica de negócios, mudar, e não ao limpar código e refatoração desnecessários. Em geral, os cenários de teste devem incluir a possibilidade de alterações estruturais não funcionais.

Quando consideramos as diferentes áreas de aplicação dos testes, surge a pergunta: quais dessas áreas valem tempo e esforço? Por exemplo, em aplicativos de microsserviço, bem como em qualquer sistema que forneça trabalho significativo na distribuição e integração de código, os testes de integração são especialmente importantes, ajudando a tatear os limites do sistema. Portanto, precisamos de uma maneira eficaz de testar todo o aplicativo como um todo durante o desenvolvimento local, mantendo o ambiente e a estrutura desse aplicativo na forma mais próxima possível da produção.

Princípios e Limitações


Independentemente das soluções que serão selecionadas, vamos definir os seguintes princípios e limitações para nosso conjunto de testes:

  • , . , . , , .
  • , , . , , , . , , .
  • - . , , .
  • , , . : « HTTP- gRPC, JSON - enterprise-, ..?”.
  • , , -. API, DSL .
  • « », , , , , , “dev” debug () , dev Quarkus', Telepresence, watch-and-deploy (« ») .
  • . , , , , . .
  • , , -, , , . , , , .


O teste de unidade verifica o comportamento de um único módulo, geralmente uma classe, enquanto todos os fatores externos que não estão relacionados à estrutura do módulo são ignorados ou simulados. Os testes de unidade devem verificar a lógica de negócios dos módulos individuais, sem verificar sua integração ou configuração.

Na minha experiência, a maioria dos desenvolvedores corporativos tem uma boa idéia de como os testes de unidade são compilados. Para impressionar, você pode ver este exemplo no meu projeto de teste de café .. Na maioria dos projetos, o JUnit é usado em combinação com o Mockito para simular dependências e, idealmente, com o AssertJ para definir com eficiência instruções legíveis. Eu sempre enfatizo que os testes de unidade podem ser executados sem extensões ou entradas especiais, ou seja, para fazer isso com o JUnit usual. A explicação é simples: é tudo sobre tempo de execução, porque precisamos da capacidade de executar centenas de testes em questão de milissegundos.

Como regra, os testes de unidade são executados com muita rapidez e é fácil montar conjuntos de testes complexos ou fluxos de trabalho especiais a partir deles, pois são simples de executar e não impõem restrições ao ciclo de vida do teste.

No entanto, quando você tem muitos testes de unidade simulando as dependências da classe testada, há uma desvantagem: eles estão intimamente relacionados à implementação (isso se aplica especialmente a classes e métodos), e é por isso que nosso código é difícil de refatorar. Em outras palavras, para cada ato de refatoração no código de produção, ele também requer alterações no código de teste. Na pior das hipóteses, os desenvolvedores começam a recusar parcialmente a refatoração, simplesmente porque é muito onerosa e a qualidade do código no projeto está diminuindo rapidamente. Idealmente, o desenvolvedor deve poder refatorar e reorganizar os elementos, desde que, por isso, não haja alterações no aplicativo que sejam perceptíveis para o usuário. Os testes de unidade nunca simplificam sempre o código de produção da refatoração.

Mas a experiência sugere que os testes de unidade são muito eficazes na verificação de códigos densamente preenchidos com lógica concisa ou descrevem a implementação de uma função específica, por exemplo, um algoritmo e, ao mesmo tempo, não interagem muito ativamente com outros componentes. Quanto menos complexo ou denso o código em uma classe específica, menor a sua complexidade ciclomática ou mais ele interage ativamente com outros componentes, menos testes de unidade eficazes serão ao testar essa classe. Especialmente nos casos com microsserviços, nos quais comparativamente pouca lógica de negócios está incluída, mas é fornecida uma ampla integração com sistemas externos, talvez haja pouca necessidade de usar testes de unidade em muitos. Em tais sistemas, os módulos individuais (com raras exceções) geralmente contêm pouca lógica especializada. Isso deve ser considerado ao decidiro que é mais apropriado para gastar tempo e esforço.

Testando situações de aplicativo


Para lidar com o problema de vincular fortemente os testes à implementação, você pode tentar uma abordagem ligeiramente diferente para expandir o escopo dos testes. No meu livro, escrevi sobre testes de componentes, porque não consegui encontrar um termo melhor; mas, em essência, neste caso, estamos falando sobre o teste de situações aplicadas.

Testes de situação de aplicativo são testes de integração que operam no nível do código, que não usam contêineres internos - eles são abandonados para acelerar o lançamento. Eles testam a lógica de negócios de componentes bem coordenados, que geralmente são usados ​​em um caso prático específico, desde o método de limite - e depois até todos os componentes associados a eles. A integração com sistemas externos, por exemplo, com bancos de dados, é imitada usando zombarias.

Construir esses cenários sem o uso de tecnologias mais avançadas que conectariam componentes automaticamente parece um grande trabalho. No entanto, definimos componentes de teste reutilizáveis, eles também são contrapartes de teste que estendem componentes simulando, conectando e adicionando configurações de teste; tudo isso é feito para minimizar a quantidade total de esforço necessária para a refatoração. O objetivo é criar as únicas responsabilidades que limitam o grau de influência das alterações em uma única classe (ou várias classes) no campo de teste. Realizando esse trabalho com o objetivo de reutilizá-lo, reduzimos a quantidade total de trabalho necessário, e essa estratégia é justificada quando o projeto cresce, mas cada componente requer apenas pequenos reparos e esse trabalho é rapidamente amortizado.

Para melhor imaginar tudo isso, suponha que estamos testando uma classe que descreve a ordem do café. Esta classe inclui duas outras classes: CoffeeShope OrderProcessor.



Classes de duplas de teste, CoffeeShopTestDoublee OrderProcessorTestDoubleeles *TDestão localizados na área de teste do projeto, onde eles herdam os componentes CoffeeShope OrderProcessorlocalizado na área principal do programa. Os colegas de teste podem definir a lógica de simulação e conexão necessária e potencialmente expandir a interface pública da classe usando os métodos de simulação necessários neste aplicativo ou métodos de verificação.

A seguir, mostra a classe dupla de teste para o componente CoffeeShop:

public class CoffeeShopTestDouble extends CoffeeShop {

    public CoffeeShopTestDouble(OrderProcessorTestDouble orderProcessorTestDouble) {
        entityManager = mock(EntityManager.class);
        orderProcessor = orderProcessorTestDouble;
    }

    public void verifyCreateOrder(Order order) {
        verify(entityManager).merge(order);
    }

    public void verifyProcessUnfinishedOrders() {
        verify(entityManager).createNamedQuery(Order.FIND_UNFINISHED, Order.class);
    }

    public void answerForUnfinishedOrders(List<Order> orders) {
        //     
    }
}

A classe dupla de teste pode acessar os campos e construtores da classe base do CoffeeShop para estabelecer dependências. Aqui, na forma de gêmeos de teste, também são usadas variantes de outros componentes, em particular, OrderProcessorTestDoubleelas são necessárias para chamar métodos adicionais de simulação ou verificação, que fazem parte do caso prático.

Classes de teste duplo são componentes reutilizáveis, cada um dos quais é escrito uma vez por escopo de cada projeto e, em seguida, é usado em muitos casos práticos:

class CoffeeShopTest {

    private CoffeeShopTestDouble coffeeShop;
    private OrderProcessorTestDouble orderProcessor;

    @BeforeEach
    void setUp() {
        orderProcessor = new OrderProcessorTestDouble();
        coffeeShop = new CoffeeShopTestDouble(orderProcessor);
    }

    @Test
    void testCreateOrder() {
        Order order = new Order();
        coffeeShop.createOrder(order);
        coffeeShop.verifyCreateOrder(order);
    }

    @Test
    void testProcessUnfinishedOrders() {
        List<Order> orders = Arrays.asList(...);
        coffeeShop.answerForUnfinishedOrders(orders);

        coffeeShop.processUnfinishedOrders();

        coffeeShop.verifyProcessUnfinishedOrders();
        orderProcessor.verifyProcessOrders(orders);
    }

}

O teste de componente verifica o caso específico da lógica de negócios que é chamada no ponto de entrada, neste caso CoffeeShop. Esses testes são obtidos de forma concisa e legível, uma vez que todas as conexões e simulações são realizadas em gêmeos de teste separados e, posteriormente, eles podem usar técnicas de triagem altamente especializadas, como verifyProcessOrders().

Como você pode ver, a classe de teste expande o escopo da classe de produção, permitindo instalar o mokee e usar métodos que verificam o comportamento. Apesar de parecer que a instalação de um sistema desse tipo exige muito esforço, esses custos são rapidamente amortizados se, no âmbito de todo o projeto, tivermos muitos casos práticos em que os componentes podem ser reutilizados. Quanto mais nosso projeto cresce, mais útil essa abordagem se torna - especialmente se você prestar atenção ao tempo necessário para concluir os testes. Todos os nossos casos de teste ainda são executados usando JUnit e, no menor tempo possível, são executados em centenas.

Esse é o principal benefício dessa abordagem: os testes de componentes são executados tão rapidamente quanto os testes de unidade regulares, no entanto, estimulam a refatoração do código de produção, pois é necessário fazer alterações em um único componente ou em apenas alguns componentes. Além disso, melhorando as contrapartes de teste com métodos expressivos de ajuste e verificação específicos para nossa área de assunto, aumentamos a legibilidade de nosso código, facilitamos seu uso e nos livramos do código estereotipado nos scripts de teste.

All Articles