Estruturas, métodos e interfaces
Você pode encontrar todos os códigos desse capítulo aqui
Suponha que precisamos de algum código de geometria para calcular o perímetro de um retângulo dado uma altura e largura. Podemos escrever uma função Perimetro(largura float64, altura float64)
, onde float64
representa números em ponto flutuante como 123.45
.
O ciclo de TDD deve ser mais familiar para você agora.
Escreva o teste primeiro
Viu a nova string de formatação? O f
é para nosso float64
e o .2
significa imprimir duas casas decimais.
Execute o teste
./formas_test.go:6:9: undefined: Perimetro
indefinido: Perimetro
Escreva o mínimo de código possível para fazer o teste rodar e verifique a saída do teste falhando
Resulta em formas_test.go:10: resultado 0, esperado 40
.
Escreva código o suficiente para fazer o teste passar
Por enquanto, tudo fácil. Agora vamos criar uma função chamada Area(largura, altura float64)
que retorna a área de um retângulo.
Tente fazer isso sozinho, segundo o ciclo de TDD.
Você deve terminar com os testes como estes:
E código como este:
Refatoração
Nosso código faz o trabalho, mas não contém nada explícito sobre retângulos. Uma pessoa descuidada poderia tentar passar a largura e altura de um triângulo para esta função sem perceber que ela retornará uma resposta errada.
Podemos apenas dar para a função um nome mais específico como AreaDoRetangulo
. Uma solução mais limpa é definir nosso próprio tipo chamado Retangulo
que encapsula este conceito para nós.
Podemos criar um tipo simples usando uma struct (estrutura). Uma struct é apenas uma coleção nomeada de campos onde você pode armazenar dados.
Declare uma struct
assim:
Agora vamos refatorar os testes para usar Retangulo
em vez de um simples float64
.
Lembre de rodar seus testes antes de tentar corrigir. Você deve ter erro útil como:
Você pode acessar os campos de uma struct
com a sintaxe minhaStruct.campo
.
Mude as duas funções para corrigir o teste.
Espero que você concorde que passar um Retangulo
para a função mostra nossa intenção com mais clareza, mas existem mais benefícios em usar structs
que já vamos entender.
Nosso próximo requisito é escrever uma função Area
para círculos.
Escreva o teste primeiro
Execute o teste
./formas_test.go:28:13: undefined: Circulo
Escreva o mínimo de código possível para fazer o teste rodar e verifique a saída do teste falhando
Precisamos definir nosso tipo Circulo
.
Agora rode os testes novamente.
./formas_test.go:29:14: cannot use circulo (type Circulo) as type Retangulo in argument to Area
Algumas linguagens de programação permitem você fazer algo como:
Mas em Go você não pode:
./formas.go:20:32: Area redeclared in this block
Temos duas escolhas:
Podemos ter funções com o mesmo nome declaradas em pacotes diferentes. Então, poderíamos criar nossa
Area(Circulo)
em um novo pacote, só que isso parece um exagero aqui.Em vez disso, podemos definir métodos em nosso mais novo tipo definido.
O que são métodos?
Até agora só escrevemos funções, mas temos usado alguns métodos. Quando chamamos t.Errorf
, nós chamamos o método Errorf
na instância de nosso t
(testing.T
).
Um método é uma função com um receptor. Uma declaração de método vincula um identificador e o nome do método a um método e associa o método com o tipo base do receptor.
Métodos são muito parecidos com funções, mas são chamados invocando-os em uma instância de um tipo específico.
Enquanto você chama funções onde quiser, como por exemplo em Area(retangulo)
, você só pode chamar métodos em "coisas" específicas.
Um exemplo ajudará. Então, vamos mudar nossos testes primeiro para chamar métodos em vez de funções, e, em seguida, corrigir o código.
Se rodarmos os testes agora, recebemos:
type Circulo has no field or method Area
Gostaria de reforçar o quão grandioso o compilador é. É muito importante ter tempo para ler lentamente as mensagens de erro que você recebe, pois isso te ajudará a longo prazo.
Escreva o mínimo de código possível para fazer o teste rodar e verifique a saída do teste falhando
Vamos adicionar alguns métodos para nossos tipos:
A sintaxe para declaração de métodos é quase a mesma que usamos para funções e isso acontece porque eles são muito parecidos. A única diferença é a sintaxe para o método receptor: func (nomeDoReceptor TipoDoReceptor) NomeDoMetodo(argumentos)
.
Quando seu método é chamado em uma variável desse tipo, você tem sua referência para o dado através da variável nomeDoReceptor
. Em muitas outras linguagens de programação isto é feito implicitamente e você acessa o receptor através de this
.
É uma convenção em Go que a variável receptora seja a primeira letra do tipo em minúsculo.
Se você executar novamente os testes, eles devem compilar e dar alguma saída do teste falhando.
Escreva código suficiente para fazer o teste passar
Agora vamos fazer nossos testes de retângulo passarem corrigindo nosso novo método.
Se você executar novamente os testes, aqueles de retângulo devem passar, mas os de círculo ainda falham.
Para fazer a função Area
de círculo passar, vamos emprestar a constante Pi
do pacote math
(lembre-se de importá-lo).
Refatoração
Existe duplicação em nossos testes.
Tudo o que queremos fazer é pegar uma coleção de formas, chamar o método Area()
e então verificar o resultado.
Queremos ser capazes de escrever um tipo de função verificaArea
que permita passar tanto Retangulo
quanto Circulo
, mas falhe ao compilar se tentarmos passar algo que não seja uma forma.
Com Go, podemos trabalhar dessa forma com interfaces.
Interfaces são um conceito muito poderoso em linguagens de programação estaticamente tipadas, como Go, porque permitem que você crie funções que podem ser usadas com diferentes tipos e permite a criação de código altamente desacoplado, mantendo ainda a segurança de tipos.
Vamos apresentar isso refatorando nossos testes.
Estamos criando uma função auxiliar como fizemos em outros exercícios, mas desta vez estamos pedindo que uma Forma
seja passada. Se tentarmos chamá-la com algo que não seja uma forma, não vai compilar.
Como algo se torna uma forma? Precisamos apenas falar para o Go o que é uma Forma
usando uma declaração de interface.
Estamos criando um novo tipo
, assim como fizemos com Retangulo
e Circulo
, mas desta vez é uma interface
em vez de uma struct
.
Uma vez adicionado isso ao código, os testes passarão.
Peraí, como assim?
A interface em Go bem diferente das interfaces na maioria das outras linguagens de programação. Normalmente você tem que escrever um código para dizer que meu tipo Foo implementa a interface Bar
.
Só que no nosso caso:
Retangulo
tem um método chamadoArea
que retorna umfloat64
, então satisfaz a interfaceForma
.Circulo
tem um método chamadoArea
que retorna umfloat64
, então satisfaz a interfaceForma
.string
não tem esse método, então não satisfaz a interface.etc.
Em Go a resolução de interface é implícita. Se o tipo que você passar combinar com o que a interface está esperando, o código será compilado.
Desacoplando
Veja como nossa função auxiliar não precisa se preocupar se a forma é um Retangulo
ou um Circulo
ou um Triangulo
. Ao declarar uma interface, a função auxiliar está desacoplada de tipos concretos e tem apenas o método que precisa para fazer o trabalho.
Este tipo de abordagem - de usar interfaces para declarar somente o que você precisa - é muito importante no desenvolvimento de software e será coberto mais detalhadamente nas próximas seções.
Refatoração adicional
Agora que você conhece as structs
, podemos apresentar os "table driven tests" (testes orientados por tabela).
Table driven tests são úteis quando você quer construir uma lista de casos de testes que podem ser testados da mesma forma.
A única sintaxe nova aqui é a criação de uma "struct anônima", testesArea
. Estamos declarando um slice de structs usando []struct
com dois campos, o forma
e o esperado
. Então preenchemos o slice com os casos.
Depois iteramos sobre eles assim como fazemos com qualquer outro slice, usando os campos da struct para executar nossos testes.
Dá para perceber como será muito fácil para uma pessoa inserir uma nova forma, implementar Area
e então adicioná-la nos casos de teste. Além disso, se for encontrada uma falha em Area
, é muito fácil adicionar um novo caso de teste para verificar antes de corrigi-la.
Testes baseados em tabela podem ser um item valioso em sua caixa de ferramentas, mas tenha certeza de que você precisa da sintaxe extra nos testes. Se você deseja testar várias implementações de uma interface ou se o dado passado para uma função tem muitos requisitos diferentes que precisam de testes, eles podem servir bem.
Vamos demonstrar tudo isso adicionando e testando outra forma; um triângulo.
Escreva o teste primeiro
Adicionar um teste para nossa nova forma é muito fácil. Simplesmente adicione {Triangulo{12, 6}, 36.0},
à nossa lista.
Execute o teste
Lembre-se, continue tentando executar o teste e deixe o compilador guiá-lo em direção a solução.
Escreva o mínimo de código possível para fazer o teste rodar e verifique a saída do teste falhando
./formas_test.go:25:4: undefined: Triangulo
Ainda não definimos Triangulo
:
Tente novamente:
Triangulo não implementa Forma (método Area faltando)
Isso nos diz que não podemos usar um Triangulo
como uma Forma
porque ele não tem um método Area()
, então adicione uma implementação vazia para fazermos o teste funcionar:
Finalmente o código compilou e temos o nosso erro:
formas_test.go:31: resultado 0.00, esperado 36.00
Escreva código suficiente para fazer o teste passar
E nossos testes passaram!
Refatoração
Novamente, a implementação está boa, mas nossos testes podem ser melhorados.
Quando você lê isso:
Não está tão claro o que todos os números representam e você deve ter o objetivo de escrever testes que sejam fáceis de entender.
Até agora você viu uma sintaxe para criar instâncias de structs como MinhaStruct{valor1, valor2}
, mas você pode opcionalmente nomear esses campos.
Vamos ver como isso funciona:
Em Test-Driven Development by Example Kent Beck refatora alguns testes para um ponto e afirma:
O teste é lido de forma mais clara, como se fosse uma afirmação da verdade, não uma sequência de operações
(ênfase minha)
Agora nossos testes (pelo menos a lista de casos) fazem afirmações da verdade sobre formas e suas áreas.
Garanta que a saída do seu teste seja útil
Lembra anteriormente quando implementamos Triangulo
e tivemos um teste falhando? Ele imprimiu formas_test.go:31: resultado 0.00 esperado, 36.00
.
Nós sabíamos que estava relacionado ao Triangulo
porque estávamos trabalhando nisso, mas e se uma falha escorregasse para o sistema em um dos 20 casos na tabela? Como alguém saberia qual caso falhou? Não parece ser uma boa experiência. Ela teria que olhar caso a caso para encontrar qual deles está falhando de fato.
Podemos mudar nossa mensagem de erro para %#v resultado %.2f, esperado %.2f
. A string de formatação %#v
irá imprimir nossa struct com os valores em seu campo para que as pessoas possam ver imediatamente as propriedades que estão sendo testadas.
Para melhorar a legibilidade de nossos futuros casos de teste, podemos renomear o campo esperado
para algo mais descritivo como temArea
.
Uma dica final com testes guiados por tabela é usar t.Run
e renomear os casos de teste.
Envolvendo cada caso em um t.Run
você terá uma saída de testes mais limpa em caso de falhas, além de imprimir o nome do caso.
E você pode rodar testes específicos dentro de sua tabela com go test -run TestArea/Retangulo
.
Aqui está o código final do nosso teste que captura isso:
Resumo
Esta foi mais uma prática de TDD, iterando em nossas soluções para problemas matemáticos básicos e aprendendo novos recursos da linguagem motivados por nossos testes.
Declarar structs para criar seus próprios tipos de dados permite agrupar dados relacionados e torna a intenção do seu código mais clara.
Declarar interfaces permite que você possa definir funções que podem ser usadas por diferentes tipos (polimorfismo paramétrico).
Adicionar métodos permite que você possa adicionar funcionalidades aos seus tipos de dados e implementar interfaces.
Testes baseados em tabela permite que você torne suas asserções mais claras e seus testes mais fáceis de estender e manter.
Este foi um capítulo importante porque agora começamos a definir nossos próprios tipos. Em linguagens estaticamente tipadas como Go, conseguir projetar seus próprios tipos é essencial para construir software que seja fácil de entender, compilar e testar.
Interfaces são uma ótima ferramenta para ocultar a complexidade de outras partes do sistema. Em nosso caso, o código de teste auxiliar não precisou conhecer a forma exata que estava afirmando, apenas como "pedir" pela sua área.
Conforme você se familiariza com Go, começa a ver a força real das interfaces e da biblioteca padrão.
Você aprenderá sobre as interfaces definidas na biblioteca padrão que são usadas em todo lugar e, implementando-as em relação aos seus próprios tipos, você pode reutilizar rapidamente muitas das ótimas funcionalidades.
Last updated