Go: с нуля до своего блога Урок 41 из 50
Тесты: узнать, что сломалось при правке
Сорок первый урок курса по Go. Обещанная в уроке 10 таблица: случаи складывают в список, `t.Run` даёт каждому имя, и провал сразу говорит, какой именно случай сломался. Плюс проверка обработчика без запуска сервера и честный разговор о том, что показывает `-cover`.
Зачем это нужно
Тест из урока 10 проверял одну функцию одним случаем. Этого хватало ровно до того дня, когда случаев стало пять.
Скопировать if пять раз можно, и так делают — а потом в четвёртой копии забывают поменять ожидаемое значение, и тест годами проверяет не то. Хуже другое: когда такой тест падает, в отчёте видно имя функции, но не видно, какой именно случай не сошёлся.
Сегодня — приём, которым в Go пишут почти все тесты. Он называется табличным, и в уроке 10 я обещал показать его, когда у вас появятся структуры. Они появились.
Сразу целиком
Новая папка, go mod init sabaq38. Первый файл — main.go, три знакомые вещи:
package main
import (
"fmt"
"net/http"
"strings"
)
// parseTags превращает то, что набрали в форме, в список без повторов.
func parseTags(s string) []string {
var out []string
seen := map[string]bool{}
for _, part := range strings.Split(s, ",") {
t := strings.ToLower(strings.TrimSpace(part))
if t == "" || seen[t] {
continue
}
seen[t] = true
out = append(out, t)
}
return out
}
// readingTime округляет вверх: недочитанная минута всё равно считается.
func readingTime(words int) int {
return (words + 199) / 200
}
func articles() http.Handler {
titles := map[string]string{"step": "О степи", "salem": "Привет"}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimPrefix(r.URL.Path, "/read/")
title, ok := titles[slug]
if !ok {
http.Error(w, "нет такой статьи", http.StatusNotFound)
return
}
fmt.Fprintf(w, "<h1>%s</h1>", title)
})
}
func main() {
fmt.Println(parseTags(" Степь , степь,, Блог "))
fmt.Println(readingTime(400), readingTime(401))
}
Второй — main_test.go, рядом:
package main
import (
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
)
func TestParseTags(t *testing.T) {
cases := []struct {
name string
in string
want []string
}{
{"пусто", "", nil},
{"один", "степь", []string{"степь"}},
{"пробелы по краям", " степь ", []string{"степь"}},
{"разный регистр", "Степь, степь", []string{"степь"}},
{"пустые между запятыми", "степь,,блог", []string{"степь", "блог"}},
{"порядок сохраняется", "блог, степь", []string{"блог", "степь"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := parseTags(c.in)
if !slices.Equal(got, c.want) {
t.Errorf("parseTags(%q) = %v, ждали %v", c.in, got, c.want)
}
})
}
}
func TestReadingTime(t *testing.T) {
cases := []struct {
name string
words int
want int
}{
{"ноль", 0, 0},
{"одно слово", 1, 1},
{"ровно двести", 200, 1},
{"двести одно", 201, 2},
{"ровно четыреста", 400, 2},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := readingTime(c.words); got != c.want {
t.Errorf("readingTime(%d) = %d, ждали %d", c.words, got, c.want)
}
})
}
}
func TestArticles(t *testing.T) {
cases := []struct {
name string
path string
code int
body string
}{
{"статья есть", "/read/step", http.StatusOK, "О степи"},
{"статьи нет", "/read/joq", http.StatusNotFound, "нет такой статьи"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
// Сервер поднимать не нужно: обработчик — обычная функция,
// а recorder притворяется браузером.
req := httptest.NewRequest(http.MethodGet, c.path, nil)
rec := httptest.NewRecorder()
articles().ServeHTTP(rec, req)
if rec.Code != c.code {
t.Errorf("код %d, ждали %d", rec.Code, c.code)
}
if !strings.Contains(rec.Body.String(), c.body) {
t.Errorf("в ответе нет %q, ответ: %q", c.body, rec.Body.String())
}
})
}
}
Запуск:
$ go test -v -run TestParseTags
=== RUN TestParseTags
=== RUN TestParseTags/пусто
=== RUN TestParseTags/один
=== RUN TestParseTags/пробелы_по_краям
=== RUN TestParseTags/разный_регистр
=== RUN TestParseTags/пустые_между_запятыми
=== RUN TestParseTags/порядок_сохраняется
--- PASS: TestParseTags
--- PASS: TestParseTags/пусто
--- PASS: TestParseTags/один
--- PASS: TestParseTags/пробелы_по_краям
--- PASS: TestParseTags/разный_регистр
--- PASS: TestParseTags/пустые_между_запятыми
--- PASS: TestParseTags/порядок_сохраняется
PASS
ok sabaq38
Время прогона в выдержках убрано: оно у всех своё.
Разбор
Таблица вместо копий
Случаи лежат в срезе структур — по строке на случай:
cases := []struct {
name string
in string
want []string
}{
{"пусто", "", nil},
{"один", "степь", []string{"степь"}},
{"разный регистр", "Степь, степь", []string{"степь"}},
}
Структура здесь безымянная: тип нужен ровно в одном месте, и заводить для него имя незачем. Три поля отвечают на три вопроса: как случай называется, что подаём, чего ждём.
Добавить седьмой случай — дописать строку. Не скопировать десять строк проверки, а дописать одну. Именно поэтому таблицу и любят: она делает добавление случая дешёвым, а значит, случаев становится больше.
Образ. Список покупок вместо десяти записок в карманах. В списке видно всё сразу, дописать строку легко, и ничего не теряется по дороге.
t.Run: у каждого случая своё имя
t.Run(c.name, func(t *testing.T) { ... }) запускает подтест — отдельный тест внутри теста, со своим именем и своим результатом.
В выводе видно, что имена стали частью отчёта: TestParseTags/разный_регистр. Пробелы Go заменяет подчёркиваниями, чтобы имя можно было передать в командную строку.
Имена пишут по-человечески: «пробелы по краям», а не «случай 3». Через полгода отчёт читаете вы же, и «случай 3» вам ничего не скажет.
Внутри подтеста своя t. Это важнее, чем кажется: t.Errorf в подтесте валит этот случай, а остальные продолжают выполняться, и вы за один запуск узнаёте про все поломки сразу, а не про первую.
Что видно, когда сломалось
Сломаем округление в readingTime — уберём + 199:
$ go test
--- FAIL: TestReadingTime
--- FAIL: TestReadingTime/одно_слово
main_test.go:51: readingTime(1) = 0, ждали 1
--- FAIL: TestReadingTime/двести_одно
main_test.go:51: readingTime(201) = 1, ждали 2
FAIL
exit status 1
FAIL sabaq38
Вот ради этого всё и затевалось. Отчёт говорит: сломались ровно два случая из пяти, вот их имена, вот что получили и чего ждали. Не «тест упал», а «одно слово и двести одно слово считаются неверно» — и по этим двум именам сразу понятно, что потерялось округление вверх.
Заметьте формат сообщения: readingTime(1) = 0, ждали 1. В нём есть вход, полученное и ожидаемое. Сообщение вроде «не сошлось» такого не даёт, и его придётся отлаживать вместо кода.
Один случай по имени
Раз у случая есть имя, его можно запустить отдельно:
$ go test -run 'TestReadingTime/двести_одно' -v
=== RUN TestReadingTime
=== RUN TestReadingTime/двести_одно
--- PASS: TestReadingTime
--- PASS: TestReadingTime/двести_одно
PASS
ok sabaq38
Косая черта отделяет имя теста от имени подтеста. Когда тестов станет двести, а чинить надо один, это экономит минуты на каждой правке.
Обработчик проверяют без сервера
TestArticles проверяет веб-обработчик, и никакого сервера при этом не поднимается:
req := httptest.NewRequest(http.MethodGet, c.path, nil)
rec := httptest.NewRecorder()
articles().ServeHTTP(rec, req)
Обработчик в Go — обычная функция с двумя аргументами. httptest.NewRequest делает запрос, httptest.NewRecorder — «лист бумаги», на который обработчик пишет ответ вместо сети. После вызова в rec.Code лежит код, в rec.Body — тело.
Это быстро (никаких портов и ожиданий) и надёжно (нечему быть занятым). Настоящий сервер в тестах поднимают через httptest.NewServer, когда проверяют цепочку целиком — с обёртками, печеньем и редиректами.
-cover: сколько строк прошло под тестом
$ go test -cover
coverage: 90.0% of statements
ok sabaq38
Девяносто процентов — это доля строк, которые хоть раз выполнились во время тестов. Полезная цифра: она показывает, куда тесты не заглядывали ни разу.
И она же — плохая цель. Строку можно выполнить, ничего не проверив: вызовите функцию и не смотрите на результат — покрытие вырастет, качество нет. Стопроцентное покрытие достигается легко и не означает, что программа работает.
Пользуйтесь ей как картой белых пятен, а не как оценкой.
Что тестировать, а что нет
Тестируют то, где легко ошибиться и дорого не заметить: границы (ноль, один, ровно двести), разбор чужого ввода, права, деньги, всё, что связано с датами.
Не тестируют то, что проверяет сам компилятор, и не тестируют чужие библиотеки: database/sql проверен без вас. И не тестируют вёрстку по строкам — она меняется каждую неделю, а тест будет падать на каждой запятой.
Отдельно: тест не должен зависеть от порядка запуска, от времени на часах и от интернета. Такой тест падает раз в неделю по непонятной причине, и его в итоге отключают — а вместе с ним и пользу.
Карта урока
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Что даёт
t.Runпо сравнению с пятьюifподряд? - Почему обработчик можно проверить без запуска сервера?
- Почему сто процентов покрытия — плохая цель?
Задание
Обязательное. Напишите табличные тесты для блога: на ParseTags, на ReadingTime и на обработчик /read/{slug} — со случаями «статья есть» и «статьи нет». Проверьте, что go test ./... проходит, а потом сломайте одну функцию нарочно и посмотрите на отчёт.
Всё это сделано в step-26 — сверьтесь после того, как напишете сами.
По желанию.
- Добавьте тест хранилища:
t.TempDir()даёт папку, которая сама уберётся, — заведите в ней базу, положите статью и достаньте её обратно. - Найдите функцию, которую тесты не трогают, через
go test -coverprofile=c.outиgo tool cover -html=c.out. - Добавьте случай, который сейчас не проходит, — например, тег из одних пробелов, — и почините код, а не тест.
Куда это встанет в блоге
С этого урока правка перестаёт быть страшной: go test ./... за секунду говорит, не сломали ли вы то, что работало вчера.
Долги. Тестов у блога пока мало, и они не трогают ни сессии, ни права. Нет проверки на гонки — она появится в уроке про горутины вместе с -race. И тестовой базы, которая наполняется одинаково перед каждым тестом, у нас тоже пока нет.
Ответы
Показать ответы
- Имя и отдельный результат для каждого случая. Провал показывает, какой именно случай не сошёлся, остальные при этом продолжают выполняться, и по имени можно запустить один случай через
-run. - Потому что обработчик — обычная функция от
http.ResponseWriterи*http.Request.httptestподставляет ей запрос и лист бумаги вместо сети; ни порт, ни сервер для этого не нужны. - Потому что покрытие считает выполненные строки, а не проверенные утверждения. Строку можно выполнить, не проверив результат, — цифра вырастет, а поломки будут проходить мимо. Это карта белых пятен, а не оценка.
Источники
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.