In dynamic languages, like python and javascript, it is possible to replace methods and classes in modules right at work. This is very convenient for tests - you can simply put a "patch" that will eliminate the heavy or unnecessary logic in the context of this test.
But what to do in C ++? Go? Java? In these languages, the code cannot be modified for tests on the fly, and creating patches requires separate tools.
In such cases, you should specifically write the code so that it is tested. This is not just a manic desire to see 100% coverage in your project. This is a step to writing supported and high-quality code.
In this article I will try to talk about the main ideas behind the writing of the code under test and show how they can be used on the example of a simple program in the go language.
Let's write a simple program to make a request to the VK API. This is a fairly simple program that generates a request, makes it, reads the answer, decodes the answer from JSON into the structure and outputs the result to the user.
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, ) }
Being professionals in our field, we decided that it was necessary to write tests for our application. Create a file with tests ...
package main import ( "testing" ) func Test_Main(t *testing.T) { main() }
It does not look very attractive. This check is a simple launch of an application that we cannot influence. We cannot exclude work with the network, check the operation with various errors and even replace the token for verification will not work. Let's try to figure out how to improve this program.
First you need to implement the "dependency injection" pattern .
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, ) // ... }
By adding a structure, we created a dependency (access key) for the application, which can be transferred from different sources, thus avoiding the “wired” values ​​and simplifies testing.
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() }
Now only a person can notice a mistake, and that, only if he knows what the conclusion should be. To resolve this issue, it is necessary not to output information directly to the output stream, but to add separate methods for obtaining information and its output. These two independent parts will be easier to check and maintain.
Create a GetUserInfo()
method that will return a structure with user information and an error (if it occurs). Since this method does not output anything, the errors that occur will be transmitted further without output, so that the code that needs the data will figure out the situation itself.
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 }
ShowUserInfo()
to use GetUserInfo()
and handle errors.
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, ) }
Now, in tests, you can verify that the correct response comes from the server, and if an invalid token is returned, an error is returned.
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) } }
In addition to updating existing tests, you need to add new tests for the ShowUserInfo()
method.
func Test_ShowUserInfo(t *testing.T) { client := VKClient{workingToken} client.ShowUserInfo() } func Test_ShowUserInfo_WithError(t *testing.T) { client := VKClient{""} client.ShowUserInfo() }
Tests for ShowUserInfo()
resemble what we initially tried to escape from. In this case, the only meaning of the method is to output information to the standard output stream. On the one hand, you can try to redefine os.Stdout and check the output; it looks like a solution that is too redundant when you can be more elegant.
Instead of using fmt.Printf
, you can use fmt.Fprintf
, which allows you to output to any io.Writer
. os.Stdout
implements this interface, which allows us to replace fmt.Printf(text)
with fmt.Fprintf(os.Stdout, text)
. After that, we can put os.Stdout
into a separate field that can be set to the desired values ​​(for tests, a line, for work, a standard output stream).
Since the ability to change Writer for output will be rarely used, mainly for tests, it makes sense to set a default value. For this we will go to go like this - we will make the VKClient
type VKClient
exportable and create a constructor function for it.
type vkClient struct { Token string OutputWriter io.Writer } func CreateVKClient(token string) vkClient { return vkClient{ token, os.Stdout, } }
In the ShowUserInfo()
function, we replace the Print
calls with 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, ) }
Now you need to update the tests so that they create the client with the help of the designer and install the other Writer where necessary.
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") } }
For each test where we output something, we create a buffer that will play the role of a standard output stream. After executing the function, it is checked that the results meet our expectations - using regular expressions or a simple comparison.
Why do I use regular expressions? For the tests to work with any valid token that I will provide to the program, regardless of the user's name and his ID.
At the moment, the program has a coverage of 86.4%. Why not 100%? We cannot provoke errors from http.PostForm()
, ioutil.ReadAll()
and json.Unmarshal()
, which means that we will not be able to verify every " return UserInfo, err
".
In order to give yourself even more control over the situation, you need to create an interface under which http.Client
will fit, the implementation of which will be located in vkClient, and used for network operations. For us in the interface, only the presence of one method is 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{}, } }
Such a move eliminates the need to perform network operations in general. Now we can simply return the expected data from VKontakte using a dummy Networker
. Of course, you should not get rid of tests that will check requests to the server, but there is no need to make requests in each test.
We will create implementations for dummy Networker
and Reader
so that we can test errors in each case — upon request, upon reading the body, and upon deserialization. If we want an error when calling PostForm, then we simply return it in this method. If we want an error
when reading the response body, it is necessary to return a dummy Reader
, which will throw an error. And if we need the error to manifest itself during deserialization, then we return the answer with an empty string in the body. If we do not want any errors - we simply return the body with the specified content.
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 }
For each problem situation, add a test. They will create a dummy Networker
with the necessary settings, according to which it will throw out an error at a certain moment. After that we call the checked function and make sure that the error occurred, and that we were expecting exactly this error.
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()) } }
Using the RawBody
field RawBody
can get rid of network requests (just return what we expect to receive from VKontakte). This may be necessary to avoid exceeding the query limits during testing or to speed up the tests.
After all the operations on the project, we received a 91-line packet (+170 test lines), which supports output to any io.Writer
, which allows using alternative ways of working with the network (using an adapter to our interface), which has a method like to output data and to get it. The project has 100% coverage. Tests completely check every line and application response to every possible error.
Each step on the road to 100% coverage increased the modularity, maintainability and reliability of the application, so there is nothing wrong with the tests dictating the package structure.
Testability of any code is a quality that does not appear from the air. Testability appears when a developer adequately uses patterns in suitable situations and writes custom and modular code. The main task was to show the thinking process when performing a program's refactoring. Similar thinking can extend to any applications and libraries, as well as to other languages.
Source: https://habr.com/ru/post/452702/
All Articles