В динамических языках, вроде python и javascript, возможно прямо во время работы заменять методы и классы в модулях. Это очень удобно для тестов — можно просто ставить "заплатки", которые будут исключать тяжёлую или ненужную логику в контексте данного теста.


Но что делать в C++? Go? Java? В этих языках код не получится модифицировать для тестов на лету, а создание заплаток требует отдельных инструментов.


В таких случаях стоит специально писать код так, чтобы он тестировался. Это не просто маниакальное стремление увидеть в своём проекте 100% покрытие. Это шаг к написанию поддерживаемого и качественного кода.


В этой статье я попробую рассказать об основных идеях, стоящих за написанием тестируемого кода и показать, как их можно использовать на примере простой программы на языке go.


Бесхитростная программа


Напишем бесхитростную программу для совершения запроса к API ВКонтакте. Это достаточно простая программа, которая формирует запрос, совершает его, считывает ответ, декодирует ответ из JSON в структуру и выводит результат пользователю.


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

Будучи профессионалами своего дела, мы решили, что необходимо написать тесты для нашего приложения. Создадим файл с тестами...


package main

import (
  "testing"
)

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

Выглядит не очень привлекательно. Эта проверка является простым запуском приложения, на который мы не можем повлиять. Мы не можем исключить работу с сетью, проверить работоспособность при различных ошибках и даже заменить токен для проверки не получится. Попробуем разобраться, как улучшить эту программу.


Паттерн "внедрение зависимости"


Для начала необходимо реализовать паттерн "внедрение зависимости".


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

  // ...
}

Добавив структуру, мы создали зависимость (ключ доступа) для приложения, которую можно передавать из разных источников, что позволяет избежать "вшитых" значений и упрощает тестирование.


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

Разделение получения информации и её вывода


Сейчас ошибку может приметить только человек, и то, только если он знает, каким должен быть вывод. Чтобы решить этот вопрос, необходимо не выводить информацию сразу в поток вывода, а добавить отдельные методы для получения информации и её вывода. Две эти независимые части будет легче проверять и поддерживать.


Создадим метод GetUserInfo(), который будет возвращать структуру с информацией о пользователе и ошибку (если она произошла). Поскольку этот метод ничего не выводит, то происходящие ошибки будут передаваться дальше без вывода, чтобы код, которому нужны данные сам разобрался с ситуацией.


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() так, чтобы он использовал GetUserInfo() и обрабатывал ошибки.


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

Теперь в тестах можно проверять, что от сервера приходит корректный ответ, а при неверном токене возвращается ошибка.


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

Помимо обновления существующих тестов, надо добавить новые тесты для метода ShowUserInfo().


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

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

Настраиваемые альтернативы


Тесты для ShowUserInfo() напоминают то, от чего мы пытались уйти изначально. В этом случае единственный смысл метода — выводить информацию в стандартный поток вывода. С одной стороны, можно попробовать переопределять os.Stdout и проверять вывод, это выглядит как слишком избыточное решение, когда можно поступить более элегантно.


Вместо использования fmt.Printf, можно использовать fmt.Fprintf, который позволяет выполнять вывод в любой io.Writer. os.Stdout реализует этот интерфейс, что позволяет нам заменить fmt.Printf(text) на fmt.Fprintf(os.Stdout, text). После этого мы можем вынести os.Stdout в отдельное поле, которое можно будет устанавливать в нужные значения (для тестов — строка, для работы — стандартный поток вывода).


Поскольку возможность менять Writer для вывода будет использоваться редко, в основном для тестов, есть смысл задать значение по умолчанию. В go для этого мы поступим так — сделаем тип VKClient неэкспортируемым и создадим для него функцию-конструктор.


type vkClient struct {
  Token        string
  OutputWriter io.Writer
}

func CreateVKClient(token string) vkClient {
  return vkClient{
    token,
    os.Stdout,
  }
}

В функции ShowUserInfo() мы заменяем вызовы Print на 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,
  )
}

Теперь необходимо обновить тесты так, чтобы они создавали клиент с помощью конструктора и там, где надо, устанавливали другого Writer.


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

Для каждого теста, где мы что-то выводим, мы создаём буфер, который будет играть роль стандартного потока вывода. После выполнения функции происходит проверка, что результаты соответствуют нашим ожиданиям — с помощью регулярных выражений или простого сравнения.


Зачем я использую регулярные выражения? Чтобы тесты работали с любым корректным токеном, который я предоставлю программе, независимо от имени пользователя и его ID.


Паттерн "внедрение зависимости" — 2


На данный момент программа имеет покрытие в 86.4%. Почему не 100%? Мы не можем спровоцировать ошибки из http.PostForm(), ioutil.ReadAll() и json.Unmarshal(), а значит каждый "return UserInfo, err" у нас проверить не получится.


Для того, чтобы дать себе ещё больше контроля над ситуацией, необходимо создать интерфейс, под которых будет подходить http.Client, реализация которого будет находиться в vkClient, и использоваться для сетевых операций. Для нас в интерфейсе важно только наличие одного метода — 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{},
  }
}

Такой ход позволяет избавиться от необходимости совершать сетевые операции вообще. Теперь мы можем просто возвращать ожидаемые данные от ВКонтакте с помощью подставного Networker. Конечно, не стоит избавляться от тестов, которые будут проверять запросы к серверу, но нет необходимости совершать запросы в каждом тесте.


Создадим реализации для подставных Networker и Reader, чтобы мы могли протестировать ошибки в каждом случае — при запросе, при чтении тела и при десериализации. Если мы хотим ошибку при вызове PostForm, то мы просто возвращает её в этом методе. Если мы хотим ошибку
при считывании тела ответа — надо возвращать подставной Reader, который выбросит ошибку. И если нам надо, чтобы ошибка проявила себя во время десериализации, то возвращаем ответ с пустой строкой в теле. Если же мы не хотим никаких ошибок — мы просто возвращаем тело с указанным содержимым.


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
}

Для каждой проблемной ситуации добавим по тесту. Они будут создавать подставные Networker с нужными настройками, в соответствии с которыми он будет выкидывать ошибку в определённый момент. После этого мы вызываем проверяемую функцию и убеждаемся, что ошибка произошла, и что мы ожидали именно эту ошибку.


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

С помощью поля RawBody можно избавиться от сетевых запросов (просто возвращать то, что мы ожидаем получить от ВКонтакте). Это может быть необходимо, чтобы избежать превышения лимитов запросов во время тестирования или для ускорения тестов.


Итоги


После всех операций над проектом, мы получили пакет длинной в 91 строку (+170 строк тестов), который поддерживает вывод в любые io.Writer, позволяет использовать альтернативные способы работы с сетью (с помощью адаптера к нашему интерфейсу), в котором есть метод как для вывода данных, так и для их получения. Проект обладает 100% покрытием. Тесты полностью проверяют каждую строчку и реакцию приложения на каждую возможную ошибку.


Каждый шаг по дороге к 100% покрытию увеличивал модульность, поддерживаемость и надёжность приложения, поэтому нет ничего плохого в том, что тесты диктовали структуру пакета.


Тестируемость любого кода — это качество, которое не появляется из воздуха. Тестируемость появляется тогда, когда разработчик адекватно использует паттерны в подходящих ситуациях и пишет настраиваемый и модульный код. Основной задачей было показать процесс мышления при выполнении рефакторинга программы. Аналогичное мышление может распространяться на любые приложения и библиотеки, а также и на другие языки.

Комментарии (2)


  1. RomanPyr
    22.05.2019 00:59

    Networker — это ужасно.
    Вы что-нибудь слышали про httptest?


    1. michaelkrukov Автор
      22.05.2019 01:14

      httptest, как я понимаю, используется для запуска сервера и тестирования хендлеров. Да, можно создавать сервер, который будет подменять вконаткте api и возвращать нужные результаты, но это достаточно экстремально и не совсем то, что надо. Основной идеей Networker является переход от прямого вызова функции пакета к вызову метода у объекта. Это можно будет использовать при добавлении ограничения запросов, кеширования, логирования и т.д. без изменения существующей логики.