Reduzindo a complexidade e facilitando a criação de testes com Fail Fast e Early Return
Published: 2024-04-16 00:10:14

Ao longo desses 3 anos como desenvolvedor de software pude perceber que muitos desenvolvedores não gostam de implementar testes, sejam eles unitários ou de integração, e que esse assunto é tedioso e trás dor de cabeça, afinal, quem nunca teve que criar casos de testes do zero ou até mesmo ajustar uma base inteira de testes após uma nova implementação? No entanto esquecemos que testes são primordiais para garantirmos a qualidade do nosso sistema, pois um bom código é um código bem testado, o design é apenas perfumaria.

Apesar desta introdução, esta leitura não é para discutirmos sobre testes e sim mostrar que um bom design pode nos auxiliar na escrita dos mesmo e assim entender o sistema, a lógica de negócio ou ramificações com pouco esforço cognitivo.

Vamos fazer o seguinte, partiremos da premissa de que testes visam validar o comportamento de um sistema resultando e em um estado positivo (esperado) e negativo (inesperado) e que para cada ramificação (if or else) será preciso criar cenários que contemplam tais comportamentos.

Tendo isso em mente vamos analisar o código e entender o que ele faz para criarmos cenários de testes para ele:

```java
@RestController
@RequestMapping("/ecommerce/v1")
class ProdutoController(
       private val produtoRepository: ProdutoRepository,
) {

   @PostMapping("/produtos")
   fun helloWorld(
           @RequestBody solicitacaoCompraProduto: SolicitacaoCompraProduto
   ): ResponseEntity<Any> {
       val possivelProduto = produtoRepository.buscar(solicitacaoCompraProduto.identificador)
       if (possivelProduto.isPresent) {
           val produto = possivelProduto.get()
           if (produto.validade.isAfter(LocalDate.now())) {
               if (solicitacaoCompraProduto.lote == produto.lote) {
                   if (solicitacaoCompraProduto.quantidade < produto.quantidade) {
                       if (solicitacaoCompraProduto.precoUnitario  ==  produto.precoUnitario) {
                           produto.quantidade - solicitacaoCompraProduto.quantidade
                           produtoRepository.atualizar(produto)
                           return ResponseEntity.status(201).build()
                       }
                   }
               }
           }
       }
       return ResponseEntity.internalServerError().build()
   }
}
```

Podemos notar que a experiência de leitura de código é péssima e o esforço para entendê-lo é grande, o que não condiz com o seu  tamanho de bloco já que ele é pequeno. 

Depois de alguns minutos consigo compreender que o objetivo deste código é fornecer uma API Rest sendo um ponto de entrada para que seja possível a compra de um produto, validar as entradas do sistema e caso elas estejam todas de acordo com esperado é executado uma ação de redução de produto no estoque, consumando a compra, caso contrário é retornado um resposta de erro ao cliente. 

Agora vamos refatorar um código para obtermos uma experiência melhor de leitura:

```java
@RestController
@RequestMapping("/ecommerce/v1")
class ProdutoController(
       private val produtoRepository: ProdutoRepository,
) {


   @PostMapping("/produtos")
   fun helloWorld(
           @RequestBody solicitacaoCompraProduto: SolicitacaoCompraProduto
   ): ResponseEntity<Any> {
       val possivelProduto = produtoRepository.buscar(solicitacaoCompraProduto.identificador)
       if (!possivelProduto.isPresent) {
           return ResponseEntity.notFound().build()
       }


       val produto = possivelProduto.get()
       try {
           produto.deduzir(
               quantidade = solicitacaoCompraProduto.quantidade,
               lote = solicitacaoCompraProduto.lote,
               precoUnitario = solicitacaoCompraProduto.precoUnitario
           )
       } catch (ex: Exception) {
           return ResponseEntity.badRequest().body(ex.message)
       }


       produtoRepository.atualizar( produto)


       return ResponseEntity.ok().build()
   }
}
```

```java
data class Produto(
       val identificador: Long,
       val nome: String,
       val precoUnitario: BigDecimal,
       val quantidade: Int,
       val validade: LocalDate,
       val lote: String
) {
       val estaVencido get() = validade.isAfter(LocalDate.now())


       fun deduzir(
               quantidade: Int,
               lote: String,
               precoUnitario: BigDecimal
       ) {
               if (estaVencido) {
                       throw Exception("O produto está vencido")
               }


               if (this.lote != lote) {
                       throw Exception("O lote é diferente")
               }


               if (this.quantidade < quantidade) {
                       throw Exception("Quantidade acima da capacidade")
               }


               if (precoUnitario != this.precoUnitario) {
                       throw Exception("Preço divergente")
               }


               this.quantidade - quantidade
       }
}
```

Podemos observar que temos um código limpo e objetivo. Consigo quase que de maneira intuitiva entender o que ele faz e visualizar suas regras para deduzir um produto. 

Olhando para este código eu consigo contemplar diversos cenários de testes:
 
“Caso o produto não seja encontrado, retorne um status http 404”

“Caso o produto esteja vencido, retorne um status http 400 com a mensagem: O produto está vencido”

“Caso o lote do produto seja diferente, retorne um status http 400 com a mensagem: O lote é diferente”

E assim sucessivamente para os demais casos! E observe que os testes são em cima dos comportamento inesperado do sistema, pois quando todos os dados estiverem “okay” ocorrerá o comportamento esperado, e aí sim surge o caso de sucesso: 

“Caso todas as entradas estejam corretas, retorne um http status 201”

E com casos mapeados podemos escrever testes baseando-se neles! Simples, né? 

Mas é claro, essa refatoração segue três técnicas de programação, Tell Don’t ask, Fail Fast e Early Return. 

Quero focar somente no Fail Fast e Early Return, dado que são simples e eficazes. 

Basicamente Fail Fast, ou falha rápida, visa detectar e reportar erros do sistema imediatamente impedindo que ele prossiga com dados incorretos. Isso envolve verificações de condições de erro. 

Podemos ver essa abordagem no método deduzir da classe Produto

```java
fun deduzir(
               quantidade: Int,
               lote: String,
               precoUnitario: BigDecimal
       ) {
               if (estaVencido) {
                       throw Exception("O produto está vencido")
               }


               if (this.lote != lote) {
                       throw Exception("O lote é diferente")
               }


               if (this.quantidade < quantidade) {
                       throw Exception("Quantidade acima da capacidade")
               }


               if (precoUnitario != this.precoUnitario) {
                       throw Exception("Preço divergente")
               }


               this.quantidade - quantidade
       }
```

Já Early Return, ou retorno antecipado, retorna imediatamente um condição quando ela é atendida, evitando “ifs” aninhados e deixando o código mais objetivo. 

Essa abordagem foi aplicada na classe ProdutoController quando verificamos se o produto não está presente

```java
val possivelProduto = produtoRepository.buscar(solicitacaoCompraProduto.identificador)
if (!possivelProduto.isPresent) {
   return ResponseEntity.notFound().build()
}
```

Portanto, essas técnicas nos auxiliam reduzir a carga cognitiva para entender a complexidade de um sistema, nos ajudam nas escritas de testes já que por intermédio deles conseguimos compreender melhor os cenários para testá-los e de brinde temos um bom design código. Lembrando que design é perfumaria e que a qualidade de um código está relacionado a sua base de teste.

Se ficou curioso para saber sobre Tell Don’t Ask eu falo sobre ele nesta publicação https://dev.to/joaopolira/contribuindo-para-coesao-e-encapsulamento-atraves-do-design-de-codigo-tell-dont-ask-5gfi