Tests for code and code for tests
In dynamic languages, such as python and javascript, it is possible to replace methods and classes in modules directly during operation. This is very convenient for tests - you can simply put "patches" that will exclude 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 towards writing supported and quality code.
In this article I will try to talk about the main ideas behind writing testable code and show how they can be used with an example of a simple go program.
Uncomplicated program
We’ll 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 response, decodes the response from JSON into a structure and displays 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,
)
}
As professionals in our field, we decided that it was necessary to write tests for our application. Create a test file ...
package main
import (
"testing"
)
func Test_Main(t *testing.T) {
main()
}
It doesn’t 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 operability for various errors, and even replace the token for verification will fail. Let's try to figure out how to improve this program.
Dependency Injection Pattern
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, which avoids 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()
}
Separation of receiving information and its output
Now only a person can make a mistake, and then only if he knows what the conclusion should be. To solve 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 verify and maintain.
Let's create a method GetUserInfo()
that will return a structure with user information and an error (if it happened). 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.
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
}
Change it ShowUserInfo()
so that it uses GetUserInfo()
and processes 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 answer is received from the server, and if the token is incorrect, 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 ")
}
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 method ShowUserInfo()
.
func Test_ShowUserInfo(t *testing.T) {
client := VKClient{workingToken}
client.ShowUserInfo()
}
func Test_ShowUserInfo_WithError(t *testing.T) {
client := VKClient{""}
client.ShowUserInfo()
}
Custom alternatives
Tests for ShowUserInfo()
recall what we tried to get away from initially. In this case, the only point 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 too redundant solution when you can act more elegantly.
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 it os.Stdout
in a separate field, which can be set to the desired values (for tests - a string, 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. In go, for this we will do this - make the type VKClient
non-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 function, ShowUserInfo()
we replace calls Print
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 using the constructor and install another 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 the function is executed, it is checked that the results correspond to our expectations - with the help of regular expressions or a simple comparison.
Why am I using regular expressions? In order for the tests to work with any valid token that I will provide to the program, regardless of the user name and user ID.
Dependency Injection Pattern - 2
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()
, therefore, each " return UserInfo, err
" we cannot verify.
In order to give yourself even more control over the situation, you need to create an interface that will be suitable for http.Client
, the implementation of which will be in vkClient, and used for network operations. For us, in the interface, only one method is important - 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 fake one Networker
. Of course, do 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 dummies Networker
and Reader
so that we can test errors in each case - upon request, when reading the body and during 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, we need to return the front one 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 contents.
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, we add a test. They will create fake ones Networker
with the necessary settings, according to which he will throw an error at a certain moment. After that, we call the function to be checked and make sure that an error occurred, and that we expected 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 field, RawBody
you can get rid of network requests (just return what we expect to receive from VKontakte). This may be necessary to avoid exceeding query limits during testing or to speed up tests.
Summary
После всех операций над проектом, мы получили пакет длинной в 91 строку (+170 строк тестов), который поддерживает вывод в любые io.Writer
, позволяет использовать альтернативные способы работы с сетью (с помощью адаптера к нашему интерфейсу), в котором есть метод как для вывода данных, так и для их получения. Проект обладает 100% покрытием. Тесты полностью проверяют каждую строчку и реакцию приложения на каждую возможную ошибку.
Каждый шаг по дороге к 100% покрытию увеличивал модульность, поддерживаемость и надёжность приложения, поэтому нет ничего плохого в том, что тесты диктовали структуру пакета.
Testability of any code is a quality that does not appear from the air. Testability appears when the developer adequately uses patterns in appropriate situations and writes custom and modular code. The main task was to show the process of thinking when performing refactoring programs. Similar thinking can extend to any application and library, as well as other languages.