Testes para código e código para testes

Em linguagens dinâmicas, como python e javascript, é possível substituir métodos e classes nos módulos diretamente durante a operação. Isso é muito conveniente para testes - você pode simplesmente colocar "patches" que excluirão lógica pesada ou desnecessária no contexto deste teste.


Mas o que fazer em C ++? Vai? Java? Nesses idiomas, o código não pode ser modificado para testes em tempo real, e a criação de patches requer ferramentas separadas.


Nesses casos, você deve escrever especificamente o código para que seja testado. Este não é apenas um desejo maníaco de ver 100% de cobertura em seu projeto. Este é um passo para escrever código de qualidade e suportado.


Neste artigo, tentarei falar sobre as principais idéias por trás da escrita de código testável e mostrar como elas podem ser usadas com um exemplo de um programa go simples.


Programa não complicado


Escreveremos um programa simples para fazer uma solicitação à API do VK. Este é um programa bastante simples que gera uma solicitação, faz, lê a resposta, decodifica a resposta do JSON em uma estrutura e exibe o resultado para o usuário.


package main import ( "encoding/json" "fmt" "io/ioutil" "net/http" "net/url" ) const token = "token here" func main() { //     var requestURL = fmt.Sprintf( "https://api.vk.com/method/%s?&access_token=%s&v=5.95", "users.get", token, ) //   resp, err := http.PostForm(requestURL, nil) //   if err != nil { fmt.Println(err) return } //       defer resp.Body.Close() //     body, err := ioutil.ReadAll(resp.Body) //   if err != nil { fmt.Println(err) return } //      var result struct { Response []struct { ID int `json:"id"` FirstName string `json:"first_name"` LastName string `json:"last_name"` } `json:"response"` } //        err = json.Unmarshal(body, &result) //   if err != nil { fmt.Println(err) return } // ,    if len(result.Response) < 1 { fmt.Println("No values in response array") return } //    fmt.Printf( "Your id: %d\nYour full name: %s %s\n", result.Response[0].ID, result.Response[0].FirstName, result.Response[0].LastName, ) } 

Como profissionais de nossa área, decidimos que era necessário escrever testes para nossa aplicação. Crie um arquivo de teste ...


 package main import ( "testing" ) func Test_Main(t *testing.T) { main() } 

Não parece muito atraente. Essa verificação é um simples lançamento de um aplicativo que não podemos influenciar. Não podemos excluir o trabalho com a rede, verificar a operacionalidade quanto a vários erros e até substituir o token para verificação falhará. Vamos tentar descobrir como melhorar este programa.


Padrão de injeção de dependência


Primeiro, você precisa implementar o padrão "injeção de dependência" .


 type VKClient struct { Token string } func (client VKClient) ShowUserInfo() { var requestURL = fmt.Sprintf( "https://api.vk.com/method/%s?&access_token=%s&v=5.95", "users.get", client.Token, ) // ... } 

Adicionando uma estrutura, criamos uma dependência (chave de acesso) para o aplicativo, que pode ser transferida de diferentes fontes, o que evita os valores "com fio" e simplifica o teste.


 package example import ( "testing" ) const workingToken = "workingToken" func Test_ShowUserInfo_Successful(t *testing.T) { client := VKClient{workingToken} client.ShowUserInfo() } func Test_ShowUserInfo_EmptyToken(t *testing.T) { client := VKClient{""} client.ShowUserInfo() } 

Separação de recebimento de informações e sua saída


Agora apenas uma pessoa pode cometer um erro, e somente se souber qual deve ser a conclusão. Para resolver esse problema, é necessário não enviar informações diretamente para o fluxo de saída, mas adicionar métodos separados para obter informações e sua saída. Essas duas partes independentes serão mais fáceis de verificar e manter.


Vamos criar o método GetUserInfo() , que retornará uma estrutura com informações do usuário e um erro (se isso aconteceu). Como esse método não produz nada, os erros que ocorrerem serão transmitidos ainda mais sem saída, para que o código que precisa dos dados descubra a situação.


 type UserInfo struct { ID int `json:"id"` FirstName string `json:"first_name"` LastName string `json:"last_name"` } func (client VKClient) GetUserInfo() (UserInfo, error) { var requestURL = fmt.Sprintf( "https://api.vk.com/method/%s?&access_token=%s&v=5.95", "users.get", client.Token, ) resp, err := http.PostForm(requestURL, nil) if err != nil { return UserInfo{}, err } // ... var result struct { Response []UserInfo `json:"response"` } // ... return result.Response[0], nil } 

Altere ShowUserInfo() para que ele use GetUserInfo() e manipule erros.


 func (client VKClient) ShowUserInfo() { userInfo, err := client.GetUserInfo() if err != nil { fmt.Println(err) return } fmt.Printf( "Your id: %d\nYour full name: %s %s\n", userInfo.ID, userInfo.FirstName, userInfo.LastName, ) } 

Agora, nos testes, você pode verificar se a resposta correta foi recebida do servidor e, se o token estiver incorreto, será retornado um erro.


 func Test_GetUserInfo_Successful(t *testing.T) { client := VKClient{workingToken} userInfo, err := client.GetUserInfo() if err != nil { t.Fatal(err) } if userInfo.ID == 0 { t.Fatal("ID is empty") } if userInfo.FirstName == "" { t.Fatal("FirstName is empty") } if userInfo.LastName == "" { t.Fatal("LastName is empty") } } func Test_ShowUserInfo_EmptyToken(t *testing.T) { client := VKClient{""} _, err := client.GetUserInfo() if err == nil { t.Fatal("Expected error but found <nil>") } if err.Error() != "No values in response array" { t.Fatalf(`Expected "No values in response array", but found "%s"`, err) } } 

Além de atualizar os testes existentes, você precisa adicionar novos testes para o método ShowUserInfo() .


 func Test_ShowUserInfo(t *testing.T) { client := VKClient{workingToken} client.ShowUserInfo() } func Test_ShowUserInfo_WithError(t *testing.T) { client := VKClient{""} client.ShowUserInfo() } 

Alternativas personalizadas


Os testes para ShowUserInfo() assemelham ao que tentamos evitar inicialmente. Nesse caso, o único ponto do método é enviar informações para o fluxo de saída padrão. Por um lado, você pode tentar redefinir os.Stdout e verificar a saída, parece uma solução redundante quando você pode agir com mais elegância.


Em vez de usar o fmt.Printf , você pode usar o fmt.Fprintf , que permite a saída para qualquer io.Writer . os.Stdout implementa essa interface, o que nos permite substituir fmt.Printf(text) por fmt.Fprintf(os.Stdout, text) . Depois disso, podemos colocar os.Stdout em um campo separado, que pode ser definido com os valores desejados (para testes - uma string, para trabalho - um fluxo de saída padrão).


Como a capacidade de alterar o Writer para saída raramente será usada, principalmente para testes, faz sentido definir um valor padrão. Além disso, faremos isso - tornar o tipo VKClient exportável e criar uma função construtora para ele.


 type vkClient struct { Token string OutputWriter io.Writer } func CreateVKClient(token string) vkClient { return vkClient{ token, os.Stdout, } } 

Na função ShowUserInfo() , substituímos as chamadas de Print por Fprintf .


 func (client vkClient) ShowUserInfo() { userInfo, err := client.GetUserInfo() if err != nil { fmt.Fprintf(client.OutputWriter, err.Error()) return } fmt.Fprintf( client.OutputWriter, "Your id: %d\nYour full name: %s %s\n", userInfo.ID, userInfo.FirstName, userInfo.LastName, ) } 

Agora você precisa atualizar os testes para que eles criem o cliente usando o construtor e instale outro gravador, quando necessário.


 func Test_ShowUserInfo(t *testing.T) { client := CreateVKClient(workingToken) buffer := bytes.NewBufferString("") client.OutputWriter = buffer client.ShowUserInfo() result, _ := ioutil.ReadAll(buffer) matched, err := regexp.Match( `Your id: \d+\nYour full name: [^\n]+\n`, result, ) if err != nil { t.Fatal(err) } if !matched { t.Fatalf(`Expected match but failed with "%s"`, result) } } func Test_ShowUserInfo_WithError(t *testing.T) { client := CreateVKClient("") buffer := bytes.NewBufferString("") client.OutputWriter = buffer client.ShowUserInfo() result, _ := ioutil.ReadAll(buffer) if string(result) != "No values in response array" { t.Fatal("Wrong error") } } 

Para cada teste em que produzimos algo, criamos um buffer que desempenhará o papel de um fluxo de saída padrão. Depois que a função é executada, verifica-se que os resultados correspondem às nossas expectativas - usando expressões regulares ou uma comparação simples.


Por que estou usando expressões regulares? Para que os testes funcionem com qualquer token válido que forneço ao programa, independentemente do nome e ID do usuário.


Padrão de injeção de dependência - 2


No momento, o programa tem uma cobertura de 86,4%. Por que não 100%? Não podemos provocar erros de http.PostForm() , ioutil.ReadAll() e json.Unmarshal() , o que significa que não podemos verificar cada " return UserInfo, err ".


Para se dar ainda mais controle sobre a situação, você precisa criar uma interface na qual o http.Client se ajustará, cuja implementação será no vkClient e usado para operações de rede. Para nós, na interface, apenas um método é PostForm - PostForm .


 type Networker interface { PostForm(string, url.Values) (*http.Response, error) } type vkClient struct { Token string OutputWriter io.Writer Networker Networker } func CreateVKClient(token string) vkClient { return vkClient{ token, os.Stdout, &http.Client{}, } } 

Tal movimento elimina a necessidade de executar operações de rede em geral. Agora podemos simplesmente retornar os dados esperados do VKontakte usando o falso Networker . Obviamente, não se livre de testes que verificarão solicitações ao servidor, mas não há necessidade de fazer solicitações em cada teste.


Criaremos implementações para o Networker e Reader falsos, para que possamos testar os erros em cada caso - mediante solicitação, ao ler o corpo e durante a desserialização. Se queremos um erro ao chamar o PostForm, simplesmente o retornamos neste método. Se queremos um erro
ao ler o corpo da resposta - é necessário retornar um Reader falso, o que gerará um erro. E se precisamos que o erro se manifeste durante a desserialização, retornamos a resposta com uma string vazia no corpo. Se não queremos erros, simplesmente retornamos o corpo com o conteúdo especificado.


 type fakeReader struct{} func (fakeReader) Read(p []byte) (n int, err error) { return 0, errors.New("Error on read") } type fakeNetworker struct { ErrorOnPostForm bool ErrorOnBodyRead bool ErrorOnUnmarchal bool RawBody string } func (fn *fakeNetworker) PostForm(string, url.Values) (*http.Response, error) { if fn.ErrorOnPostForm { return nil, fmt.Errorf("Error on PostForm") } if fn.ErrorOnBodyRead { return &http.Response{Body: ioutil.NopCloser(fakeReader{})}, nil } if fn.ErrorOnUnmarchal { fakeBody := ioutil.NopCloser(bytes.NewBufferString("")) return &http.Response{Body: fakeBody}, nil } fakeBody := ioutil.NopCloser(bytes.NewBufferString(fn.RawBody)) return &http.Response{Body: fakeBody}, nil } 

Para cada situação problemática, adicionamos um teste. Eles criarão o Networker falso com as configurações necessárias, de acordo com as quais ele lançará um erro em um determinado momento. Depois disso, chamamos a função a ser verificada e garantimos que ocorreu um erro e esperávamos esse erro.


 func Test_GetUserInfo_ErrorOnPostForm(t *testing.T) { client := CreateVKClient(workingToken) client.Networker = &fakeNetworker{ErrorOnPostForm: true} _, err := client.GetUserInfo() if err == nil { t.Fatal("Expected error but none found") } if err.Error() != "Error on PostForm" { t.Fatalf(`Expected "Error on PostForm" but got "%s"`, err.Error()) } } func Test_GetUserInfo_ErrorOnBodyRead(t *testing.T) { client := CreateVKClient(workingToken) client.Networker = &fakeNetworker{ErrorOnBodyRead: true} _, err := client.GetUserInfo() if err == nil { t.Fatal("Expected error but none found") } if err.Error() != "Error on read" { t.Fatalf(`Expected "Error on read" but got "%s"`, err.Error()) } } func Test_GetUserInfo_ErrorOnUnmarchal(t *testing.T) { client := CreateVKClient(workingToken) client.Networker = &fakeNetworker{ErrorOnUnmarchal: true} _, err := client.GetUserInfo() if err == nil { t.Fatal("Expected error but none found") } const expectedError = "unexpected end of JSON input" if err.Error() != expectedError { t.Fatalf(`Expected "%s" but got "%s"`, expectedError, err.Error()) } } 

Usando o campo RawBody , RawBody pode se livrar das solicitações de rede (basta retornar o que esperamos receber do VKontakte). Isso pode ser necessário para evitar exceder os limites de consulta durante o teste ou acelerar os testes.


Sumário


Após todas as operações no projeto, recebemos um pacote de 91 linhas (+170 linhas de testes), que suporta saída para qualquer io.Writer , permite usar métodos alternativos de trabalho com a rede (usando o adaptador para nossa interface), no qual existe um método como para produzir dados e obtê-los. O projeto tem 100% de cobertura. Os testes verificam totalmente todas as respostas de linha e aplicativo a todos os erros possíveis.


Cada etapa no caminho para 100% de cobertura aumentou a modularidade, a manutenção e a confiabilidade do aplicativo, portanto, não há nada errado com os testes que determinam a estrutura do pacote.


A testabilidade de qualquer código é uma qualidade que não aparece no ar. A testabilidade aparece quando o desenvolvedor usa padrões adequadamente em situações apropriadas e escreve código personalizado e modular. A principal tarefa foi mostrar o processo de raciocínio ao executar programas de refatoração. Pensamentos semelhantes podem se estender a qualquer aplicativo e biblioteca, além de outros idiomas.

Source: https://habr.com/ru/post/pt452702/


All Articles