Você pode encontrar todo o código para este capítulo aqui
No capitulo anterior continuamos interagindo com nossa aplicação pela adição de um novo endpoint /liga
. Durante o caminho aprendemos como lidar com JSON, tipos embutidos e roteamento.
Nossa dona do produto está de certa forma preocupada, por conta do software perder as pontuações quando o servidor é reiniciado. Ela também não se agradou que nós não interpretamos o endpoint /liga
que deveria retornar os jogadores ordenados pelo número de vitórias!
// server.go
package main
import (
"encoding/json"
"fmt"
"net/http"
)
// GuardaJogador armazena informações sobre os jogadores
type GuardaJogador interface {
PegaPontuacaoDoJogador(nome string) int
SalvaVitoria(nome string)
PegaLiga() []Jogador
}
// Jogador guarda o nome com o número de vitorias
type Jogador struct {
Nome string
Vitorias int
}
// ServidorDoJogador é uma interface HTTP para informações dos jogadores
type ServidorDoJogador struct {
armazenamento GuardaJogador
http.Handler
}
const jsonContentType = "application/json"
// NovoServidorDoJogador cria um ServidorDoJogador com roteamento configurado
func NovoServidorDoJogador(armazenamento GuardaJogador) *ServidorDoJogador {
p := new( ServidorDoJogador)
p.armazenamento = armazenamento
roteador := http.NewServeMux()
roteador.Handle("/liga", http.HandlerFunc(p.ManipulaLiga))
roteador.Handle("/jogadores/", http.HandlerFunc(p.ManipulaJogador))
p.Handler = roteador
return p
}
func (p *ServidorDoJogador) ManipulaLiga(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(p.armazenamento.PegaLiga())
w.Header().Set("content-type", jsonContentType)
w.WriteHeader(http.StatusOK)
}
func (p *ServidorDoJogador) ManipulaJogador(w http.ResponseWriter, r *http.Request) {
jogador := r.URL.Path[len("/jogadores/"):]
switch r.Method {
case http.MethodPost:
p.processaVitoria(w, jogador)
case http.MethodGet:
p.mostraPontuacao(w, jogador)
}
}
func (p *ServidorDoJogador) mostraPontuacao(w http.ResponseWriter, jogador string) {
pontuacao := p.armazenamento.PegaPontuacaoDoJogador(jogador)
if pontuacao == 0 {
w.WriteHeader(http.StatusNotFound)
}
fmt.Fprint(w, pontuacao)
}
func (p *ServidorDoJogador) processaVitoria(w http.ResponseWriter, jogador string) {
p.armazenamento.salvaVitorias(jogador)
w.WriteHeader(http.StatusAccepted)
}
// ArmazenamentoDeJogadorNaMemoria.go
package main
func NovoArmazenamentoDeJogadorNaMemoria() *ArmazenamentoDeJogadorNaMemoria {
return &ArmazenamentoDeJogadorNaMemoria{map[string]int{}}
}
type ArmazenamentoDeJogadorNaMemoria struct {
armazenamento map[string]int
}
func (i *ArmazenamentoDeJogadorNaMemoria) PegaLiga() []Jogador {
var liga []Jogador
for nome, vitorias := range i.armazenamento {
liga = append(liga, Jogador{nome, vitorias})
}
return liga
}
func (i *ArmazenamentoDeJogadorNaMemoria) SalvaVitoria(nome string) {
i.armazenamento[nome]++
}
func (i *ArmazenamentoDeJogadorNaMemoria) PegaPontuacaoDoJogador(nome string) int {
return i.armazenamento[nome]
}
// main.go
package main
import (
"log"
"net/http"
)
func main() {
servidor:= NovoServidorDoJogador(NovoArmazenamentoDeJogadorNaMemoria())
if err := http.ListenAndServe(":5000", servidor); err != nil {
log.Fatalf("Não foi possivel ouvir na porta 5000 %v", err)
}
}
Você pode encontrar todos os testes relacionados no link no começo desse capítulo.
Existem diversos bancos de dados que poderíamos usar para isso, mas nós vamos por uma abordagem mais simples. Nós iremos armazenar os dados para essa aplicação em um arquivo como JSON.
Isso mantém os dados bastante manipuláveis e é relativamente simples de implementar.
Não será bem escalável mas, dado que isto é um protótipo, vai funcionar para agora. Se nossas circunstâncias mudarem e isto não for mais apropriado, será simples trocar para algo diferente por conta da abstração de GuardarJogadores
que nós usamos.
Nós vamos manter o NovoArmazenamentoDeJogadorNaMemoria
por enquanto para que os testes de integração continuem passando a medida que formos desenvolvendo nossa armazenamento. Quando estivermos confiantes que nossa implementação é suficiente para fazer os testes de integração passarem , nós iremos trocar e apagar NovoArmazenamentoDeJogadorNaMemoria
Por agora você deve estar familiar com as interfaces em torno da biblioteca padrão para leitura de dados (io.Reader
), escrita de dados (io.Writer
) e como nós podemos usar a biblioteca padrão para testar essas funções sem ter que usar arquivos de verdade.
Para esse trabalho ser completo precisamos implementar GuardaJogador
, então escreveremos testes para nossa armazenamento chamando os métodos que nós precisamos implementar. Começaremos com PegaLiga
.
func TestSistemaDeArquivoDeArmazenamentoDoJogador(t *testing.T) {
t.Run("/liga de um leitor", func(t *testing.T) {
bancoDeDados := strings.NewReader(`[
{"Nome": "Cleo", "Vitorias": 10},
{"Nome": "Chris", "Vitorias": 33}]`)
armazenamento := SistemaDeArquivoDeArmazenamentoDoJogador{bancoDeDados}
recebido := armazenamento.PegaLiga()
esperado := []Jogador{
{"Cleo", 10},
{"Chris", 33},
}
defineLiga(t, recebido, esperado)
})
}
Estamos usando strings.NewReader
que irá nos retornar um Reader
, que é o que nosso SistemaDeArquivoDeArmazenamentoDoJogador
irá usar para ler os dados. Em main
abriremos um arquivo, que também é um Reader
.
# github.com/larien/aprenda-go-com-testes/json-and-io/v7
./SistemaDeArquivoDeArmazenamentoDoJogador_test.go:15:12: undefined: SistemaDeArquivoDeArmazenamentoDoJogador
Vamos definir SistemaDeArquivoDeArmazenamentoDoJogador
em um novo arquivo
type SistemaDeArquivoDeArmazenamentoDoJogador struct {}
Tente de novo
# github.com/larien/aprenda-go-com-testes/json-and-io/v7
./SistemaDeArquivoDeArmazenamentoDoJogador_test.go:15:28: too many values in struct initializer
./SistemaDeArquivoDeArmazenamentoDoJogador_test.go:17:15: armazenamento.PegaLiga undefined (type SistemaDeArquivoDeArmazenamentoDoJogador has no field or method PegaLiga)
Está reclamando porque estamos passando para ele um Reader
mas não está esperando um e não tem PegaLiga
definida ainda.
type SistemaDeArquivoDeArmazenamentoDoJogador struct {
bancoDeDados io.Reader
}
func (f *SistemaDeArquivoDeArmazenamentoDoJogador) PegaLiga() []Jogador {
return nil
}
Tente mais uma vez...
=== RUN TestSistemaDeArquivoDeArmazenamentoDoJogador//league_from_a_reader
--- FAIL: TestSistemaDeArquivoDeArmazenamentoDoJogador//league_from_a_reader (0.00s)
SistemaDeArquivoDeArmazenamentoDoJogador_test.go:24: recebido [] esperado [{Cleo 10} {Chris 33}]
Nós lemos JSON de um leitor antes
func (f *SistemaDeArquivoDeArmazenamentoDoJogador) PegaLiga() []Jogador {
var liga []Jogador
json.NewDecoder(f.bancoDeDados).Decode(&liga)
return liga
}
O teste deve passar.
Fizemos isso antes! Nosso código de teste para o servidor tinha que decodificar o JSON da resposta.
Vamos tentar DRYando isso em uma função.
Crie um novo arquivo chamado liga.go
e coloque isso nele.
func NovaLiga(rdr io.Reader) ([]Jogador, error) {
var liga []Jogador
err := json.NewDecoder(rdr).Decode(&liga)
if err != nil {
err = fmt.Errorf("Problema parseando a liga, %v", err)
}
return liga, err
}
Chame isso em nossa implementação e em nosso teste helper obterLigaDaResposta
in serv_test.go
func (f *SistemaDeArquivoDeArmazenamentoDoJogador) PegaLiga() []Jogador {
liga, _ := NovaLiga(f.bancoDeDados)
return liga
}
Ainda não temos a estratégia para lidar com a análise de erros mas vamos continuar.
Existe um problema na nossa implementação. Primeiramente, vamos relembrar como io.Reader
é definida.
type Reader interface {
Read(p []byte) (n int, err error)
}
Com nosso arquivo, você consegue imagina-lo lendo byte por byte até o fim. O que acontece se você tentar e ler
uma segunda vez?
Adicione o seguinte no final do seu teste atual.
// read again
recebido = armazenamento.PegaLiga()
defineLiga(t, recebido, esperado)
Queremos que passe, mas se você rodar o teste ele não passa.
O problema é nosso Reader
chegou no final, então não tem mais nada para ser lido. Precisamos de um jeito de avisar para voltar ao inicio.
ReadSeeker é outra interface na biblioteca padrão que pode ajudar.
type ReadSeeker interface {
Reader
Seeker
}
Lembra-se do incorporamento? Esta é uma interface composta de Reader
e Seeker
type Seeker interface {
Seek(offset int64, whence int) (int64, error)
}
Parece bom, podemos mudar SistemaDeArquivoDeArmazenamentoDoJogador
para pegar essa interface no lugar?
type SistemaDeArquivoDeArmazenamentoDoJogador struct {
bancoDeDados io.ReadSeeker
}
func (f *SistemaDeArquivoDeArmazenamentoDoJogador) PegaLiga() []Jogador {
f.bancoDeDados.Seek(0, 0)
liga, _ := NovaLiga(f.bancoDeDados)
return liga
}
Tente rodar o teste,agora passa! Ainda bem que string.NewReader
que nós usamos em nosso teste também implementa ReadSeeker
então não precisamos mudar nada.
A seguir vamos implementar PegarPontuacaooDoJogador
.
t.Run("pegar pontuação do jogador", func(t *testing.T) {
bancoDeDados := strings.NewReader(`[
{"Nome": "Cleo", "Vitorias": 10},
{"Nome": "Chris", "Vitorias": 33}]`)
armazenamento := SistemaDeArquivoDeArmazenamentoDoJogador{bancoDeDados}
recebido := armazenamento.("Chris")
esperado := 33
if recebido != esperado {
t.Errorf("recebido %d esperado %d", recebido, esperado)
}
})
./SistemaDeArquivoDeArmazenamentoDoJogador_test.go:38:15: armazenamento. undefined (type SistemaDeArquivoDeArmazenamentoDoJogador has no field or method )
Precisamos adicionar o método para o novo tipo para fazer o teste compilar.
func (f *SistemaDeArquivoDeArmazenamentoDoJogador) (nome string) int {
return 0
}
Agora compila e o teste falha
=== RUN TestSistemaDeArquivoDeArmazenamentoDoJogador/get_player_score
--- FAIL: TestSistemaDeArquivoDeArmazenamentoDoJogador//get_player_score (0.00s)
SistemaDeArquivoDeArmazenamentoDoJogador_test.go:43: recebido 0 esperado 33
Podemos iterar sobre a liga para encontrar o jogador e retornar a pontuação dele.
func (f *SistemaDeArquivoDeArmazenamentoDoJogador) (nome string) int {
var vitorias int
for _, jogador := range f.PegaLiga() {
if jogador.Nome == nome {
vitorias = jogador.Vitorias
break
}
}
return vitorias
}
Você terá visto vários refatoramentos de teste helper, então deixarei este para você fazer funcionar
t.Run("/pega pontuacao do jogador", func(t *testing.T) {
bancoDeDados := strings.NewReader(`[
{"Nome": "Cleo", "Vitorias": 10},
{"Nome": "Chris", "Vitorias": 33}]`)
armazenamento := SistemaDeArquivoDeArmazenamentoDoJogador{bancoDeDados}
recebido := armazenamento.("Chris")
esperado := 33
definePontuacaoIgual(t, recebido, esperado)
})
Finalmente, precisamos começar a salvar pontuações com SalvaVitoria
.
Nossa abordagem é um pouco ruim para escritas. Não podemos (facilmente) apenas atualizar uma "linha" de JSON em um arquivo. Precisaremos armazenar a inteira nova representação de nosso banco de dados em cada escrita.
Como escrevemos? Normalmente usaríamos um Writer
, mas já temos nosso ReadSeeker
. Potencialmente podemos ter duas dependências, mas a biblioteca padrão já tem uma interface para nós: o ReadWriteSeeker
, que permite fazermos tudo que precisamos com um arquivo.
Vamos atualizar nosso tipo:
type SistemaDeArquivoDeArmazenamentoDoJogador struct {
bancoDeDados io.ReadWriteSeeker
}
Veja se compila:
./SistemaDeArquivoDeArmazenamentoDoJogador_test.go:15:34: cannot use bancoDeDados (type *strings.Reader) as type io.ReadWriteSeeker in field value:
*strings.Reader does not implement io.ReadWriteSeeker (missing Write method)
./SistemaDeArquivoDeArmazenamentoDoJogador_test.go:36:34: cannot use bancoDeDados (type *strings.Reader) as type io.ReadWriteSeeker in field value:
*strings.Reader does not implement io.ReadWriteSeeker (missing Write method)
Não é tão surpreendente que strings.Reader
não implementa ReadWriteSeeker
, então o que vamos fazer?
Temos duas opções:
- Criar um arquivo temporário para cada teste.
*os.File
implementaReadWriteSeeker
. O pró disso é que isso se torna mais um teste de integração, mas nós realmente estamos lendo e escrevendo de um sistema de arquivos então isso nos dará um alto nível de confiança. Os contras são que preferimos testes unitários porque são mais rápidos e normalmente mais simples. Também precisaremos trabalhar mais criando arquivos temporários e então ter certeza que serão removidos após o teste. - Poderíamos usar uma biblioteca externa. Mattetti escreveu uma biblioteca filebuffer que implementa a interface que precisamos e assim não precisariamos modificar o sistema de arquivos.
Não acredito que exista uma resposta especialmente errada aqui, mas ao escolher usar uma biblioteca externa eu teria que explicar o gerenciamento de dependências! Então usaremos os arquivos.
Antes de adicionarmos nosso teste precisamos fazer nossos outros testes compilarem substituindo o strings.Reader
com um os.File
.
Vamos criar uma função auxiliar que irá criar um arquivo temporário com alguns dados dentro dele
func criaArquivoTemporario(t *testing.T, dadoInicial string) (io.ReadWriteSeeker, func()) {
t.Helper()
arquivotmp, err := ioutil.TempFile("", "db")
if err != nil {
t.Fatalf("não foi possivel escrever o arquivo temporário %v", err)
}
arquivotmp.Write([]byte(dadoInicial))
removeArquivo := func() {
arquivotmp.Close()
os.Remove(arquivotmp.Name())
}
return arquivotmp, removeArquivo
}
TempFile cria um arquivo temporário para usarmos. O valor "db"
que passamos é um prefixo colocado em um arquivo de nome aleatório que vai criar. Isto é para garantir que não vai dar conflito acidental com outros arquivos.
Você irá notar que não estamos retornando apenas nosso ReadWriteSeeker
(o arquivo) mas também uma função. Precisamos garantir que o arquivo é removido uma vez que o teste é finalizado. Não queremos que dados sejam vazados dos arquivos no teste como é possível acontecer e desinteressante para o leitor. Ao retornar uma função removeArquivo
, cuidamos dos detalhes no nosso auxiliar e tudo que a chamada precisa fazer é executar defer limpaBancoDeDados()
.
func TestaArmazenamentoDeSistemaDeArquivo(t *testing.T) {
t.Run("liga de um leitor", func(t *testing.T) {
bancoDeDados, limpaBancoDeDados := criaArquivoTemporario(t, `[
{"Nome": "Cleo", "Vitorias": 10},
{"Nome": "Chris", "Vitorias": 33}]`)
defer limpaBancoDeDados()
armazenamento := SistemaDeArquivoDeArmazenamentoDoJogador{bancoDeDados}
recebido := armazenamento.PegaLiga()
esperado := []Jogador{
{"Cleo", 10},
{"Chris", 33},
}
defineLiga(t, recebido, esperado)
// ler novamente
recebido = armazenamento.PegaLiga()
defineLiga(t, recebido, esperado)
})
t.Run("retorna pontuação do jogador", func(t *testing.T) {
bancoDeDados, limpaBancoDeDados := criaArquivoTemporario(t, `[
{"Nome": "Cleo", "Vitorias": 10},
{"Nome": "Chris", "Vitorias": 33}]`)
defer limpaBancoDeDados()
armazenamento := SistemaDeArquivoDeArmazenamentoDoJogador{bancoDeDados}
recebido := armazenamento.("Chris")
esperado := 33
definePontuacaoIgual(t, recebido, esperado)
})
}
Rode os testes e eles devem estar passando! Teve uma quantidade razoável de mudanças mas agora parece que nossa definição de interface completa e deve ser muito fáci adicionar novos testes de agora em diante.
Vamos pegar a primeira iteração de gravar uma vitória de um jogador existente
t.Run("armazena vitórias de um jogador existente", func(t *testing.T) {
bancoDeDados, limpaBancoDeDados := criaArquivoTemporario(t, `[
{"Nome": "Cleo", "Vitorias": 10},
{"Nome": "Chris", "Vitorias": 33}]`)
defer limpaBancoDeDados()
armazenamento := SistemaDeArquivoDeArmazenamentoDoJogador{bancoDeDados}
armazenamento.SalvaVitoria("Chris")
recebido := armazenamento.("Chris")
esperado := 34
definePontuacaoIgual(t, recebido, esperado)
})
./SistemaDeArquivoDeArmazenamentoDoJogador_test.go:67:8: armazenamento.SalvaVitoria undefined (type SistemaDeArquivoDeArmazenamentoDoJogador has no field or method SalvaVitoria)
Adicione um novo método
func (f *SistemaDeArquivoDeArmazenamentoDoJogador) SalvaVitoria(nome string) {
}
=== RUN TestSistemaDeArquivoDeArmazenamentoDoJogador/store_wins_for_existing_players
--- FAIL: TestSistemaDeArquivoDeArmazenamentoDoJogador/store_wins_for_existing_players (0.00s)
SistemaDeArquivoDeArmazenamentoDoJogador_test.go:71: recebido 33 esperado 34
Nossa implementação está vazia então a pontuação anterior está sendo retornada.
func (f *SistemaDeArquivoDeArmazenamentoDoJogador) SalvaVitoria(nome string) {
liga := f.PegaLiga()
for i, jogador := range liga {
if jogador.Nome == nome {
liga[i].Vitorias++
}
}
f.bancoDeDados.Seek(0,0)
json.NewEncoder(f.bancoDeDados).Encode(liga)
}
Você deve está se perguntando por que estou fazendo liga[i].Vitorias++
invés de jogador.Vitorias++
.
Quando você percorre
sobre um pedaço é retornado o índice atual do laço (no nosso caso i
) e uma cópia do elemento naquele índice. Mudando o valor Vitorias
não irá afetar no pedaço liga
que iteramos sobre. Por este motivo, precisamos pegar a referência do valor atual fazendo liga[i]
e então mudando este valor.
Se rodar os testes, eles devem estar passando.
Em PegaPontuacaoDoJogador
e SalvaVitoria
, estamos iterando sobre []Jogador
para encontrar um jogador pelo nome.
Poderíamos refatorar esse código comum nos internos de SistemaDeArquivoDeArmazenamentoDoJogador
mas para mim, parece que talvez seja um código util então poderíamos colocar em um novo tipo. Trabalhando com uma "Liga" até agora tem sido com []Jogador
mas podemos criar um novo tipo chamado Liga
. Será mais fácil para outros desenvolvedores entenderem e assim podemos anexar métodos utéis dentro desse tipo para usarmos.
Dentro de liga.go
adicionamos o seguinte
type Liga []Jogador
func (l Liga) Find(nome string) *Jogador {
for i, p := range l {
if p.Nome==nome {
return &l[i]
}
}
return nil
}
Agora se qualquer um tiver uma Liga
facilmente será encontrado um dado jogador.
Mude nossa interface GuardaJogador
para retornar Liga
invés de []Jogador
. Tente e rode novamente os teste, você terá um problema de compilação por termos modificado a interface mas é fácil de resolver; apenas modifique o tipo de retorno de []Jogador
to Liga
.
Isso nos permite simplificar os métodos em SistemaDeArquivoDeArmazenamentoDoJogador
.
func (f *SistemaDeArquivoDeArmazenamentoDoJogador) (nome string) int {
jogador := f.PegaLiga().Find(nome)
if jogador != nil {
return jogador.Vitorias
}
return 0
}
func (f *SistemaDeArquivoDeArmazenamentoDoJogador) SalvaVitoria(nome string) {
liga := f.PegaLiga()
jogador :=liga.Find(nome)
if jogador != nil {
jogador.Vitorias++
}
f.bancoDeDados.Seek(0, 0)
json.NewEncoder(f.bancoDeDados).Encode(liga)
}
Isto parece bem melhor and podemos ver como talvez possamos encontrar como outras funcionalidades úteis em torno de Liga
podem ser refatoradas.
Agora precisamos tratar o cenário de salvar vitórias de novos jogadores.
t.Run("armazena vitorias de novos jogadores", func(t *testing.T) {
bancoDeDados, limpaBancoDeDados := criaArquivoTemporario(t, `[
{"Nome": "Cleo", "Vitorias": 10},
{"Nome": "Chris", "Vitorias": 33}]`)
defer limpaBancoDeDados()
armazenamento := SistemaDeArquivoDeArmazenamentoDoJogador{bancoDeDados}
armazenamento.SalvaVitoria("Pepper")
recebido := armazenamento.("Pepper")
esperado := 1
definePontuacaoIgual(t, recebido, esperado)
})
=== RUN TestSistemaDeArquivoDeArmazenamentoDoJogador/store_wins_for_new_players#01
--- FAIL: TestSistemaDeArquivoDeArmazenamentoDoJogador/store_wins_for_new_players#01 (0.00s)
SistemaDeArquivoDeArmazenamentoDoJogador_test.go:86: recebido 0 esperado 1
Apenas precisamos tratar o caso onde Find
returna nil
por não ter conseguido encontrar o jogador.
func (f *SistemaDeArquivoDeArmazenamentoDoJogador) SalvaVitoria(nome string) {
liga := f.PegaLiga()
jogador := liga.Find(nome)
if jogador != nil {
jogador.Wins++
} else {
liga = append(liga, Jogador{nome, 1})
}
f.bancoDeDados.Seek(0, 0)
json.NewEncoder(f.bancoDeDados).Encode(liga)
}
O caminho feliz parece bom então agora vamos tentar usar nossa nova armazenamento
no teste de integração. Isto nos dará mais confiança que o software funciona e então podemos deletar o redundante NovoArmazenamentoDeJogadorNaMemoria
.
Em TestRecordingWinsAndRetrievingThem
substitui a velha armazenamento.
bancoDeDados, limpaBancoDeDados := criaArquivoTemporario(t, "")
defer limpaBancoDeDados()
armazenamento := &SistemaDeArquivoDeArmazenamentoDoJogador{bancoDeDados}
Se você rodar o teste ele deve passar e agora podemos deletar NovoArmazenamentoDeJogadorNaMemoria
. main.go
terá problemas de compilação que nos motivará para agora usar nossa nova armazenamento no código "real".
package main
import (
"log"
"net/http"
"os"
)
const dbFileName = "game.db.json"
func main() {
db, err := os.OpenFile(dbFileName, os.O_RDWR|os.O_CREATE, 0666)
if err != nil {
log.Fatalf("problema abrindo %s %v", dbFileName, err)
}
armazenamento := &SistemaDeArquivoDeArmazenamentoDoJogador{db}
server := NovoServidorDoJogador(armazenamento)
if err := http.ListenAndServe(":5000", server); err != nil {
log.Fatalf("não foi possivel escutar na porta 5000 %v", err)
}
}
- Nós criamos um arquivo para nosso banco de dados.
- O 2º argumento para
os.OpenFile
permite definir as permissões para abrir um arquivo, no nosso casoO_RDWR
significa que queremos ler e escrever eos.O_CREATE
significa criar um arquivo se ele não existe. - O 3º argumento significa definir as permissões para o arquivo, no nosso caso, todos os usuários podem ler e escrever o arquivo. (Veja superuser.com para uma explicação mais detalhada).
Rodando o programa agora os dados permanecem em um arquivo entre reinicializações, uhu!
Toda vez que alguém chama PegaLiga()
ou ()
estamos lendo o arquivo do ínicio, e transformando ele em JSON. Não deveríamos ter que fazer isso porque SistemaDeArquivoDeArmazenamentoDoJogador
é inteiramente responsável pelo estado da liga; apenas queremos usar o arquivo para pegar o estado atual e atualiza-lo quando os dados mudarem.
Podemos criar um construtor que pode fazer parte dessa inicialização para nós e armazena a liga como um valor em nosso SistemaDeArquivoDeArmazenamentoDoJogador
para ser usado nas leitura então.
type SistemaDeArquivoDeArmazenamentoDoJogador struct {
bancoDeDados io.ReadWriteSeeker
liga Liga
}
func NovoSistemaDeArquivoDeArmazenamentoDoJogador(bancoDeDados io.ReadWriteSeeker) *SistemaDeArquivoDeArmazenamentoDoJogador {
bancoDeDados.Seek(0, 0)
liga, _ := NovaLiga(bancoDeDados)
return &SistemaDeArquivoDeArmazenamentoDoJogador{
bancoDeDados:bancoDeDados,
liga:liga,
}
}
Desta maneira precisamos ler do disco apenas uma vez . Podemos agora substituir todas as nossas chamadas anteriores para pegar a liga do disco e apenas usar f.liga
no lugar.
func (f *SistemaDeArquivoDeArmazenamentoDoJogador) PegaLiga() Liga {
return f.liga
}
func (f *SistemaDeArquivoDeArmazenamentoDoJogador) (nome string) int {
jogador := f.liga.Find(nome)
if jogador != nil {
return jogador.Vitorias
}
return 0
}
func (f *SistemaDeArquivoDeArmazenamentoDoJogador) SalvaVitoria(nome string) {
jogador := f.liga.Find(nome)
if jogador != nil {
jogador.Vitorias++
} else {
f.liga = append(f.liga, Jogador{nome, 1})
}
f.bancoDeDados.Seek(0, 0)
json.NewEncoder(f.bancoDeDados).Encode(f.liga)
}
Se você tentar e rodar os testes eles agora vão reclamar sobre inicializar SistemaDeArquivoDeArmazenamentoDoJogador
então fixe-o chamando nosso construtor.
Existe mais alguma ingenuidade na maneira como estamos lidando com arquivos que poderiamos criar um erro bem bobo futuramente.
Quando nós chamamos SalvaVitoria
nós procuramos
no ínicio do arquivo e então escrevemos o novo dado mas e se o novo dado for menor que o que estava lá antes?
Na nossa situação atual, isso é impossível. Nunca editamos ou apagamos pontuações, então os dados apenas podem aumentar, mas seria irresponsabilidade nossa deixar o código desse jeito, não é inimaginável que um cenário de apagamento poderia aparecer.
Como iremos testar isso então? O que precisamos fazer primeiro é refatorar nosso código, então separamos nossa preocupação do tipo de dados que escrevemos, da escrita. Podemos então testar isso separadamente para verificar se funciona como esperamos.
Agora iremos criar um novo tipo para encapsular nossa funcionalidade "quando escrevemos, vamos para o começo". Vou chama-la de Fita
. Criamos um novo arquivo com o seguinte
package main
import "io"
type fita struct {
arquivo io.ReadWriteSeeker
}
func (t *fita) Write(p []byte) (n int, err error) {
t.arquivo.Seek(0, 0)
return t.arquivo.Write(p)
}
Note que apenas implementamos Write
agora, já que encapsula a parte de Procura
. Isso que dizer que SistemaDeArquivoDeArmazenamentoDoJogador
pode ter uma referência a Writer
invés disso.
type SistemaDeArquivoDeArmazenamentoDoJogador struct {
bancoDeDados io.Writer
liga Liga
}
Atualize o construtor para usar fita
func NovoSistemaDeArquivoDeArmazenamentoDoJogador(bancoDeDados io.ReadWriteSeeker) *SistemaDeArquivoDeArmazenamentoDoJogador {
bancoDeDados.Seek(0, 0)
liga, _ := NovaLiga(bancoDeDados)
return &SistemaDeArquivoDeArmazenamentoDoJogador{
bancoDeDados: &fita{bancoDeDados},
liga: liga,
}
}
Finalmente, podemos ter o incrível beneficio que queríamos removendo Procura
de SalvaVitoria
. Sim, não parece muito, mas pelo menos isso significa que, se fizermos qualquer outro tipo de escritas, podemos confiar no nosso Write
para se comportar como precisamos. Além disso, agora podemos testar o potencial código problemático separadamente e corrigi-lo.
Agora vamos escrever o teste onde atualizamos todo o conteúdo de um arquivo com algo menor que o conteúdo original . Em fita_test.go
:
Vamos apenas criar um arquivo, tentar e escrever nele usando nossa fita, ler todo novamente e visualizar o que está no arquivo
func TestaFita_Escrita(t *testing.T) {
arquivo, limpa := criaArquivoTemporario(t, "12345")
defer limpa()
fita := &fita{arquivo}
fita.Write([]byte("abc"))
arquivo.Seek(0, 0)
novoConteudoDoArquivo, _ := ioutil.ReadAll(arquivo)
recebido := string(novoConteudoDoArquivo)
esperado := "abc"
if recebido != esperado {
t.Errorf("recebido '%s' esperado '%s'", recebido, esperado)
}
}
=== RUN TestaFita_Escrita
--- FAIL: TestaFita_Escrita (0.00s)
fita_test.go:23: recebido 'abc45' esperado 'abc'
Como pensamos! Ele apenas escreve os dados que queremos, deixando todo o resto.
os.File
tem uma função truncada que vai permitir que o arquivo seja esvaziado eficientemente. Devemos ser capazes de apenas chama-la para conseguir o que queremos.
Mude fita
para o seguinte
type fita struct {
file *os.File
}
func (t *fita) Write(p []byte) (n int, err error) {
t.file.Truncate(0)
t.file.Seek(0, 0)
return t.file.Write(p)
}
O compilador irá falhar em alguns lugares quando esperamos um io.ReadWriteSeeker
mas estamos mandando um *os.File
. Você deve ser capaz de corrigir esses problemas por conta própria, mas se ficar preso basta checar o código fonte.
Uma vez que você tenha refatorado nosso teste TestaFita_Escrita
deve estar passando!
Em SalvaVitoria
temos uma linhajson.NewEncoder(f.bancoDeDados).Encode(f.league)
.
Não precisamos criar um novo codificador toda vez que escrevemos, podemos inicializar um em nosso construtor e usa-lo.
Armazena uma referência para um Encoder
para nosso tipo.
type SistemaDeArquivoDeArmazenamentoDoJogador struct {
bancoDeDados *json.Encoder
liga Liga
}
Inicialize no construtor
func NovoSistemaDeArquivoDeArmazenamentoDoJogador(arquivo *os.File) *SistemaDeArquivoDeArmazenamentoDoJogador {
arquivo.Seek(0, 0)
liga, _ := NovaLiga(arquivo)
return &SistemaDeArquivoDeArmazenamentoDoJogador{
bancoDeDados: json.NewEncoder(&fita{arquivo}),
liga: liga,
}
}
Use em SalvaVitoria
.
É verdade que no geral deve ser favorecido não testar coisas privadas, uma vez que isso, as vezes, leva a testar coisas bastante acopladas para a implementação; que pode impedir refatoramento no futuro.
Entretanto,não devemos esquecer que testes nos dá confiança.
Não estamos confiantes que nossa implementação funcionaria se tivéssemos adicionado algum tipo de funcionalidade para editar ou deletar. Não queremos deixar o código assim, especialmente se isso foi trabalhado por mais de uma pessoa que talvez não estivesse ciente dos defeitos da nossa abordagem.
Finalmente, é apenas um teste! Se decidirmos mudar a maneira como funciona não será um desastre deletar o teste, mas teremos que ter pego o requisito para futuro mantenedores.
Começamos o código usando io.Reader
como o caminho mais fácil para testar de forma unitária nosso novo GuardaJogador
. A medida que desenvolvemos nosso código, movemos para io.ReadWriter
e então para io.ReadWriteSeeker
. Descobrimos então que não tinha nada na biblioteca padrão que implementasse isso além de *os.File
. Poderiamos ter decidido escrever o nosso ou usar um de código aberto, mas isso pareceu pragmático apenas para fazer arquivos temporários para os testes.
Finalmente, precisamos de Truncate
que também está no *os.File
. Isso seria uma opção para criar nossa própria interface pegando esses requisitos.
type ReadWriteSeekTruncate interface {
io.ReadWriteSeeker
Truncate(size int64) error
}
Mas o que isso está realmente nos dando? Lembre-se que não estamos mockando e isso é irrealista para um armazenamento de sistema de arquivos receber outro tipo além que um *os.File
então não precisamos do polimorfismo que interface nos dá.
Não tenha medo de cortar e mudar tipos e experimentar como temos aqui. O bom de usar uma linguagem tipada estaticamente é o compilador que ajudará você com toda mudança.
Antes de começarmos no ordenamento, devemos ter certeza que estamos contentes com nosso código atual e remover qualquer débito técnico que ainda resta. É um principio importante para trabalhar com software o mais rápido possível (mantenha-se fora do estado vermelho) mas isso não quer dizer que devemos ignorar os casos de erro!
Se voltarmos para SistemaDeArquivoDeArmazenamentoDoJogador.go
temos liga, _ := NovaLiga(f.bancoDeDados)
no nosso construtor.
NovaLiga
pode retornar um erro se é instável passar a liga do io.Reader
que fornecemos.
Era pragmático ignorar isso naquela hora como já tinhamos testes falhando. Se tivemos tentado lidar com isso ao mesmo tempo estamos lidando com duas coisas de uma vez.
Vamos fazer com que nosso construtor seja capaz de retornar um erro.
func NovoSistemaDeArquivoDeArmazenamentoDoJogador(arquivo *os.File) (*SistemaDeArquivoDeArmazenamentoDoJogador, error) {
arquivo.Seek(0, 0)
liga, err := NovaLiga(arquivo)
if err != nil {
return nil, fmt.Errorf("problema carregando o armazenamento do jogador de arquivo %s, %v", arquivo.Nome(), err)
}
return &SistemaDeArquivoDeArmazenamentoDoJogador{
bancoDeDados: json.NewEncoder(&fita{arquivo}),
liga: liga,
}, nil
}
Lembre-se que é importante retornar mensagens de erro úteis (assim como nossos testes). As pessoas na internet dizem que a maioria dos códigos em Go é
if err != nil {
return err
}
Isso é 100% não idiomático. Adicionando informação contextual (i.e o que você estava fazendo que causou o erro\) para suas mensagens de erro facilita manipular o software.
Se você tentar e compilar, vai ver alguns erros.
./main.go:18:35: multiple-value NovoSistemaDeArquivoDeArmazenamentoDoJogador() in single-value context
./SistemaDeArquivoDeArmazenamentoDoJogador_test.go:35:36: multiple-value NovoSistemaDeArquivoDeArmazenamentoDoJogador() in single-value context
./SistemaDeArquivoDeArmazenamentoDoJogador_test.go:57:36: multiple-value NovoSistemaDeArquivoDeArmazenamentoDoJogador() in single-value context
./SistemaDeArquivoDeArmazenamentoDoJogador_test.go:70:36: multiple-value NovoSistemaDeArquivoDeArmazenamentoDoJogador() in single-value context
./SistemaDeArquivoDeArmazenamentoDoJogador_test.go:85:36: multiple-value NovoSistemaDeArquivoDeArmazenamentoDoJogador() in single-value context
./server_integration_test.go:12:35: multiple-value NovoSistemaDeArquivoDeArmazenamentoDoJogador() in single-value context
Em main vamos querer sair do programa, imprimindo o erro.
armazenamento, err := NovoSistemaDeArquivoDeArmazenamentoDoJogador(db)
if err != nil {
log.Fatalf("problema criando o sistema de arquivo do armazenamento do jogador, %v ", err)
}
Nos nossos testes podemos garantir que não exista erro . Podemos fazer uma função auxiliar para ajudar com isto.
func defineSemErro(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("não esperava um erro mas obteve um, %v", err)
}
}
Trabalhe nos outros problemas de compilação usando essa auxiliar. Finalmente, você deve ter um teste falhando
=== RUN TestRecordingWinsAndRetrievingThem
--- FAIL: TestRecordingWinsAndRetrievingThem (0.00s)
server_integration_test.go:14: não esperava um erro mas obteve um, problema carregando o armazenamento do jogador de arquivo /var/folders/nj/r_ccbj5d7flds0sf63yy4vb80000gn/T/db841037437, problem parsing league, EOF
Não podemos analisar a liga porque o arquivo está vazio.Não estávamos obtendo erros antes porque sempre os ignoramos.
Vamos corrigir nosso grande teste de integração colocando algum JSON válido nele e então podemos escrever um teste específico para este cenário.
func TestRecordingWinsAndRetrievingThem(t *testing.T) {
bancoDeDados, limpaBancoDeDados := criaArquivoTemporario(t, `[]`)
//etc...
Agora todos os testes estão passando, precisamos então lidar com o cenário onde o arquivo está vazio.
t.Run("funciona com um arquivo vazio", func(t *testing.T) {
bancoDeDados, limpaBancoDeDados := criaArquivoTemporario(t, "")
defer limpaBancoDeDados()
_, err := NovoSistemaDeArquivoDeArmazenamentoDoJogador(bancoDeDados)
defineSemErro(t, err)
})
=== RUN TestSistemaDeArquivoDeArmazenamentoDoJogador/works_with_an_empty_file
--- FAIL: TestSistemaDeArquivoDeArmazenamentoDoJogador/works_with_an_empty_file (0.00s)
SistemaDeArquivoDeArmazenamentoDoJogador_test.go:108: não esperava um erro mas obteve um, problema carregando o armazenamento do jogador de arquivo /var/folders/nj/r_ccbj5d7flds0sf63yy4vb80000gn/T/db019548018, problem parsing league, EOF
Mude nosso construtor para o seguinte
func NovoSistemaDeArquivoDeArmazenamentoDoJogador(arquivo *os.File) (*SistemaDeArquivoDeArmazenamentoDoJogador, error) {
arquivo.Seek(0, 0)
info, err := arquivo.Stat()
if err != nil {
return nil, fmt.Errorf("problema ao usar o arquivo %s, %v", arquivo.Nome(), err)
}
if info.Size() == 0 {
file.Write([]byte("[]"))
file.Seek(0, 0)
}
liga, err := NovaLiga(file)
if err != nil {
return nil, fmt.Errorf("problema carregando armazenamento de jogador do aquivo %s, %v", arquivo.Nome(), err)
}
return &SistemaDeArquivoDeArmazenamentoDoJogador{
bancoDeDados: json.NewEncoder(&fita{file}),
liga: liga,
}, nil
}
Arquivo.Stat
retorna estatísticas do nosso arquivo. Isto nos permite checar o tamanho do arquivo, se está vazio podemos Escrever
um array JSON vazio e Busca
de volta para o ínicio, pronto para o resto do arquivo.
Nosso construtor está um pouco bagunçado, podemos extrair o código de inicialização em uma função
func iniciaArquivoBDDeJogador(arquivo *os.File) error {
arquivo.Seek(0, 0)
info, err := arquivo.Stat()
if err != nil {
return fmt.Errorf("problema ao usar arquivo %s, %v", file.Name(), err)
}
if info.Size()==0 {
arquivo.Write([]byte("[]"))
arquivo.Seek(0, 0)
}
return nil
}
func NovoSistemaDeArquivoDeArmazenamentoDoJogador(arquivo *os.File) (*SistemaDeArquivoDeArmazenamentoDoJogador, error) {
err := iniciaArquivoBDDeJogador(file)
if err != nil {
return nil, fmt.Errorf("problema inicializando arquivo do jogador, %v", err)
}
liga, err := Nova(liga)
if err != nil {
return nil, fmt.Errorf("problema carregando armazenamento de jogador do arquivo %s, %v", arquivo.Nome(), err)
}
return &SistemaDeArquivoDeArmazenamentoDoJogador{
bancoDeDados: json.NewEncoder(&fita{file}),
liga: liga,
}, nil
}
Nossa dona do produto quer que /liga
retorne os jogadores ordenados pela pontuação.
A principal decisão a ser feita é onde isso deve acontecer no software. Se estamos usando um "verdadeiro" banco de dados usariamos coisas como ORDER BY
, então o ordenamento é super rápido por esse motivo parece que a implementção de GuardaJogador
deve ser responsável.
Podemos atualizar a inserção no nosso primeiro teste em TestaArmazenamentoDeSistemaDeArquivo
t.Run("liga ordernada", func(t *testing.T) {
bancoDeDados, limpaBancoDeDados := criaArquivoTemporario(t, `[
{"Nome": "Cleo", "Vitorias": 10},
{"Nome": "Chris", "Vitorias": 33}]`)
defer limpaBancoDeDados()
armazenamento := SistemaDeArquivoDeArmazenamentoDoJogador{bancoDeDados}
recebido := armazenamento.PegaLiga()
esperado:= []Jogador{
{"Chris", 33},
{"Cleo", 10},
}
defineLiga(t, recebido, esperado)
// read again
recebido = armazenamento.PegaLiga()
defineLiga(t, recebido, esperado)
})
A ordem que está sendo recebida do JSON está errada e nosso esperado
vai checar que é retornado para o chamador na ordem correta.
=== RUN TestSistemaDeArquivoDeArmazenamentoDoJogador/league_from_a_reader,_sorted
--- FAIL: TestSistemaDeArquivoDeArmazenamentoDoJogador/league_from_a_reader,_sorted (0.00s)
SistemaDeArquivoDeArmazenamentoDoJogador_test.go:46: recebido [{Cleo 10} {Chris 33}] esperado [{Chris 33} {Cleo 10}]
SistemaDeArquivoDeArmazenamentoDoJogador_test.go:51: recebido [{Cleo 10} {Chris 33}] esperado [{Chris 33} {Cleo 10}]
func (f *SistemaDeArquivoDeArmazenamentoDoJogador) PegaLiga() League {
sort.Slice(f.liga, func(i, j int) bool {
return f.liga[i].Vitorias > f.liga[j].Vitorias
})
return f.liga
}
Slice ordena a parte fornecida dada a menor função fornecida
Moleza!
- A interface
Seeker
e sua relação comReader
eWriter
. - Trabalhando com arquivos.
- Criando uma auxiliar fácil de usar para testes com arquivos que escondem todas as bagunças.
sort.Slice
para ordenar partes.- Usando o compilador para nos ajudar a fazer mudanças estruturais de forma segura na aplicação.
- Maior partes das regras em engenharia de software não são realmente regras, apenas boas práticas que funcionam 80% do tempo.
- Descobrimos um cenário onde nos "regras" anteriores de não testar funções internas não foi útil, então quebramos essa regra.
- É importante entender o que estamos perdendo e ganhado ao quebrar as regras . No nosso caso, não tinha problema porque era apenas um teste e seria muito difícil exercitar o cenário contrário.
- Para poder quebrar as regras, você deve entende-las. Uma analogia é com aprender a tocar violão. Não importa quão criativo você seja, você deve entender e praticar os fundamentos.
- Temos uma API HTTP onde é possível criar jogadores e aumentar a pontuação deles..
- Podemos retornar uma liga das pontuações de todos como JSON.
- O dado é mantindo com um arquivo JSON.