Shanraq.org Shanraq.org
Тесты: узнать, что сломалось при правке
IT

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 проверен без вас. И не тестируют вёрстку по строкам — она меняется каждую неделю, а тест будет падать на каждой запятой.

Отдельно: тест не должен зависеть от порядка запуска, от времени на часах и от интернета. Такой тест падает раз в неделю по непонятной причине, и его в итоге отключают — а вместе с ним и пользу.

Карта урока

Карта урока: таблица случаев, имена и отчёт

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Что даёт t.Run по сравнению с пятью if подряд?
  2. Почему обработчик можно проверить без запуска сервера?
  3. Почему сто процентов покрытия — плохая цель?

Задание

Обязательное. Напишите табличные тесты для блога: на ParseTags, на ReadingTime и на обработчик /read/{slug} — со случаями «статья есть» и «статьи нет». Проверьте, что go test ./... проходит, а потом сломайте одну функцию нарочно и посмотрите на отчёт.

Всё это сделано в step-26 — сверьтесь после того, как напишете сами.

По желанию.

  • Добавьте тест хранилища: t.TempDir() даёт папку, которая сама уберётся, — заведите в ней базу, положите статью и достаньте её обратно.
  • Найдите функцию, которую тесты не трогают, через go test -coverprofile=c.out и go tool cover -html=c.out.
  • Добавьте случай, который сейчас не проходит, — например, тег из одних пробелов, — и почините код, а не тест.

Куда это встанет в блоге

С этого урока правка перестаёт быть страшной: go test ./... за секунду говорит, не сломали ли вы то, что работало вчера.

Долги. Тестов у блога пока мало, и они не трогают ни сессии, ни права. Нет проверки на гонки — она появится в уроке про горутины вместе с -race. И тестовой базы, которая наполняется одинаково перед каждым тестом, у нас тоже пока нет.

Ответы

Показать ответы
  1. Имя и отдельный результат для каждого случая. Провал показывает, какой именно случай не сошёлся, остальные при этом продолжают выполняться, и по имени можно запустить один случай через -run.
  2. Потому что обработчик — обычная функция от http.ResponseWriter и *http.Request. httptest подставляет ей запрос и лист бумаги вместо сети; ни порт, ни сервер для этого не нужны.
  3. Потому что покрытие считает выполненные строки, а не проверенные утверждения. Строку можно выполнить, не проверив результат, — цифра вырастет, а поломки будут проходить мимо. Это карта белых пятен, а не оценка.

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

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

Пока нет комментариев. Будьте первым.