Na edição de 2022 da GopherCon Brasil, tive o prazer de palestrar sobre Fuzz Test. Foi muito bacana, pois durante a palestra, assim como nos corredores do evento, fizeram vários questionamentos que eu ainda não tinha feito sobre essa feature do Go.
Se você ainda não conhece esse tipo de teste, convido você a ler um post que publicamos aqui no blog (link para o post) onde explicamos melhor o assunto.
O que vou tratar nesse post é o resultado das perguntas feitas no evento mais um link que o Ricardo Maricato me enviou.
Para ver uma das formas de implementar o Fuzz Test para requests HTTP, vamos implementar um endpoint para validação dos dados de uma pessoa.
Vamos começar criando uma struct com um método de validação, e algumas variáveis para armazenar os erros de validação que podemos ter.
var ( ErrNameRequired = errors.New("Name is required") ErrEmailRequired = errors.New("Email is required") ErrPwdRequired = errors.New("Password is required") ErrPwdMinChars = errors.New("Password minimum is 6 chars") ErrAgeMin = errors.New("Minimum age is 18") ) type Person struct { Name string `json:"name"` Email string `json:"email"` Password string `json:"password"` Age uint8 `json:"age"` } func (p *Person) IsValid() error { if p.Name == "" { return ErrNameRequired } if p.Email == "" { return ErrEmailRequired } if p.Password == "" { return ErrPwdRequired } if len(p.Password) < 6 { return ErrPwdMinChars } if p.Age < 18 { return ErrAgeMin } return nil }
Agora, vamos implementar o handler que irá fazer o decode do payload e executar as validações necessárias.
func validate(rw http.ResponseWriter, r *http.Request) { data, err := ioutil.ReadAll(r.Body) if err != nil { http.Error(rw, http.StatusText(http.StatusInternalServerError), http.StatusInsufficientStorage) return } var person Person err = json.Unmarshal(data, &person) if err != nil { http.Error(rw, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } err = person.IsValid() if err != nil { rw.WriteHeader(http.StatusBadRequest) fmt.Fprintf(rw, err.Error()) return } rw.Header().Add("Content-Type", "application/json") rw.WriteHeader(http.StatusOK) fmt.Fprintf(rw, "") }
Por último, precisamos implementar nossa função main. O arquivo completo ficará assim:
package main import ( "encoding/json" "errors" "fmt" "io/ioutil" "log" "net/http" ) var ( ErrNameRequired = errors.New("Name is required") ErrEmailRequired = errors.New("Email is required") ErrPwdRequired = errors.New("Password is required") ErrPwdMinChars = errors.New("Password minimum is 6 chars") ErrAgeMin = errors.New("Minimum age is 18") ) type Person struct { Name string `json:"name"` Email string `json:"email"` Password string `json:"password"` Age uint8 `json:"age"` } func (p *Person) IsValid() error { if p.Name == "" { return ErrNameRequired } if p.Email == "" { return ErrEmailRequired } if p.Password == "" { return ErrPwdRequired } if len(p.Password) < 6 { return ErrPwdMinChars } if p.Age < 18 { return ErrAgeMin } return nil } func validate(rw http.ResponseWriter, r *http.Request) { data, err := ioutil.ReadAll(r.Body) if err != nil { http.Error(rw, http.StatusText(http.StatusInternalServerError), http.StatusInsufficientStorage) return } var person Person err = json.Unmarshal(data, &person) if err != nil { http.Error(rw, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } err = person.IsValid() if err != nil { rw.WriteHeader(http.StatusBadRequest) fmt.Fprintf(rw, err.Error()) return } rw.Header().Add("Content-Type", "application/json") rw.WriteHeader(http.StatusOK) fmt.Fprintf(rw, "") } func main() { http.HandleFunc("/person/validate", validate) log.Fatal(http.ListenAndServe(":8080", nil)) }
Maravilha! Agora que já temos nosso endpoint implementado, vamos começar escrever a nossa função de Fuzz Test.
Podemos pensar nossa função em cinco blocos:
- Criar alguns test cases;
- Adicionar os test cases como Seed para o fuzz;
- Mapear os erros conhecidos;
- Iniciar um servidor para testes;
- Função que irá receber os inputs gerados.
func FuzzValidate(f *testing.F) { // criar test cases testcases := []Person{ {"Tiago Temporin", "tiago@aprendagolang.com.br", "1234", 32}, {"Maria Castro", "maria.castro@algumdominio.com", "1Av6s#", 22}, {"Daniela Fernandez", "dani@teste.com.br", "1234", 16}, } // adicionar test cases como seed for _, tc := range testcases { data, _ := json.Marshal(tc) f.Add(data) } // mapear os erros conhecidos knowErrs := map[string]bool{ ErrNameRequired.Error(): true, ErrEmailRequired.Error(): true, ErrPwdRequired.Error(): true, ErrPwdMinChars.Error(): true, ErrAgeMin.Error(): true, } // iniciar servidor para test srv := httptest.NewServer(http.HandlerFunc(validate)) defer srv.Close() // função que irá receber os inputs gerados f.Fuzz(func(t *testing.T, data []byte) {
Dentro da função que irá receber os inputs, vamos realizar um POST com o input que foi gerado e depois validar se o status code é diferente de 200.
Caso seja diferente de 200, precisamos validar se o erro retornado foi um dos erros que mapeamos ou algo não esperado por nós.
O arquivo completo ficará assim:
package main import ( "bytes" "encoding/json" "fmt" "io/ioutil" "net/http" "net/http/httptest" "testing" ) func FuzzValidate(f *testing.F) { // criar test cases testcases := []Person{ {"Tiago Temporin", "tiago@aprendagolang.com.br", "1234", 32}, {"Maria Castro", "maria.castro@algumdominio.com", "1Av6s#", 22}, {"Daniela Fernandez", "dani@teste.com.br", "1234", 16}, } // adicionar test cases como seed for _, tc := range testcases { data, _ := json.Marshal(tc) f.Add(data) } // mapear os erros conhecidos knowErrs := map[string]bool{ ErrNameRequired.Error(): true, ErrEmailRequired.Error(): true, ErrPwdRequired.Error(): true, ErrPwdMinChars.Error(): true, ErrAgeMin.Error(): true, } // iniciar servidor para test srv := httptest.NewServer(http.HandlerFunc(validate)) defer srv.Close() // função que irá receber os inputs gerados f.Fuzz(func(t *testing.T, data []byte) { // realizar POST resp, err := http.DefaultClient.Post(srv.URL, "application/json", bytes.NewBuffer(data)) if err != nil { t.Errorf("Error: %v", err) } // validar status code if resp.StatusCode != http.StatusOK { // ler response body body, _ := ioutil.ReadAll(resp.Body) // verificar se é um erro conhecido if _, ok := knowErrs[string(body)]; ok { t.Skip(fmt.Sprintf("skiping knowing error: %s", body)) } t.Errorf("Expected status code %d, got %d with error :%s", http.StatusOK, resp.StatusCode, body) } }) }
Ao executar nosso teste com o comando go test -fuzz . -v obtive o seguinte resultado:

Dando uma olhada na pasta testdata/fuzz/FuzzValidate, conseguimos verificar qual foi o input da request que falhou.
go test fuzz v1 []byte("0")
Huummm… realmente eu não estava esperando um input fora do formato. Para resolver esse problema e deixar nosso código testável, vamos adicionar uma nova variável de erro, modificar o status e a mensagem de erro quando o payload não está no formato esperado.
var ( ... ErrInvalidPayload = errors.New("Invalid payload") ) ... err = json.Unmarshal(data, &person) if err != nil { rw.WriteHeader(http.StatusUnprocessableEntity) fmt.Fprintf(rw, ErrInvalidPayload.Error()) return } ...
Precisamos adicionar esse novo erro a nossa lista de erros conhecidos da função de teste.
... knowErrs := map[string]bool{ ErrNameRequired.Error(): true, ErrEmailRequired.Error(): true, ErrPwdRequired.Error(): true, ErrPwdMinChars.Error(): true, ErrAgeMin.Error(): true, ErrInvalidPayload.Error(): true, // novo erro } ...
Ao executar novamente o go test -fuzz . -v
por mais de 1 minuto, não obtive mais nenhum erro.
Esse tipo de teste que escrevemos irá nos ajudar a validar payloads inesperados. Após esse cenário, podemos escrever um outro tipo de fuzz test para gerar inputs inesperados em um payload esperado.
Porém, para não ficar muito extenso, esse outro modo ficará para outro post.
Enquanto isso, se você quer saber mais sobre como fazer testes em Go, confira nosso curso “Testes e Benchmark“.
No mais, deixe suas dúvidas nos comentários.
Até a próxima.