Golang тестирование за пределами gotour

twqzdegyxjgeh3sevf4dsdrtk_s.jpeg

Никто не любит писать тесты, конечно же я шучу, все обожают их писать. Как подскажут тимлиды и HR, на собеседованиях правильный ответ — я очень люблю и пишу тесты. Но вдруг вы любите писать тесты на другом языке. Как же начать писать покрытый тестами код на го?

Часть 1. Тестируем handler


В go из коробки есть поддержка http server в «net/http», так что поднять его можно без каких либо усилий. Открывшиеся возможности позволяют почувствовать себя крайне могущественным, и поэтому наш код будет возвращать 42«ого пользователя.

func userHandler(w http.ResponseWriter, r *http.Request) {
   var user User
   userId, err := strconv.Atoi(r.URL.Query().Get("id"))
   if err != nil {
      w.Write([]byte( "Error"))
      return
   }
   if userId == 42 {
      user = User{userId, "Jack", 2}
   }
   jsonData, _ := json.Marshal(user)
   w.Write(jsonData)
}

type User struct {
   Id     int
   Name   string
   Rating uint
}


Данный код получает на вход параметр id пользователя, дальше эмулирует наличие пользователя в базе, и возвращает. Теперь надо это тестировать…

Есть прекрасная вещь «net/http/httptest», она позволяет сымитировать вызов нашего handler«a и затем сравнить ответ.

r := httptest.NewRequest("GET", "http://127.0.0.1:80/user?id=42", nil)
w := httptest.NewRecorder()
userHandler(w, r)
user := User{}
json.Unmarshal(w.Body.Bytes(), &user)
if user.Id != 42 {
   t.Errorf("Invalid user id %d expected %d", user.Id, 42)
}


Часть 2. Дорогая, у нас тут внешний API


И зачем нам переводить дыхание, если мы только размялись? Внутри наших сервисов рано или поздно появятся внешние api. Это странный часто прячущийся зверь, который может вести себя как угодно. Для тестов нам бы хотелось более сговорчивого коллегу. И наш недавно познанный httptest поможет нам и тут. В качестве примера, код вызова внешнего api с передачей данных далее.

func ApiCaller(user *User, url string) error {
   resp, err := http.Get(url)
   if err != nil {
      return err
   }
   defer resp.Body.Close()
   return updateUser(user, resp.Body)
}


Чтобы победить это, мы можем сделать мок внешнего API, самый просто вариант выглядит так:

 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
      w.Header().Set("Content-Type", "application/json; charset=utf-8")
      w.Header().Set("Access-Control-Allow-Origin", "*")
      fmt.Fprintln(w, `{
  "result": "ok",
  "data": {
    "user_id": 1,
    "rating": 42
  }
}`)
   }))
   defer ts.Close()

   user := User{id: 1}
   err := ApiCaller(&user, ts.URL)


ts.URL будет содержать строку формата `http://127.0.0.1:49799`, которая и будет моком api, вызывающего вашу реализацию

Часть 3. Давайте работать с базой


Есть простой путь: поднять докер с базой, накатить миграции, фикстуры и запустить наш прекрасный сервис. Но попытаемся писать тесты, имея минимум зависимостей с внешними сервисами.

Работа с базой в го позволяет подменить сам драйвер, и минуя 100 страниц кода и размышлений, я предлагаю вам взять библиотеку github.com/DATA-DOG/go-sqlmock
Разобраться с sql.Db можно по доке. Возьмем чуть более интересный пример, в котором будет orm для — gorm.

func DbListener(db *gorm.DB) {
   user := User{}
   transaction := db.Begin()
   transaction.First(&user, 1)
   transaction.Model(&user).Update("counter", user.Counter+1)
   transaction.Commit()
}


Надеюсь, этот пример хотя бы заставил вас подумать, как же это тестировать. В «mock.ExpectExec» можно подставить регулярное выражение, покрывающее нужный вам кейс. Единственное, надо помнить, что порядок выставление expect«ов должен совпадать с порядком и количеством вызовов.

func TestDbListener(t *testing.T) {
   db, mock, _ := sqlmock.New()
   defer db.Close()

   mock.ExpectBegin()
   result := []string{"id", "name", "counter"}
   mock.ExpectQuery("SELECT \\* FROM `Users`").WillReturnRows(sqlmock.NewRows(result).AddRow(1, "Jack", 2))
   mock.ExpectExec("UPDATE `Users`").WithArgs(3, 1).WillReturnResult(sqlmock.NewResult(1, 1))
   mock.ExpectCommit()

   gormDB, _ := gorm.Open("mysql", db)
   DbListener(gormDB.LogMode(true))

   if err := mock.ExpectationsWereMet(); err != nil {
      t.Errorf("there were unfulfilled expectations: %s", err)
   }
}


Много примеров по тестированию базы я нашел здесь.

Часть 4. Работа с файловой системой


Мы попробовали свои силы в разных областях и смирились, что хорошо все мокать. Тут все не так однозначно. Я предлагаю два подхода, мокать или использовать файловую систему.

Вариант 1 — все мокаем по github.com/spf13/afero

Плюсы:

  • Ничего не надо переделывать, если вы уже используете эту библиотеку. (но тогда и читать это вам скучно)
  • Работа с виртуальной файловой системой, что значительно ускорит ваши тесты.

Минусы:

  • Требуется доработка существующего кода.
  • В виртуальной файловой системе не работает chmod. Но это может быть фичей т.к. в документации указано — «Avoid security issues and permissions».


Из этих немногочисленных пунктов я сразу сделал 2 теста. В варианте с файловой системой я создал нечитаемый файл и проверил, как отработает система.

func FileRead(path string) error {
   path = strings.TrimRight(path, "/") + "/" // независим от заверщающего слеша
   files, err := ioutil.ReadDir(path)
   if err != nil {
      return fmt.Errorf("cannot read from file, %v", err)
   }

   for _, f := range files {
      deleteFileName := path + f.Name()
      _, err := ioutil.ReadFile(deleteFileName)
      if err != nil {
         return err
      }
      err = os.Remove(deleteFileName) // после вывода удаляем файл
   }
   return nil
}


Использование afero.Fs требует минимальных доработок, но принципиально в коде ничего не меняет

func FileReadAlt(path string, fs afero.Fs) error {
   path = strings.TrimRight(path, "/") + "/" // независим от заверщающего слеша
   files, err := afero.ReadDir(fs, path)
   if err != nil {
      return fmt.Errorf("cannot read from file, %v", err)
   }

   for _, f := range files {
      deleteFileName := path + f.Name()
      _, err := afero.ReadFile(fs, deleteFileName)
      if err != nil {
         return err
      }
      err = fs.Remove(deleteFileName) // после вывода удаляем файл
   }
   return nil
}


Но наше веселье будет неполным, если мы не узнаем насколько быстрее afero, чем native:
Минутка бенчмарков

BenchmarkIoutil       5000     242504 ns/op     7548 B/op     27 allocs/op
BenchmarkAferoOs      300000     4259 ns/op     2144 B/op     30 allocs/op
BenchmarkAferoMem     300000     4169 ns/op     2144 B/op     30 allocs/op


Итак библиотека на порядок опережает стандартную, но вот использовать виртуальную файловую систему или реальную — уже на ваше усмотрение.

Рекомендую:

haisum.github.io/2017/09/11/golang-ioutil-readall
matthias-endler.de/2018/go-io-testing

Послесловие


Мне честно очень нравится 100% покрытие, но небиблиотечный код не нуждается в нем. И даже оно не гарантирует защиту от ошибки. Ориентируйтесь на требования бизнеса, а не на возможность функции вернуть 10 различных ошибок.

Для любителей потыкать код и позапускать тесты, репозиторий.

© Habrahabr.ru