Unidade III: TDD Avançado, Níveis de Teste e TDD na Programação Web

Instrutor Cristian Mello
Instrutor Cristian Mello

Tudo é feito de Mini Stones ("pedrinhas" ou "tijolinhos")

Algumas vezes vamos empacar nos testes e ficarmos um pouco perdidos. Devemo-nos lembrar de que cada etapa conquistada é um tijolinho e o seu correto posicionamento determina as bases do nosso código e profissionalismo.

Instrutor Cristian Mello

Bibliografia desta Unidade

  1. Valente, Marco Tulio de Oliveira. Engenharia de Software Moderna. Marco Tulio de Oliveira Valente, 2020.

  2. Freeman, Steve, and Nat Pryce. Growing Object-Oriented Software, Guided by Tests. Addison Wesley, 2010.

  3. Manual de Devops: como obter agilidade, confiabilidade e segurança em organizações tecnológicas. Alta Books, 2021.

Instrutor Cristian Mello

TDD Avançado

Instrutor Cristian Mello

Hands-on: Algoritmo de Ordenação

Prática acompanhada pelo instrutor.

Instrutor Cristian Mello

Hands-on: TAD de Pilha (Parte II)

Vamos começar a testar os seguintes comportamentos:

  • Dada uma capacidade , ao empilhar mais um elemento (), dispare um erro de transbordamento (Qual o melhor nome possível?);
  • A pilha precisa ter capacidade inicial para 10 elementos por padrão ()
  • Deve ser permitido criar uma pilha com capacidade inicial maior que o padrão ()
  • Dar capacidade de programação genérica (Java Generics)
Instrutor Cristian Mello

Freeman & Pryce (2010)

  • A publicação impactou toda a indústria de software comercial à época;
  • Turbinou o uso do TDD para grandes projetos, do início ao fim;
  • Teve observação dos autores do Agile (Kent Beck, Cunningham, Robert C. Martin, ...)
Instrutor Cristian Mello
Instrutor Cristian Mello

Os testes têm níveis e por onde começar

Vamos aos conceitos trazidos por Freeman e Pryce (2010).

  • Aceitação (Acceptance): também chamado de Functional, System ou Customer Tests
  • Integração (Integration)
  • Unidade (Unit)
Instrutor Cristian Mello

Teste de Aceitação (Acceptance)

Devemo-nos perguntar "O sistema inteiro funciona como o esperado?"

  • O teste de aceitação adota a visão do usuário utilizando o sistema
  • Uma nova feature (recurso): começamos a escrever um Teste de Aceitação
    • Se está falhando: o sistema ainda não entrega o que deveria
    • Deu "verdinho": cumprimos o critério de aceite. Recurso entregue.
  • Atua como um guia de escrita de código

Exemplo de Critério de Aceite: Um Cliente pode pagar o cartão de crédito em determinadas bandeiras. (Valente, 2022)

Instrutor Cristian Mello

Regra Principal do Teste de Aceitação: Sempre que possível, teste o sistema inteiro (início ao fim, end-to-end) sem chamar o código interno.

Isto é, devemos fazer como CAIXA PRETA. Testes de Caixa Preta significam que interagimos somente com a parte externa (outside), através de:

  • Simulações de componentes de Interface Gráfica
  • Chamando Web Services (via APIs)
  • Analisando a estrutura de documentos gerados (parsing)
Instrutor Cristian Mello

Como assim 'sem chamar o código interno'?

Devemos interagir com interfaces externas (como APIs)

Instrutor Cristian Mello

Um Teste de Aceitação deve exercitar tudo

  • Banco de Dados
  • API de terceiros
  • Servidor de Integração Contínua (Continuous Integration ou CI)

É o teste mais desafiador. Exige que tenhamos todos os componentes mais básicos primeiro. É comumente ignorado, elevando-se os riscos de regressão de funcionalidades.

Instrutor Cristian Mello

Como se parece?

Instrutor Cristian Mello
# language: pt

Funcionalidade: Como Cliente Eu gostaria de consultar produto para comprar
  Como um Cliente a procura de um produto
  Eu gostaria de saber se o produto está a venda
  De modo que Eu possa ser encorajado a comprá-lo.

  Criterios de Aceite:
    1. Procura por um produto por categoria
    2. Recebe um aviso ao procurar categoria indisponivel

  Fundo:
    Dados produtos cadastrados na loja

  Delineacao do Cenario: Procura por um produto por categoria
    Quando o cliente procurar por "<categoria>"
    Entao uma lista de produtos deve aparecer com produtos pertencentes a "<categoria>"
    E cada produto mostrado aparecera com "<nome>", "<quantidade>" e "<disponivelAVenda>"
Instrutor Cristian Mello

O Aspecto da Regressividade

É o simples fato de algo que funcionava e, depois de algumas alterações no sistema, deixou de funcionar. É onde mais nos aborrecemos enquanto usuários.

  • Testes de aceitação de features terminadas: indicadores de regressão caso ocorra;
Instrutor Cristian Mello

Sobre Integração Contínua (CI) e DevOps

  • Elemento fortemente defendido em DevOps, mas já estava presente em Extreme Programming (XP);
  • O que faz?
    1. Integra o código produzido por toda a equipe (Exemplo: após Merge de Pull Request);
    2. Compila e executa os testes (build and tests) "mergeados";
    3. Leva o código para Servidor de Delivery (Continuous Delivery);
    4. De Delivery vai para Deployment (Continuous Deployment)
    5. Entra em Operação.
Instrutor Cristian Mello

Teste de Integração (Integration)

Devemos nos perguntar: o nosso código funciona com um outro e este outro não podemos alterar?

Instrutor Cristian Mello

Integração é o ato de se juntar a nosso código com um outro que a equipe não pode alterar.

Podemos pegar nosso código e testar junto com os seguintes códigos externos:

  • Mapeador de Persistência: Jakarta Persistence (JPA);
  • Biblioteca de outro time que não podemos alterar.
  • ...
Instrutor Cristian Mello

Principal Propósito do Teste Integrado: verificar se abstrações construídas com o código dos outros irão funcionar como deveria.

Instrutor Cristian Mello

Ok, mas um Teste de Integração é muito parecido com o Aceitação, certo?

Não. Em pequenas aplicações, quase sempre não precisaremos de Teste Integrado. Um de Aceitação já bastaria. Entretanto, Testes de Aceitação são mais lentos para verificar problemas de configuração. Aqui poderia fazer mais sentido o uso de testes integrados mais rápidos de serem executados.

Instrutor Cristian Mello

Quando devemos escrever Teste de Integração?

  • Depende da cultura;
  • Tecnologias...
  • Mas por experiência minha, pode ser útil para testar queries complexas com JPA (RAW SQL) e aspectos de conexão, persistência, etc
    • Regras de geração de Chave Primária (Incremental, por UUID, ...)
    • Saber se o código de Service (Domínio) funciona bem com queries elaboradas
Instrutor Cristian Mello

Como se parece?

@Test 
@Sql(scripts = "insert-products.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) 
@Sql(scripts = "delete-products.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) 
void quantityByProductTypeTest() { 
    assertThat(stockService.getQuantityByProductType("Perfume")).isEqualTo(3L); 
}

class StockService {
    @Autowired
    private ProductRepository productRepository;

    public long getQuantityByProductType(String name){ 
        return productRepository.countByProductType_Name(name); 
    } 
}

interface ProductRepository extends JpaRepository<Product, UUID> {}

Fonte: Writing Integration Tests in Spring Boot App with JPA using JUnit5, Testcontainers and JPA Buddy

Instrutor Cristian Mello

Teste de Unidade (Unit)

Devemos nos perguntar: nossos objetos fazem a coisa que deveria ser feita? É fácil de trabalhar com eles?

Instrutor Cristian Mello

Como se parece?

class StackTest {
    @Test
    public canCreateEmptyStack() {
      var stack = new Stack();
      assertTrue(stack.isEmpty());
    }
}
Instrutor Cristian Mello
Instrutor Cristian Mello

Teste Unitário promove código bem projetado (well-designed)

  • Forçamos a classe a ser mais facilmente testada;
  • Dependências externas precisam ser substituídas;
  • Responsabilidades precisam ser claras;
Instrutor Cristian Mello

Teste Unitário alcança as 2 qualidades mais desejadas de um software: código fracamente acoplado e com alta coesão.

Instrutor Cristian Mello

Calma... O que é Acoplamento e Coesão?

São uma medida da facilidade com que conseguimos alterar o comportamento de um código. É uma medida aproximada.

Instrutor Cristian Mello

Acoplamento

Alto acoplamento: alterou uma parte do sistema, todas as restantes quebram. Alterações exigem reconstrução de todo o sistema.

Fraco acoplamento: alterou uma parte, poucas ou nenhuma parte do sistema precisa ser alterada.

Instrutor Cristian Mello

Coesão

Medida de responsabilidades de uma unidade (pode ser uma função ou classe)

Baixa Coesão: uma unidade que faz muita coisa que não deveria

Exemplos:

  1. Uma Classe que cuida do parse de Datas e de URLs ao mesmo tempo. O que uma coisa tem a ver com outra?

  2. Uma Classe que deveria processar URL mas só verifica partes de uma URL e não ela toda. Isso força a procura de outros processadores para complementar o trabalho que ela deveria fazer.

Instrutor Cristian Mello

Alta Coesão: uma unidade faz o que deveria e faz completamente

Instrutor Cristian Mello

Seja num projeto legado sem testes ou novo: Teste de Aceitação é quem manda

Devemos começar pelos Testes de Aceitação a cada nova Feature

  • Escreva um código de teste que falha;
  • Vai continuar falhando até que o sistema fique completo.
Instrutor Cristian Mello

Vão ficar falhando???

Calma. É assim mesmo. São testes que vão demorar a ser executados. Precisamos esperar pelos testes integrados e unitários.

Instrutor Cristian Mello

TDD aplicado a um projeto inteiro (sem exceções)

Só seguirmos a Golden Rule do TDD

Escreva um teste que irá falhar!

Instrutor Cristian Mello
Instrutor Cristian Mello

Por onde exatamente começar?

Freeman & Pryce apontam para iniciarmos pelos Testes de Aceitação.

Instrutor Cristian Mello

O que devemos fazer de fato em primeiro lugar ao escrever uma classe ou feature?

Escreva o Caso de Sucesso mais simples possível: aqui Freeman & Pryce pedem maior cuidado!

Instrutor Cristian Mello

Freeman & Pryce

  • Existe uma suposição popular para iniciarmos pelos Casos Degenerados

"The simplest thing that could possibly work" (Kent Beck)

  • Uncle Bob incentiva começarmos com Casos Degenerados (Clean Craftsmanship)

  • Entretanto, Freeman & Pryce afirmam que isso entrega pouco valor ao sistema. Não dão feedback interessante sobre a validade das ideias de negócio. Os Clientes ficam mais preocupados. Afeta a moral da equipe.

Instrutor Cristian Mello

Como Freeman & Pryce priorizam a Lista de Casos de Teste? Exemplos

1. Liste os casos de sucesso

  • Cliente compra produto na loja, recebe o produto;
  • Um cartão de atividade é criado, registrando a tarefa a ser feita.

2. Liste os casos de falha ou degenerados

  • Cliente compra um produto fora de estoque. A operação deve ser impedida.
  • Cartão de atividade com identificador inexistente é procurado no sistema. Deve dizer que não foi encontrado.
Instrutor Cristian Mello

Uma possível análise

A visão de Bob é útil para exercitar TDD e para criar bibliotecas, frameworks e etc (software de base).

A visão de Freeman & Pryce é útil para entregar valor em produtos de maior retorno comercial.

Entretanto, Anote os casos degenerados/falha num bloco de notas ou registre num Card de subtask. Caso esqueçamos, isso vai virar a futura Issue que o cliente vai descobrir.

Instrutor Cristian Mello

Não seria mais fácil começar pelos testes unitários?

É comum acharmos que implementar classes de Domínio (Modelo) poderia ser mais simples. Entretanto, Freeman & Pryce apontam que esta abordagem torna propensa a criação de código desnecessário. (Pg. 43)

Instrutor Cristian Mello
Instrutor Cristian Mello

Poderíamos iniciar pela modelagem UML?

  • É válido como compreensão inicial;
  • Entretanto, na maioria das vezes introduz excesso de classes e abstrações;
  • Prejuízo a performance em larga escala.
Instrutor Cristian Mello

Ilustração do problema: Criação do Jogo de Boliche em UML (Clean Craftsmanship, pg. 81)

center

Instrutor Cristian Mello

Por TDD: apenas 1 classe

center

Instrutor Cristian Mello

center

Instrutor Cristian Mello

Hands On: Exercitando Programação Web com TDD de ponta-a-ponta

1. Vamos praticar a criação de Teste de Aceitação com Cucumber em Java (boilerplate já criado)

2. Escrever Teste Integrado para testar Controllers e JPA

3. Escrever Teste Unitário para validar comportamentos de classes

Instrutor Cristian Mello

Lista de Casos de Teste

Sucesso

(1) Como Cliente, Eu gostaria de consultar produto para comprar.

Degenerado/Falha

(2) Como Cliente, Eu gostaria de receber um aviso de que, ao procurar um produto por categoria indisponível, Eu receba uma mensagem me notificando.

Instrutor Cristian Mello

Montando nosso Walking Skeleton

Vamos iniciar a fase da chamada ITERAÇÃO ZERO.

É uma etapa de infraestrutura. Segundo Freeman & Pryce, não devemos contar isso nas iterações comuns. Após a conclusão da iteração zero, o projeto se inicia de fato.

Devemos procurar a arquitetura mais simples e ao mesmo tempo completa. Vai precisar de um Banco de Dados? Já o coloque. Vai precisar de uma API REST? Já a coloque.

Instrutor Cristian Mello

Importante evitarmos o BDUF (Big Design Up Front)

Devemos evitar de construir classes, algoritmos e tomar decisões de arquitetura cedo demais (Freeman, pg. 35)

O Agile orienta descobrir os detalhes a medida que as iterações avançam, o software é entregue e o feedback real é tomado.

Instrutor Cristian Mello

O Caso (1) vai atuar como um orientador para o primeiro teste. O Walking Skeleton será construído para atendê-lo.

Instrutor Cristian Mello

Arquitetura Mínima (PlantUML)

skinparam actorStyle awesome

actor User
interface API
database "H2 Database" {
  [Product]
}

API - [Web Application]
[Web Application] - [Product]
User <-> API
Instrutor Cristian Mello
Instrutor Cristian Mello

Infraestrutura mais básica possível

  • JDK 21
  • Spring Boot 3
  • Banco de Dados H2 (SQL, em memória RAM)
  • Framework de Teste Integrado e Unitário: JUnit 5
  • Biblioteca de Asserções: AssertJ
  • Framework de Teste de Aceitação: Cucumber

Chamamos Teste de Aceitação como UAT - User Acceptance Test também.

Instrutor Cristian Mello

Checklist

  • Faça clone do projeto
  • Aprender a executar 3 tipos de teste: e2e, it e ut
  • Visualizar um relatório de Integração Contínua (CI: GitHub Actions)
  • (1) Como Cliente, Eu gostaria de consultar um produto para comprar (Walking Skeleton)
  • (2) Como Cliente, Eu gostaria de receber um aviso de que, ao procurar um produto por categoria indisponível, Eu receba uma mensagem me notificando.
Instrutor Cristian Mello

Clonando

git clone https://github.com/xpian94/forkstore.git
Instrutor Cristian Mello

Executando todos os testes

mvn clean verify -P all-tests

Executando todos os testes unitários (ut) e integrados (it) somente

mvn clean verify # ignora e2e

Executando todos os testes integrados (it) somente

mvn clean verify -DskipTests # ignora e2e e ut, sobrando it
Instrutor Cristian Mello

Executando todos os testes unitários (ut) somente

mvn clean verify -DskipITs # ignora e2e e it, sobrando ut

Executando todos os testes de aceitação (e2e) somente

Aconselho a evitar este tipo de comando. Um e2e deve exercitar
todos os testes também. Algumas IDEs oferecem uma maneira de executar somente os testes integrados por meio de plugins, devendo ser usado com muita cautela.

Instrutor Cristian Mello

Checklist

  • Faça clone do projeto
  • Aprender a executar 3 tipos de teste: e2e, it e ut
  • Visualizar um relatório de Integração Contínua (CI: GitHub Actions)
  • (1) Como Cliente, Eu gostaria de consultar produto para comprar. (Walking Skeleton)
  • (2) Como Cliente, Eu gostaria de receber um aviso de que, ao procurar um produto por categoria indisponível, Eu receba uma mensagem me notificando.
Instrutor Cristian Mello

Visualizar um relatório de Integração Contínua (CI: GitHub Actions)

Mais à frente será explicado em detalhes como funciona a prática de Integração Contínua.

Onde ver o relatório? Clique em Forkstore - GitHub Actions.

Instrutor Cristian Mello

A partir do repositório de exemplo, vamos fazer um commit vazio e dar um push para disparar o servidor de CI. Vamos vê-lo em ação.

Instrutor Cristian Mello
git commit --allow-empty -m "#trigger ci" # pode usar qualquer string
git push

Atenção: ao visitarmos o relatório gerado, vamos ver algo assim

Instrutor Cristian Mello

center

Está certo mesmo?! Não! É muito fácil sermos enganados por automações de verificação de cobertura e ferramentas de teste.

Instrutor Cristian Mello

Não temos nada e a ferramenta jacoco-badge-generator disse que está 100% coberto! Conclusão: desconfie um pouco de ferramentas!

center

Instrutor Cristian Mello

Checklist

  • Faça clone do projeto
  • Aprender a executar 3 tipos de teste: e2e, it e ut
  • Visualizar um relatório de Integração Contínua (CI: GitHub Actions)
  • (1) Como Cliente, Eu gostaria de consultar produto para comprar. (Walking Skeleton)
  • (2) Como Cliente, Eu gostaria de receber um aviso de que, ao procurar um produto por categoria indisponível, Eu receba uma mensagem me notificando.
Instrutor Cristian Mello

(1) Como Cliente Eu gostaria de consultar produto para comprar.

Abra o arquivo ClienteConsultaProdutoAComprar.feature (pasta test/resources/org/forkstore)

Funcionalidade: Como Cliente Eu gostaria de consultar produto para comprar
  Como um Cliente a procura de um produto
  Eu gostaria de saber se o produto esta a venda
  De modo que Eu possa ser encorajado a compra-lo.

  Criterios de Aceite:
    1. Procura por um produto por categoria
    2. Recebe um aviso ao procurar categoria indisponivel
Instrutor Cristian Mello

Por enquanto vamos ignorar

Fundo:
    Dados produtos cadastrados na loja
Instrutor Cristian Mello

Delineação do Cenário

Delineacao do Cenario: Procura por um produto por categoria
    Quando o cliente procurar por "<categoria>"
    Entao uma lista de produtos deve aparecer com produtos pertencentes a "<categoria>"
    E cada produto mostrado aparecera com "<nome>", "<quantidade>" e "<disponivelAVenda>"

    Exemplos:
    | categoria  | nome                   | quantidade | disponivelAVenda |
    | Utensilios | Kit 12 Facas           | 3          | Sim              |
    | Utensilios | Colher de Silicone     | 12         | Sim              |
    | Utensilios | Ralador 4 faces Inox   | 0          | Nao              |

Evite colocar acentuações em delineação de cenário e fundo.

Instrutor Cristian Mello

1. Quando o cliente procura por categoria

Deve receber uma lista de produtos correspondente à categoria

  • Visite a classe ProcuraProdutoPorCategoria.java no pacote org.forkstore.e2e
@Quando("o cliente procurar por {string}")
public void oClienteProcurarPor(String categoria) throws JsonProcessingException {
    var url = "http://localhost:%d/product?category=%s".formatted(port, categoria);

    response = restTemplate.getForObject(url, String.class);
    responseAsJsonNode = objectMapper.readTree(response);
}
Instrutor Cristian Mello
public class ProcuraProdutoPorCategoria {
    @Autowired
    private TestRestTemplate restTemplate;

    @LocalServerPort
    private int port;

    private String response;

    private final ObjectMapper objectMapper = new ObjectMapper();

    private JsonNode responseAsJsonNode;

    // ...
}
Instrutor Cristian Mello

2. Então uma lista de produtos deve aparecer com os produtos pertencentes a uma categoria

@Entao("uma lista de produtos deve aparecer com produtos pertencentes a {string}")
public void umaListaDeProdutosDeveAparecerComProdutosPertencentesA(String categoria) {
    var fieldValues = responseAsJsonNode.findValues("category");
    var fieldValuesAsList = fieldValues.stream().map(JsonNode::asText).toList();

    assertThat(fieldValues.size()).isEqualTo(3);
    assertThat(fieldValuesAsList).contains(categoria);
}
Instrutor Cristian Mello

Verificar se o teste acima funciona. Depois devemos retirar a implementação abaixo.

Crie uma classe chamada ProductController.java em main/java/org/forkstore (é o pacote org.forkstore)

Instrutor Cristian Mello
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;

@RestController
public class ProductController {
    @GetMapping(value = "/product", produces = MediaType.APPLICATION_JSON_VALUE)
    String searchByCategory(@RequestParam(name = "category") String category) {
        return """
            [
                {
                    "category": "Utensilios",
                    "name": "Kit 12 Facas"
                },
                {
                    "category": "Utensilios",
                    "name": "Colher de Silicone"
                },
                {
                    "category": "Utensilios",
                    "name": "Ralador 4 faces Inox"
                }
            ]
            """;
    }
}
Instrutor Cristian Mello

3. Refatorando o teste

@Entao("uma lista de produtos deve aparecer com produtos pertencentes a {string}")
public void umaListaDeProdutosDeveAparecerComProdutosPertencentesA(String categoria) {
    findByFieldNameAndValueAndVerify("category", categoria);
}
private void findByFieldNameAndValueAndVerify(String fieldName, String fieldValue) {
    var fieldValues = responseAsJsonNode.findValues(fieldName);
    var fieldValuesAsList = fieldValues.stream().map(JsonNode::asText).toList();

    assertThat(fieldValues.size()).isEqualTo(3);
    assertThat(fieldValuesAsList).contains(fieldValue);
}
Instrutor Cristian Mello

4. Implementando teste "E cada produto mostrado aparecerá com <nome>, <quantidade> e <disponivelAVenda>

@E("cada produto mostrado aparecera com {string}, {string} e {string}")
public void cadaProdutoMostradoApareceraComE(String nome, String quantidade, String disponivelAVenda) {
    findByFieldNameAndValueAndVerify("name", nome);
    findByFieldNameAndValueAndVerify("quantity", quantidade);
    findByFieldNameAndValueAndVerify("available", String.valueOf(disponivelAVenda.equals("Sim")));
}
Instrutor Cristian Mello
@GetMapping(value = "/product", produces = MediaType.APPLICATION_JSON_VALUE)
String searchByCategory(@RequestParam(name = "category") String category) {
    return """
        [
            {
                "category": "Utensilios",
                "name": "Kit 12 Facas",
                "quantity": 3,
                "available": true
            },
            {
                "category": "Utensilios",
                "name": "Colher de Silicone",
                "quantity": 12,
                "available": true
            },
            {
                "category": "Utensilios",
                "name": "Ralador 4 faces Inox",
                "quantity": 0,
                "available": false
            }
        ]
        """;
}
Instrutor Cristian Mello

Exercitando o Banco de Dados

Instrutor Cristian Mello

5. Simulação do cadastro de 3 produtos

A classe PreparacaoDaHistoria fica em org.forkstore.e2e

public class PreparacaoDaHistoria {
    @Dados("produtos cadastrados na loja")
    public void produtosCadastradosNaLoja() {
        var first = new Product();
        var second = new Product();
        var third = new Product();
    }
}
package org.forkstore;

public class Product { }
Instrutor Cristian Mello
public class PreparacaoDaHistoria {
    @Dados("produtos cadastrados na loja")
    public void produtosCadastradosNaLoja() {
        var first = new Product();
        var second = new Product();
        var third = new Product();

        first.setCategory("Utensilios");
        first.setName("Kit 12 Facas");
        first.setQuantity(3);
        first.setAvailable(true);
    }
}
Instrutor Cristian Mello
second.setCategory("Utensilios");
second.setName("Colher de Silicone");
second.setQuantity(12);
second.setAvailable(true);

third.setCategory("Utensilios");
third.setName("Ralador 4 faces Inox");
third.setQuantity(0);
third.setAvailable(false);
Instrutor Cristian Mello
public class PreparacaoDaHistoria {
    @Dados("produtos cadastrados na loja")
    public void produtosCadastradosNaLoja() {
        // ...
        repo.saveAll(List.of(first, second, third));
    }
}
public class PreparacaoDaHistoria {
    @Autowired
    private ProductRepository repo; 
    
    // ...
}
@Repository
public interface ProductRepository extends CrudRepository<Product, Long> {}
Instrutor Cristian Mello

Product deve ser um Entity

@Entity
public class Product {
    Long id;

    // ...
}

Teremos muitos erros ainda. Vamos corrigindo aos poucos.

Instrutor Cristian Mello
@Entity
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    Long id;

    // ...
}
Instrutor Cristian Mello

Uma classe como esta satisfaz a criação da massa de dados de testes

@Entity
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    Long id;

    public void setCategory(String category) {}

    public void setName(String name) {}

    public void setQuantity(int quantity) {}

    public void setAvailable(boolean available) {}
}
Instrutor Cristian Mello

Soa estranho... Não temos sequer os campos para o BD

Com setters vazios, não se faz nada! Mas tudo bem, vamos prosseguindo.

À frente, algum teste vai pedir uma implementação mais esperta.

Os testes devem passar.

Instrutor Cristian Mello

6. Refatorando o controller

@RestController
public class ProductController {
    private final ProductRepository repo;

    public ProductController(ProductRepository repo) {
        this.repo = repo;
    }

    @GetMapping(value = "/product", produces = MediaType.APPLICATION_JSON_VALUE)
    List<Product> searchByCategory(@RequestParam(name = "category") String category) {
        return (List<Product>) repo.findAll();
    }
}
Instrutor Cristian Mello

Resolvendo alguns problemas gerados pela refatoração

A refatoração aqui apontou que nossa fase de criação de dados de teste está falha.
Não tem problema, vamos consertá-la.

Instrutor Cristian Mello

Classe Product

Existe um recurso interessante de algumas IDEs chamado Multi-selection.
Podemos reaproveitar setters e transformar em campos. Deve ficar assim:

@Entity
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    Long id;

    String category;

    String name;

    Integer quantity;

    Boolean available;
}
Instrutor Cristian Mello

Resolve? Ainda não. Temos ainda a falta de setters. Solução rápida: Lombok Setters.

// ...
import lombok.Setter;

@Entity
@Setter
public class Product {
    // ...
}

Com isso, teremos setters sem precisar de utilizar recursos de IDE. O Lombok permite
gerar código no momento que o compilador gera a AST (Abstract Syntax Tree).

Instrutor Cristian Mello

Mesmo assim teremos falhas

org.opentest4j.AssertionFailedError: 
expected: 3
 but was: 0
Expected :3
Actual   :0

Solução? Adicione getters.

Instrutor Cristian Mello
// ...

@Entity
@Setter
@Getter
public class Product {
    // ...
}

Ainda teremos mais erros. O primeiro teste funciona, mas os outros 2 não.

Instrutor Cristian Mello

Num teste teremos isso

org.opentest4j.AssertionFailedError: 
expected: 3
 but was: 6
Expected :3
Actual   :6

Noutro, algo semelhante. O que pode ser?

org.opentest4j.AssertionFailedError: 
expected: 3
 but was: 9
Expected :3
Actual   :9
Instrutor Cristian Mello

Importante: Etapas como Fundo (Background)

Fundo:
    Dados produtos cadastrados na loja

Isso faz com que cada Exemplo de um cenário delineado tenha a execução do método
PreparacaoDaHistoria.produtosCadastradosNaLoja(). Isso vai poluindo a base de dados.

Instrutor Cristian Mello

Solução? Vamos limpar a base de dados com um recurso chamado Hooks

public class Hooks {
    @Autowired
    private ProductRepository repo;

    @After // @Before aqui tem o mesmo efeito
    public void cleanUp() {
        repo.deleteAll();
    }
}

After: quer dizer que o método cleanUp vai ser chamado depois do exemplo de teste de um cenário delineado tiver sido executado.

Instrutor Cristian Mello

Agora todos os testes passam. Vamos ver como vai ser o trabalho com um CI.

Instrutor Cristian Mello

7. Continuous Integration (CI) com GitHub Actions

  • O teste de aceitação inicial está passando. Vamos verificar se o código submetido ao GitHub consegue passar através de um servidor de integração contínua.
Instrutor Cristian Mello

Como funciona o GitHub Actions?

alt text

Instrutor Cristian Mello

Event = Evento que ocorre num repositório.

Exemplo: git push, pull request, ...

Instrutor Cristian Mello

Observa-se que no arquivo .github/.github_action.yml temos

name: CI
on:
  pull_request:
    branches:
      - 'feature/*'
      - 'release/*'
      - 'main'
  • Quer dizer, toda vez que fizermos um Pull Request com as branches de destino sendo feature/*, /release/* e main, algumas etapas serão executadas sequencialmente.
Instrutor Cristian Mello

Neste workflow de exemplo, temos 1 job que vai rodar uma sequência de comandos de terminal num Ubuntu Linux.

Workflow = Sequência de Jobs (tarefas automatizadas)

Job = Sequência de Tasks (conhecidas como Steps)

Step = Task = Coleção de comandos de shell, setup task ou action

Instrutor Cristian Mello

Runner = É um servidor que roda os workflows definidos na pasta .github/workflows. Definimos com a expressão runs-on relativa ao job.

Instrutor Cristian Mello
jobs:
  test: # test = job id
    runs-on: ubuntu-latest # vai rodar um Ubuntu Linux padrão
Instrutor Cristian Mello

center

Instrutor Cristian Mello

Primeira coisa a se fazer: pedir para o CI dar um git checkout na branch

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

uses: vai executar uma Action como parte da step. Logo, o checkout será feito logo no início da step. Se estiver fazendo um commit e push na branch ABC, é nesta branch que será feito o checkout.

Instrutor Cristian Mello

center

Instrutor Cristian Mello

Depois, pedir para o CI configurar o ambiente com as ferramentas para rodar nosso projeto em Java 21 com Maven

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      # ...
      - uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: '21'
          cache: 'maven'
Instrutor Cristian Mello

center

Instrutor Cristian Mello

Ao todo temos 6 steps. São

Step 1: CI Info

  • Para mostrar informações úteis como a branch em que nos encontramos.
- name: CI Info
  run: echo "${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}"
Instrutor Cristian Mello

center

Instrutor Cristian Mello

Step 2: Build and All Tests

- name: Build and All Tests
  run: mvn clean verify -P all-tests
Instrutor Cristian Mello

center

Instrutor Cristian Mello

center

Instrutor Cristian Mello

Step 3: Build Tests (Unit and Integration)

- name: Build Tests (Unit and Integration)
  if: startsWith(github.head_ref, 'feature')
  run:
      mvn clean verify -P ut-it
  • Se tiver numa branch de feature, chame somente os testes unitários e integrados

Step 4: Generate JaCoCo Badge

- name: Generate JaCoCo Badge
  id: jacoco
  uses: cicirello/jacoco-badge-generator@v2
  with:
    generate-branches-badge: true
  • Gera um arquivo .svg como
Instrutor Cristian Mello

center

Para incluir este badge acima no README, precisa-se incluir outra Step para commit (devido à controvérsia de bots, preferi omitir).

Instrutor Cristian Mello

Step 5: Log coverage percentage

- name: Log coverage percentage
  run: |
    echo "coverage = ${{ steps.jacoco.outputs.coverage }}"
    echo "branch coverage = ${{ steps.jacoco.outputs.branches }}"
Instrutor Cristian Mello

Step 6: Upload JaCoCo coverage report

- name: Upload JaCoCo coverage report
  uses: actions/upload-artifact@v2
  with:
    name: jacoco-report
    path: target/site/jacoco/

alt text

Instrutor Cristian Mello

Links úteis do GitHub Actions

Entendendo como funciona o GitHub Actions

Jobs e Workflow

Lista de Runners disponíveis

Instrutor Cristian Mello

Vemos no CI que todos os testes passam e que o primeiro teste de fato passa.

Instrutor Cristian Mello

Temos que cumprir a regra do Freeman & Pryce: um Teste de Aceitação deve falhar.

Vamos apagar o código de produção e subir para ver o CI falhando nas asserções.

Instrutor Cristian Mello

Ao subirmos precisamos ver isso no CI

Error:  Tests run: 5, Failures: 3, Errors: 0, Skipped: 0
Instrutor Cristian Mello

Revisando o Checklist

  • Faça clone do projeto
  • Aprender a executar 3 tipos de teste: e2e, it e ut
  • Visualizar um relatório de Integração Contínua (CI: GitHub Actions)
  • (1) Como Cliente, Eu gostaria de consultar produto para comprar.
    • Walking Skeleton exercitou API e Banco de Dados
  • (2) Como Cliente, Eu gostaria de receber um aviso de que, ao procurar um produto por categoria indisponível, Eu receba uma mensagem me notificando.
Instrutor Cristian Mello

Relembrando

center

Instrutor Cristian Mello

8. Vamos precisar criar um teste unitário. O que podemos testar?

Devemos nos perguntar: nossos objetos fazem a coisa que deveria ser feita? É fácil de trabalhar com eles?

Quais objetos podemos pensar? Do DDD (Domain-Driven Design), temos:

  • Entidades (camada de Domínio)
  • Caso de Uso (camada de Domínio)
  • Controllers (camada de Infraestrutura)
  • Repositories (camada de Persistência)

Os Casos de Uso oferecem maior facilidade. Podemos isolar os componentes externos mais facilmente através de Test Double (Dublê de Teste).

Instrutor Cristian Mello

Implementando um Caso de Uso através de um Service

Podemos utilizar uma classe de Service do Spring para implementar as regras de negócio de um Caso de Uso.

Alguns preferem ainda criar uma nova classe de Caso de Uso. Opto pela simplicidade para evitar proliferação de classes.

Instrutor Cristian Mello

Claro, vamos começar pelo teste de uma classe que ainda não existe

Instrutor Cristian Mello

Um código mínimo

É o coração da nossa intenção. Queremos chamar o caso de uso da procura de um produto por categoria.

public class ProductServiceTest {
    @Test
    void nothing() {
      var found = service.searchByCategory("category");
    }
}

Vamos resolvendo as falhas...

Instrutor Cristian Mello
public class ProductServiceTest {
    private ProductService service;
    // ...
}
public class ProductService {
    public List<Object> searchByCategory(String category) {
        return null;
    }
}

Teremos a falha

java.lang.NullPointerException: Cannot invoke ...because "this.service" is null

Solução: instanciação

Instrutor Cristian Mello
public class ProductServiceTest {
    private ProductService service = new ProductService();

    @Test
    void nothing() {
        var found = service.searchByCategory("category");
    }
}
Instrutor Cristian Mello

Vamos colocar a primeira asserção

public class ProductServiceTest {
    private ProductService service = new ProductService();

    @Test
    void nothing() {
        var found = service.searchByCategory("category");

        assertThat(found.size()).isEqualTo(1);
    }
}

Falha. Resolvendo...

Instrutor Cristian Mello
public class ProductService {
    public List<Object> searchByCategory(String category) {
        return List.of(new Object());
    }
}

Sucesso! BIZARRO?! Calma...

Instrutor Cristian Mello

Vamos verificar se, ao buscar por categoria, um repository é acionado para buscar os dados.

Instrutor Cristian Mello
@Test
void nothing() {
    var found = service.searchByCategory("category");

    verify(repository).findByCategory("category");

    assertThat(found.size()).isEqualTo(1);
}
public class ProductServiceTest {
    private ProductService service = new ProductService();

    private ProductRepository repository;

    // ...
}
public class ProductRepository {
    public void findByCategory(String category) {}
}
Instrutor Cristian Mello
org.mockito.exceptions.misusing.NullInsteadOfMockException: 
Argument passed to verify() should be a mock but is null!
Examples of correct verifications:
    verify(mock).someMethod();
    verify(mock, times(10)).someMethod();
    verify(mock, atLeastOnce()).someMethod();
    not: verify(mock.someMethod());
Also, if you use @Mock annotation don't miss openMocks()

A falha diz tudo. repository precisa ser um Mock instanciado. Vamos instanciar então...

Instrutor Cristian Mello
org.mockito.exceptions.misusing.NotAMockException: 
Argument passed to verify() is of type ProductRepository and is not a mock!
...

repository precisa ser um Mock!

Instrutor Cristian Mello
@ExtendWith(MockitoExtension.class) // Habilita injeção automática aos Mocks
public class ProductServiceTest {
    private ProductService service = new ProductService();

    @Mock
    private ProductRepository repository;

    @Test
    void nothing() {
        var found = service.searchByCategory("category");

        verify(repository).findByCategory("category");

        assertThat(found.size()).isEqualTo(1);
    }
}
Instrutor Cristian Mello

Problema-chave

Wanted but not invoked:
repository.findByCategory("category");
-> at org.forkstore.ProductRepository.findByCategory(ProductRepository.java:5)
Actually, there were zero interactions with this mock.

Podemos aprender a programar assim, analisando respostas das falhas. findByCategory nunca é chamado de fato.

Vamos fazer com que seja chamado forçadamente.

Instrutor Cristian Mello
public class ProductService {
    private ProductRepository repository;

    public List<Object> searchByCategory(String category) {
        repository.findByCategory(category);

        return List.of(new Object());
    }
}

Falha. Corrigindo.

public class ProductService {
    private ProductRepository repository = new ProductRepository();
    // ...
}

Vai falhar novamente.

Instrutor Cristian Mello
Wanted but not invoked:
repository.findByCategory("category");
-> at org.forkstore.ProductRepository.findByCategory(ProductRepository.java:5)
Actually, there were zero interactions with this mock.

E agora?

Instrutor Cristian Mello
@ExtendWith(MockitoExtension.class)
public class ProductServiceTest {
    @Mock
    private ProductRepository repository;

    private ProductService service = new ProductService(repository);

    // ...
}
public class ProductService {
    private ProductRepository repository;

    public ProductService(ProductRepository repository) {
        this.repository = repository;
    }

    // ...
}
Instrutor Cristian Mello
@ExtendWith(MockitoExtension.class)
public class ProductServiceTest {
    @Mock
    private ProductRepository repository;

    // Injeção de dependência para o objeto service receber referencia do mock de repository 
    @InjectMocks
    private ProductService service = new ProductService(repository);

    // ...
}

Ou de forma abreviada

@ExtendWith(MockitoExtension.class)
public class ProductServiceTest {
    // ...
    @InjectMocks
    private ProductService service;

    // ...
}
Instrutor Cristian Mello

Resolvido. Sabemos que findByCategory é chamado na regra de negócio.

No entanto, o código está nos enganando. Este retorno assim precisa mudar.

public List<Object> searchByCategory(String category) {
    repository.findByCategory(category);

    return List.of(new Object());
}

Como? Vamos deixar os testes ainda mais específicos para provocar maior generalidade.

Instrutor Cristian Mello

Uma nova categoria pode retornar 2 produtos.

@Test
void nothing() {
    var found = service.searchByCategory("category");

    verify(repository).findByCategory("category");

    assertThat(found.size()).isEqualTo(1);

    found = service.searchByCategory("other");

    verify(repository).findByCategory("other");

    assertThat(found.size()).isEqualTo(2);
}

Agora vamos ter a falha que precisamos.

Instrutor Cristian Mello

Qual solução?

  • A mais óbvia
public List<Object> searchByCategory(String category) {
    repository.findByCategory(category);

    if (category.equals("category")) return List.of(new Object());

    if (category.equals("other")) return List.of(new Object(), new Object());

    return List.of(new Object());
}

Resolve, mas cria um problema: os ifs estão gerando duplicação. Vamos refatorar e
procurar uma forma mais inteligente de remover a duplicação.

Instrutor Cristian Mello

Maneira singela: jogue a duplicação para longe do System Under Test

E como a regra de negócio só tem feito 1 ação, já colete o seu retorno. Se porventura precisarmos de adicionar novas regras, é só reescrever.

public List<Object> searchByCategory(String category) {
    return repository.findByCategory(category);
}
public void findByCategory(String category) {
    if (category.equals("category")) return List.of(new Object());

    if (category.equals("other")) return List.of(new Object(), new Object());

    return List.of(new Object());
}

Teremos problemas de compilação. Vamos ajustar a assinatura do método.

Instrutor Cristian Mello

Ajustando

public List<Object> findByCategory(String category) { /* ... */ }

Isso resolve a compilação. Entretanto, os testes falharam.

org.opentest4j.AssertionFailedError: 
expected: 1
 but was: 0
Expected :1
Actual   :0

Qual a razão?

Instrutor Cristian Mello

Um objeto "mockado" ou o ato de "mockar" em si é um tipo de interceptação ou "sequestro" da CPU.

@ExtendWith(MockitoExtension.class)
public class ProductServiceTest {
    @Mock
    private ProductRepository repository;

    @InjectMocks
    private ProductService service;

    @Test
    void nothing() {
        var found = service.searchByCategory("category");

        // ...
    }
}
Instrutor Cristian Mello

O que vai acontecer com a CPU?

  1. O objeto service vai receber a referência de um objeto anotado com @Mock caso a utilize. E realmente vai ser usado, pois o construtor de ProductService vai recebê-la.
public ProductService(ProductRepository repository) {
    this.repository = repository;
}
Instrutor Cristian Mello
  1. O campo repository anotado com @Mock indica que o objeto do tipo ProductRepository terá sua instanciação interceptada. Ela vai ser substituída por um outro objeto que vai atuar como um dublê.

No fim das contas, repository será um Doppelganger.

Como isso funciona exatamente? Estude um outro grande tópico chamado Java Reflection API. As frameworks de Mock a utilizam.

Instrutor Cristian Mello

center

Instrutor Cristian Mello

Esse dublê, por outro lado, não sabe o que vai imitar. Inicialmente, o objeto mockado apenas imita as assinaturas dos métodos da classe a ser imitada.

Como programadores, somos responsáveis pelo "Jogo da Imitação". Como não colocamos instruções de imitação, nosso código falha.

Instrutor Cristian Mello

Inserindo instruções de imitação, atuação ou stubbing

@Test
void nothing() {
    when(repository.findByCategory("category")).thenReturn(List.of(new Object()));
    when(repository.findByCategory("other")).thenReturn(List.of(new Object(), new Object()));

    var found = service.searchByCategory("category");

    // ...
}

Pronto, resolvido! Mas vamos fazer limpezas.

Instrutor Cristian Mello

Limpe os ifs que estavam aqui. Não são mais necessários.

public class ProductRepository {
    public List<Object> findByCategory(String category) {
        return null;
    }
}
Instrutor Cristian Mello

Isto aqui está uma bagunça. Vamos sair disto

@Test
void nothing() {
    when(repository.findByCategory("category")).thenReturn(List.of(new Object()));
    when(repository.findByCategory("other")).thenReturn(List.of(new Object(), new Object()));

    var found = service.searchByCategory("category");

    verify(repository).findByCategory("category");

    assertThat(found.size()).isEqualTo(1);

    found = service.searchByCategory("other");

    verify(repository).findByCategory("other");

    assertThat(found.size()).isEqualTo(2);
}
Instrutor Cristian Mello

Para isto

@Test
void nothing() {
    String category = "category";
    String other = "other";

    List<Object> productsFromCategory = List.of(new Object());
    List<Object> productsFromOther = List.of(new Object(), new Object());

    stubbingFindByCategory(category, productsFromCategory);
    stubbingFindByCategory(other, productsFromOther);

    searchByCategoryAndAssert("category", 1);
    searchByCategoryAndAssert("other", 2);
}
Instrutor Cristian Mello
private void stubbingFindByCategory(String category, List<Object> products) {
    when(repository.findByCategory(category)).thenReturn(products);
}

private void searchByCategoryAndAssert(String category, int expectedSize) {
    var found = service.searchByCategory(category);

    verify(repository).findByCategory(category);

    assertThat(found.size()).isEqualTo(expectedSize);
}
Instrutor Cristian Mello

Vamos dar um nome decente ao teste.

@Test
void shouldSearchByCategory() { /* ... */ }  

Mas e esses new Object()? Calma, temos que ter testes específicos para nos criar a necessidade de mudar.

Instrutor Cristian Mello

Terminamos nosso teste unitário. Por favor, verifique se está passando.

Instrutor Cristian Mello

9. Implementando um Teste Integrado. Relembrando

Principal Propósito do Teste Integrado: verificar se abstrações construídas com o código dos outros ou externos irão funcionar como deveria.

Só lembrar disto: testar com um código que não podemos alterar.

Qual teste é o melhor candidato? Testar nosso repository com JPA (Jakarta Persistence).

Instrutor Cristian Mello

Temos

public class ProductRepositoryIT {
    @Test
    void nothing() {

    }
}

Vamos lá

public class ProductRepositoryIT {
    private ProductRepository repository;

    @Test
    void nothing() {
        var products = repository.findByCategory("category");
    }
}
Instrutor Cristian Mello

Resolvendo falhas

public class ProductRepositoryIT {
    private ProductRepository repository = new ProductRepository();

    // ...
}

Estamos procurando testar a interação com um Banco de Dados. Existem particularidades a serem testadas em qualquer banco de dados. Qual aspecto degenerado de um objeto de um BD?

Instrutor Cristian Mello

Vamos testar a presença de chave primária

@Test
void nothing() {
    var products = repository.findByCategory("category");

    Assertions.assertThat(products.get(0).getId()).isEqualTo(1L);
}

Falha. Como resolver? Vemos que a assinatura abaixo está nos tirando a possibilidade de definir o método getId().

public List<Object> findByCategory(String category) { /* ... */ }
Instrutor Cristian Mello

A classe Object vai dar lugar à sua herdeira: Product

public class Product extends Object {}

// Ou melhor

public class Product {}

Em TDD, viramos artesãos: do barro, das coisas sem forma, nascem as coisas bem definidas.

public List<Product> findByCategory(String category) {
    return null;
}
Instrutor Cristian Mello

Vamos ter uma falha em outro código

public List<Object> searchByCategory(String category) {
    return repository.findByCategory(category);
}
java: incompatible types: ...List<org.forkstore.Product> cannot be converted to ...List<java.lang.Object>

Simples. Mas isso vai gerar uma cascata de erros. Outros testes acabam sendo dependentes deste código. Vamos precisar consertá-los.

public List<Product> searchByCategory(String category) {
    return repository.findByCategory(category);
}
Instrutor Cristian Mello

Consertando

private void stubbingFindByCategory(String category, List<Object> products) {
    when(repository.findByCategory(category)).thenReturn(products);
}

transforme em

private void stubbingFindByCategory(String category, List<Product> products) {
    when(repository.findByCategory(category)).thenReturn(products);
}
Instrutor Cristian Mello

Lá embaixo, devemos mexer

@Test
void shouldSearchByCategory() {
    String category = "category";
    String other = "other";

    List<Product> productsFromCategory = List.of(new Product());
    List<Product> productsFromOther = List.of(new Product(), new Product());

    // ...
}

Ok. Devemos retestar as classes que tiveram alterações? Sim, mas recomenda-se focar na classe de teste escolhida. No caso, temos que nos focar em ProductRepositoryIT.

Instrutor Cristian Mello

Se estiver treinando via linha de comando, o Maven permite individualizar a classe de teste.

mvn clean verify -Dtest=ProductRepositoryIT
Instrutor Cristian Mello

Agora podemos implementar getId()

public class Product {
    public Long getId() {
        return 0L;
    }
}

Long? Tradicionalmente em mapeamento Objeto-relacional, IDs podem ser muito grandes. Long é o suficiente e de melhor capacidade.

Resolvido. Teremos erro lógico.

Instrutor Cristian Mello
java.lang.NullPointerException: Cannot invoke "java.util.List.get(int)" because "products" is null

Isso se deve porque estamos fazendo

public List<Product> findByCategory(String category) {
    return null;
}

Corrija

public List<Product> findByCategory(String category) {
    return List.of(new Product());
}
Instrutor Cristian Mello

Nova falha ainda

org.opentest4j.AssertionFailedError: 
expected: 1L
 but was: 0L
Expected :1L
Actual   :0L

Solução

public class Product {
    public Long getId() {
        return 1L;
    }
}
Instrutor Cristian Mello

Asserção para ID 2

Assertions.assertThat(products.get(1).getId()).isEqualTo(2L);

Compreensível

java.lang.IndexOutOfBoundsException: Index: 1 Size: 1

Rápido

public List<Product> findByCategory(String category) {
    return List.of(new Product(), new Product());
}
Instrutor Cristian Mello
org.opentest4j.AssertionFailedError: 
expected: 2L
 but was: 1L
Expected :2L
Actual   :1L

E agora? Simples.

public class Product {
    private static Long id = 1L;

    public Long getId() {
        return id++;
    }
}

Resolve. Entretanto, estamos criando assim um tipo de persistência em memória RAM.

Instrutor Cristian Mello

Refatorando para dar suporte ao JPA.

O teste integrado tem a necessidade de testar um código externo fora de nosso controle. O teste que fizemos anteriormente é um Unitário ainda. Vamos transformar em Integrado.

Instrutor Cristian Mello

Transformações. Temos

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import lombok.Getter;

@Entity
@Getter
public class Product {
    @Id
    private Long id;
}

Falha ainda. Vamos melhorar as asserções.

Instrutor Cristian Mello
Assertions.assertThat(products.get(0).getId()).isEqualTo(1L);
Assertions.assertThat(products.get(1).getId()).isEqualTo(2L);

para

Product first = products.get(0);
Product second = products.get(1);

assertThat(first.getId()).isEqualTo(1L);
assertThat(second.getId()).isEqualTo(2L);

Fica mais nítido o problema. getId retorna o valor do campo id que é nulo. Long não é um tipo primitivo, é um tipo chamado Wrapper Class. Ele "embala" um primitivo.

Instrutor Cristian Mello

A razão da falha está no fato de que os objetos estão sendo criados sem definir ID.

Para resolver esse de forma efetiva, podemos seguir uma direção mais interessante. Transformar ProductRepository num CrudRepository.

Vamos sair disto

public class ProductRepository {
    public List<Product> findByCategory(String category) {
        return List.of(new Product(), new Product());
    }
}
Instrutor Cristian Mello

Para isto

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
    List<Product> findByCategory(String name);
}

Automagicamente, vamos ganhar de brinde a implementação de findByCategory() que o JPA já tem pronta. É o reuso na sua maior expressão.

Falha de compilação ainda.

Instrutor Cristian Mello

Não podemos instanciar um interface.

public class ProductRepositoryIT {
    private ProductRepository repository = new ProductRepository();
    // ...
}

Solução: Injeção de Dependência

@Autowired
private ProductRepository repository;

Só podemos injetar uma dependência se um terceiro instanciá-la antes para a gente. Quem vai fazer isso por nós?

Instrutor Cristian Mello

@DataJpaTest

@DataJpaTest
public class ProductRepositoryIT { /* ... */ }

Isso vai fazer o Banco de Dados H2 sair da jaula!!! Vamos precisar dele para os testes fazerem sentido. Alcançamos o verdadeiro propósito do teste integrado.

Instrutor Cristian Mello

Agora teremos

No property 'category' found for type 'Product'

Definimos então o campo

@Entity
@Getter
public class Product {
    @Id
    private Long id;

    private String category;
}
Instrutor Cristian Mello

Calma

java.lang.IndexOutOfBoundsException: Index 0 out of bounds for length 0

Por que será? Como o banco de dados foi iniciado, a tabela de Product está vazia. Só a gente acionar a persistência.

Instrutor Cristian Mello
@Test
void nothing() {
    repository.saveAll(List.of(new Product(), new Product()));
}

Falha.

...JpaSystemException: Identifier of entity '...Product' must be manually assigned before calling 'persist()'

Isto se deve à falta de atribuição de ID. Dá para resolver rápido sem gambiarras.

@Id
@GeneratedValue // automatiza a geração de ID
private Long id;
Instrutor Cristian Mello

Voltamos ao

java.lang.IndexOutOfBoundsException: Index 0 out of bounds for length 0

Minha hipótese: category não está sendo cadastrado no banco

Instrutor Cristian Mello

Vamos utilizar o pattern Builder fornecido pelo Lombok

@Test
void nothing() {
    Product one = Product.builder()
        .category("category")
        .build();

    Product two = Product.builder()
        .category("category")
        .build();

    repository.saveAll(List.of(one, two));

    // ...
}
Instrutor Cristian Mello
@Entity
@Getter
@Builder // AQUI!
@NoArgsConstructor // VAI PRECISAR PARA NÃO QUEBRAR OUTROS TESTES
@AllArgsConstructor // VAI PRECISAR
public class Product {
    @Id
    @GeneratedValue
    private Long id;

    private String category;
}

Resolvido!

Instrutor Cristian Mello

Limpando o teste e colocando um bom nome

@Test
void shouldFindByCategory() {
    var one = Product.builder()
        .category("category")
        .build();

    var two = Product.builder()
        .category("category")
        .build();

    repository.saveAll(List.of(one, two));

    var products = repository.findByCategory("category");

    one = products.get(0);
    two = products.get(1);

    assertThat(one.getId()).isEqualTo(1L);
    assertThat(two.getId()).isEqualTo(2L);
}
Instrutor Cristian Mello

Observamos que os logs mostram os comandos do SQL. Isso nos ajuda a saber que de fato um banco de dados relacional foi usado.

Hibernate: select next value for product_seq
Hibernate: select next value for product_seq
Hibernate: insert into product (category,id) values (?,?)
Hibernate: insert into product (category,id) values (?,?)
Hibernate: select p1_0.id,p1_0.category from product p1_0 where p1_0.category=?
Instrutor Cristian Mello

10. Implementando Teste Integrado de Controller

Instrutor Cristian Mello

Um Controller é um componente de Infraestrutura. O propósito dele é de cuidar do recebimento, processar e dar resposta a uma requisição.

Na Arquitetura RESTful, um Controller recebe uma requisição HTTP e responde sob HTTP através de uma route e um verbo (GET, POST, PUT, ...).

Vale ressaltar que para ser RESTful é preciso cumprir demais requisitos fora de escopo deste material. (Veja sobre HATEOAS).

Instrutor Cristian Mello

Comecemos. Vamos usar uma framework chamada Spring MVC Test ou MockMVC. É mais confiável do que testar as classes de controller chamando seus métodos, desprezando-se as partes importantes.

MockMVC nos permite testar: request mappings, data binding, message conversion, type conversion, validation e etc.

@SpringBootTest
public class ProductControllerIT {
    @Test
    void nothing() {
        var response = mockMvc
            .perform(get("/product").queryParam("category", "acategory"));
    }
}
Instrutor Cristian Mello

Falha. Resolvendo

@SpringBootTest
public class ProductControllerIT {
    @Autowired
    private MockMvc mockMvc;
}

Atenção!

No qualifying bean of type '...MockMvc' available: expected at least 1 bean which qualifies as autowire candidate. ...

Isso nos faz lembrar de que na Injeção de Dependência, a instanciação de um objeto vem de um terceiro que vai instanciar para a gente.

Instrutor Cristian Mello

Solução

@SpringBootTest
@AutoConfigureMockMvc
public class ProductControllerIT { /* ... */ }

Adicionando. Já temos nossa falha de lógica!

var response = mockMvc
    .perform(get("/product").queryParam("category", "acategory"))
    .andExpect(status().isOk());
java.lang.AssertionError: Status expected:<200> but was:<404>
Expected :200
Actual   :404
Instrutor Cristian Mello

Seguindo. Vai dar verde. Rota vai retornar 200 Ok.

@RestController
public class ProductController {
    @RequestMapping(method = RequestMethod.GET, value = "/product")
    void searchByCategory() {}
}

Exija que o conteúdo da resposta do servidor seja do tipo application/json.

var response = mockMvc
    .perform(get("/product").queryParam("category", "acategory"))
    .andExpect(status().isOk())
    .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE));
Instrutor Cristian Mello
@RequestMapping(method = RequestMethod.GET, value = "/product", produces = MediaType.APPLICATION_JSON_VALUE)
void searchByCategory() { /* ... */ }

Isso ainda não resolve. Mesmo erro. Mas é fácil resolver.

@RequestMapping(method = RequestMethod.GET, /* ... */)
String searchByCategory() {
    return "";
}

Testando se a query param foi recebida. Falha.

assertThat(response.getRequest().getParameter("category")).isEqualTo("acategory");
Instrutor Cristian Mello

Resolva

// ...
String searchByCategory(@RequestParam(name = "category") String category) {
    return "";
}

Limpe

@Test
void shouldSearchByCategory_Ok() throws Exception {
    var queryParamName = "category";
    var queryParamValue = "acategory";

    var response = mockMvc
        .perform(get("/product").queryParam(queryParamName, queryParamValue))
        .andExpect(status().isOk())
        .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE))
        .andReturn();

    assertThat(response.getRequest().getParameter(queryParamName)).isEqualTo(queryParamValue);
}
Instrutor Cristian Mello

Aconselho parar por ai. Está ótimo!

Um Controller vai ser ainda testado pelos testes de aceitação. Não vamos testar nada mais além do que aspectos de comunicação do REST com ele.

Alguns desenvolvedores inserem testes como para verificar se o código do serviço será de fato acionado dentro do controller. Existem outras maneiras de atestar isso.

Instrutor Cristian Mello

Temos sempre que ter o cuidado de separar as responsabilidades de cada componente e delimitar o número de dependências de teste.

Existem um tópico mais avançado que trata sobre isso: Fragilidade dos Testes.

Instrutor Cristian Mello

11. Vamos fazer os últimos ajustes

Ao executarmos todos os testes, somente os testes de aceitação falham ainda.

Instrutor Cristian Mello

center

Instrutor Cristian Mello

Lendo o log de erro, notamos

Step failed
java.lang.IllegalArgumentException: argument "content" is null

Temos um teste falhando. Só seguir o TDD, vamos fazer passar.

List<Product> searchByCategory(@RequestParam(name = "category") String category) {
    return List.of(Product.builder().build());
}

Já ajuda um pouco. Entretanto,

Step failed
Expected :3
Actual   :1
Instrutor Cristian Mello

E assim?

List<Product> searchByCategory(@RequestParam(name = "category") String category) {
    Product one = Product.builder().build();
    Product two = Product.builder().build();
    Product three = Product.builder().build();
    return List.of(one, two, three);
}

Ainda não, mas melhorou.

Step failed
java.lang.AssertionError: 
Expecting ListN:
  ["null", "null", "null"]
to contain:
  ["Utensilios"]
but could not find the following element(s):
  ["Utensilios"]
Instrutor Cristian Mello

Um pouco mais inteligente: que tal adicionarmos logo o objeto do service ProductService e chamar o método searchByCategory?

Instrutor Cristian Mello
List<Product> searchByCategory(@RequestParam(name = "category") String category) {
    return service.searchByCategory(category);
}

e

@RestController
public class ProductController {
    @Autowired
    private ProductService service; 

    // ...
}

E agora?

Instrutor Cristian Mello
Step failed
Expected :3
Actual   :0

É um bom sinal. Minha hipótese é de que o controller já está utilizando o service e o banco de dados está sendo consultado.

Por acaso cadastramos os produtos de exemplo? Vamos lá...

Instrutor Cristian Mello
public class PreparacaoDaHistoria {
    @Autowired
    private ProductRepository repository;

    @Dados("produtos cadastrados na loja")
    public void produtosCadastradosNaLoja() {
        var first = Product.builder()
            .category("Utensilios")
            .build();

        var second = Product.builder()
            .category("Utensilios")
            .build();

        var third = Product.builder()
            .category("Utensilios")
            .build();

        repository.saveAll(List.of(first, second, third));
    }
}
Instrutor Cristian Mello

center

Instrutor Cristian Mello

Quase lá!!! Adicione os campos que o cliente pediu.

Instrutor Cristian Mello
var first = Product.builder()
    .category("Utensilios")
    .name("Kit 12 Facas")
    .quantity(3)
    .available(true)
    .build();

var second = Product.builder()
    .category("Utensilios")
    .name("Colher de Silicone")
    .quantity(12)
    .available(true)
    .build();

var third = Product.builder()
    .category("Utensilios")
    .name("Ralador 4 faces Inox")
    .quantity(0)
    .available(false)
    .build();
Instrutor Cristian Mello

Teremos falha de compilação. Resolvendo...

public class Product {
    @Id
    @GeneratedValue
    private Long id;

    private String category;

    private String name;

    private Integer quantity;

    private Boolean available;
}
Instrutor Cristian Mello

Haja coração!!!! As madrugadas me revelam que cada exemplo de teste polui o banco de dados. Vamos limpá-lo.

center

Instrutor Cristian Mello
public class Hooks {
    @Autowired
    private ProductRepository productRepository;

    @After
    public void cleanUp() {
        productRepository.deleteAll();
    }
}
Instrutor Cristian Mello

Acabou!!!

Instrutor Cristian Mello

center

Instrutor Cristian Mello

Feature terminada sucesso!!!

Instrutor Cristian Mello

alt text

Instrutor Cristian Mello

alt text

Instrutor Cristian Mello
Instrutor Cristian Mello

Fica para a próxima Copa

  • Faça clone do projeto
  • Aprender a executar 3 tipos de teste: e2e, it e ut
  • Visualizar um relatório de Integração Contínua (CI: GitHub Actions)
  • (1) Como Cliente, Eu gostaria de consultar produto para comprar.
    • Walking Skeleton exercitou API e Banco de Dados
  • (2) Como Cliente, Eu gostaria de receber um aviso de que, ao procurar um produto por categoria indisponível, Eu receba uma mensagem me notificando.
Instrutor Cristian Mello

Acaba por aqui?!

Não. TDD pode servir como um orientador disciplinar para se aprender a programar de maneira eficaz e gerar maior confiança no código. Exige mais repetição e variação da prática com diversos exemplos.

É só o início.

POI.

Instrutor Cristian Mello