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() {
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() }
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 }
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.