Никто не любит писать тесты. Конечно же я шучу, все обожают их писать! Как подскажут тимлиды и 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 различных ошибок.
Для любителей потыкать код и позапускать тесты, репозиторий.
korjavin
Получив много кровавого опыты с sqlmock на малюсеньком рефакторинге, я бы уже никому никогда даже подход такой не порекомендовал.
Плюс, однажды выхватили регрессию при выкатке в staging при пройденных тестах, потому что реальный посгрес вёл себя не так.
Имхо лучше уж хоть docker-ы, хоть inmemory экземпляры, но не sqlmock.
jacksparrow Автор
Я положительно отношусь к поднятию базы в докере/ci для интеграционных тестов, но случаи ошибок коннекта, открытия транзакции, комита и тд, на такой базе тяжело проверить. Если вам данные случаи не критичны, то использования sqlmock не имеет необходимости.