Tests de code et code de tests

Dans les langages dynamiques, comme python et javascript, il est possible de remplacer les méthodes et les classes dans les modules directement pendant le fonctionnement. Ceci est très pratique pour les tests - vous pouvez simplement mettre des "correctifs" qui excluront une logique lourde ou inutile dans le contexte de ce test.


Mais que faire en C ++? Allez? Java? Dans ces langues, le code ne peut pas être modifié pour des tests à la volée et la création de correctifs nécessite des outils séparés.


Dans de tels cas, vous devez écrire spécifiquement le code afin qu'il soit testé. Ce n'est pas seulement un désir maniaque de voir une couverture à 100% dans votre projet. Il s'agit d'une étape vers l'écriture de code pris en charge et de qualité.


Dans cet article, je vais essayer de parler des principales idées derrière l'écriture de code testable et montrer comment elles peuvent être utilisées avec un exemple d'un simple programme go.


Programme simple


Nous allons écrire un programme simple pour faire une demande à l'API VK. Il s'agit d'un programme assez simple qui génère une demande, la fait, lit la réponse, décode la réponse de JSON dans une structure et affiche le résultat à l'utilisateur.


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, ) } 

En tant que professionnels de notre domaine, nous avons décidé qu'il était nécessaire de rédiger des tests pour notre application. Créer un fichier de test ...


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

Ça n'a pas l'air très attrayant. Cette vérification est un simple lancement d'une application que nous ne pouvons pas influencer. Nous ne pouvons pas exclure le travail avec le réseau, vérifier l'opérabilité pour diverses erreurs et même remplacer le jeton pour que la vérification échoue. Essayons de comprendre comment améliorer ce programme.


Modèle d'injection de dépendance


Vous devez d'abord implémenter le modèle "injection de dépendance" .


 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, ) // ... } 

En ajoutant une structure, nous avons créé une dépendance (clé d'accès) pour l'application, qui peut être transférée depuis différentes sources, ce qui évite les valeurs "câblées" et simplifie les tests.


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

Séparation des informations reçues et de leur sortie


Maintenant, seule une personne peut faire une erreur, et seulement si elle sait quelle devrait être la conclusion. Pour résoudre ce problème, il est nécessaire de ne pas sortir directement les informations dans le flux de sortie, mais d'ajouter des méthodes distinctes pour obtenir les informations et leur sortie. Ces deux parties indépendantes seront plus faciles à vérifier et à entretenir.


Créons la méthode GetUserInfo() , qui renverra une structure avec des informations utilisateur et une erreur (si cela s'est produit). Étant donné que cette méthode ne produit rien, les erreurs qui se produisent seront transmises davantage sans sortie, de sorte que le code qui a besoin des données définira la situation.


 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 } 

Modifiez ShowUserInfo() afin qu'il utilise GetUserInfo() et gère les erreurs.


 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, ) } 

Maintenant, dans les tests, vous pouvez vérifier que la bonne réponse est reçue du serveur et si le jeton est incorrect, une erreur est renvoyée.


 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) } } 

En plus de mettre à jour les tests existants, vous devez ajouter de nouveaux tests pour la méthode ShowUserInfo() .


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

Alternatives personnalisées


Les tests de ShowUserInfo() ressemblent à ce que nous avons essayé de faire initialement. Dans ce cas, le seul point de la méthode est de transmettre des informations au flux de sortie standard. D'une part, vous pouvez essayer de redéfinir os.Stdout et vérifier la sortie, cela ressemble à une solution trop redondante lorsque vous pouvez agir plus élégamment.


Au lieu d'utiliser fmt.Printf , vous pouvez utiliser fmt.Fprintf , qui vous permet de sortir vers n'importe quel io.Writer . os.Stdout implémente cette interface, ce qui nous permet de remplacer fmt.Printf(text) par fmt.Fprintf(os.Stdout, text) . Après cela, nous pouvons mettre os.Stdout dans un champ séparé, qui peut être défini sur les valeurs souhaitées (pour les tests - une chaîne, pour le travail - un flux de sortie standard).


Étant donné que la possibilité de modifier Writer pour la sortie sera rarement utilisée, principalement pour les tests, il est logique de définir une valeur par défaut. Pour ce faire, nous allons le faire - rendre le type VKClient exportable et créer une fonction constructeur pour lui.


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

Dans la fonction ShowUserInfo() , nous remplaçons les appels Print par 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, ) } 

Vous devez maintenant mettre à jour les tests afin qu'ils créent le client à l'aide du constructeur et installent un autre Writer si nécessaire.


 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") } } 

Pour chaque test où nous sortons quelque chose, nous créons un tampon qui jouera le rôle d'un flux de sortie standard. Une fois la fonction exécutée, il est vérifié que les résultats correspondent à nos attentes - à l'aide d'expressions régulières ou d'une simple comparaison.


Pourquoi est-ce que j'utilise des expressions régulières? Pour que les tests fonctionnent avec n'importe quel jeton valide que je fournirai au programme, indépendamment du nom d'utilisateur et de l'ID utilisateur.


Modèle d'injection de dépendance - 2


À l'heure actuelle, le programme a une couverture de 86,4%. Pourquoi pas à 100%? Nous ne pouvons pas provoquer d'erreurs de http.PostForm() , ioutil.ReadAll() et json.Unmarshal() , ce qui signifie que nous ne pouvons pas vérifier chaque " return UserInfo, err ".


Afin de vous donner encore plus de contrôle sur la situation, vous devez créer une interface sous laquelle http.Client s'adaptera, dont l'implémentation sera dans vkClient et utilisée pour les opérations réseau. Pour nous, dans l'interface, une seule méthode est 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{}, } } 

Une telle opération élimine le besoin d'effectuer des opérations réseau en général. Maintenant, nous pouvons simplement retourner les données attendues de VKontakte en utilisant le faux Networker . Bien sûr, ne vous débarrassez pas des tests qui vérifieront les demandes au serveur, mais il n'est pas nécessaire de faire des demandes à chaque test.


Nous allons créer des implémentations pour les faux Networker et Reader , afin que nous puissions tester les erreurs dans chaque cas - sur demande, lors de la lecture du corps et pendant la désérialisation. Si nous voulons une erreur lors de l'appel de PostForm, nous la renvoyons simplement dans cette méthode. Si nous voulons une erreur
lors de la lecture du corps de la réponse - il est nécessaire de renvoyer un faux Reader , qui générera une erreur. Et si nous avons besoin que l'erreur se manifeste pendant la désérialisation, alors nous renvoyons la réponse avec une chaîne vide dans le corps. Si nous ne voulons aucune erreur, nous renvoyons simplement le corps avec le contenu spécifié.


 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 } 

Pour chaque situation problématique, nous ajoutons un test. Ils créeront un faux Networker avec les paramètres nécessaires, selon lesquels il lèvera une erreur à un certain point. Après cela, nous appelons la fonction à vérifier et nous nous assurons qu'une erreur s'est produite et que nous nous attendions à cette erreur.


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

En utilisant le champ RawBody , RawBody pouvez vous débarrasser des requêtes réseau (renvoyez simplement ce que nous attendons de VKontakte). Cela peut être nécessaire pour éviter de dépasser les limites de requête pendant les tests ou pour accélérer les tests.


Résumé


Après toutes les opérations sur le projet, nous avons reçu un paquet de 91 lignes (+170 lignes de tests), qui prend en charge la sortie vers n'importe quel io.Writer , vous permet d'utiliser des méthodes alternatives de travail avec le réseau (en utilisant l'adaptateur à notre interface), dans lequel il existe une méthode comme pour produire des données et pour les obtenir. Le projet a une couverture à 100%. Les tests vérifient complètement chaque réponse de ligne et d'application à chaque erreur possible.


Chaque étape sur la route vers une couverture à 100% a augmenté la modularité, la maintenabilité et la fiabilité de l'application, il n'y a donc rien de mal à ce que les tests dictent la structure du package.


La testabilité de tout code est une qualité qui n'apparaît pas du ciel. La testabilité apparaît lorsque le développeur utilise correctement les modèles dans les situations appropriées et écrit du code personnalisé et modulaire. La tâche principale était de montrer le processus de réflexion lors de l'exécution de programmes de refactoring. Une réflexion similaire peut s'étendre à n'importe quelle application et bibliothèque, ainsi qu'à d'autres langues.

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


All Articles