День 14 · Первый тест: go test · страница 6 из 7

Сначала падающий тест, потом починка

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

Тест, который написан до починки и на ней падает, называют репродьюсером: он воспроизводит дефект.


Зачем писать тест раньше починки

Ситуация. Кладовщик жалуется: «на заказе ровно в 12 штук сумма больше, чем в накладной». Дефект уже понятен: в price.go вместо qty >= 12 написано qty > 12.

Рука тянется исправить один знак и закрыть вопрос. Что при этом теряется:

Без репродьюсера С репродьюсером
«кажется, починил» — проверено глазами один раз тест сначала красный, после правки зелёный: разница видна
не доказано, что вы поняли именно этот дефект падающий тест — доказательство: вы назвали вход и ответ заранее
через месяц кто-то вернёт > при правке соседней строки тест поймает это в тот же день
случай остаётся в голове случай остаётся в проекте

Последняя строка — главная. Возврат починенного дефекта называют регрессом, и тест-репродьюсер — единственная дешёвая защита от него.


Как это выглядит

Шаг 1. Воспроизвести. Ставим в price.go дефект — qty > 12 вместо qty >= 12, — и смотрим, что функция считает на 12 штуках:

Пример · только посмотреть, набирать не нужно
stagiaire@lab:~/gocourse/day14/scratch$ go run .
13500
54000

12 × 4500 = 54000 — скидки нет, хотя по условию она с 12 штук уже есть. Ожидаемое: 48600.

Шаг 2. Записать это тестом. Отдельная тестовая функция с именем, из которого видно, про что она. Дописываем её в конец scratch/price_test.go — там уже лежит таблица со страницы 5, она занимает 22 строки, поэтому новая функция начинается с 24-й:

Код для чтения · разбираем, набирать не нужно
24  func TestPriceOnThreshold(t *testing.T) {
25  	got := price(12)
26  	want := 48600
27  	if got != want {
28  		t.Errorf("price(12) = %d, хотели %d", got, want)
29  	}
30  }

Запускаем только её — по имени, как на странице 2:

Пример · только посмотреть, набирать не нужно
stagiaire@lab:~/gocourse/day14/scratch$ go test -run TestPriceOnThreshold .
--- FAIL: TestPriceOnThreshold (0.00s)
    price_test.go:28: price(12) = 54000, хотели 48600
FAIL
FAIL	day14/scratch	0.005s
FAIL

Номер строки — 28, потому что именно там стоит t.Errorf новой функции; у вас он будет свой, смотря сколько строк в файле выше.

Красный тест — это успех шага. Он доказывает две вещи: дефект есть и тест его видит.

Шаг 3. Починить. Одна правка: qty > 12 обратно в qty >= 12.

Шаг 4. Повторный прогон. Не только новый тест, а все:

Пример · только посмотреть, набирать не нужно
stagiaire@lab:~/gocourse/day14/scratch$ go test -v .
=== RUN   TestPrice
--- PASS: TestPrice (0.00s)
=== RUN   TestPriceOnThreshold
--- PASS: TestPriceOnThreshold (0.00s)
PASS
ok  	day14/scratch	0.008s

Вот здесь тесты окупаются в первый раз: шестой шаг методики дня 09 — «не сломалось ли остальное» — обошёлся в одну команду вместо десяти запусков руками.

Если бы репродьюсер оказался зелёным ещё до починки, это значило бы, что дефект в другом месте или на другом входе, — и чинить было бы рано.


Попробуйте сейчас: репродьюсер в черновике.

Цель: увидеть весь порядок на своей программе — красный тест, правка, зелёный тест.

1. Поставьте в scratch/price.go дефект: qty > 12 вместо qty >= 12. Допишите в конец scratch/price_test.go тестовую функцию TestPriceOnThreshold — ту же, что в разделе выше, только без номеров строк:

✎ Наберите в файл scratch/price_test.go
func TestPriceOnThreshold(t *testing.T) {
	got := price(12)
	want := 48600
	if got != want {
		t.Errorf("price(12) = %d, хотели %d", got, want)
	}
}

Файл целиком не переписывайте: таблица со страницы 5 остаётся выше, новая функция идёт после неё. Запустите только её:

▶ Выполните
cd ~/gocourse/day14/scratch
go test -run TestPriceOnThreshold .

2. Убедитесь, что она упала, и почините price.go.

3. Прогоните все тесты каталога:

▶ Выполните
go test -v .

Готово, когда: до починки go test -run TestPriceOnThreshold . печатал FAIL, после починки go test -v . печатает PASS у каждого теста и ok в конце.


Имя теста — тоже сообщение

Тестовых функций в файле становится несколько, и имя каждой должно говорить, что именно она защищает. Сравните:

Имя Что о нём думает читатель
TestPrice1, TestPrice2 порядок написания; про что они — неизвестно
TestPriceOnThreshold про границу порога
TestPriceZero про нулевой заказ
TestPriceBulk про заказ со скидкой

Имя начинается с Test, дальше — заглавная буква, и дальше по-английски без пробелов. Это не прихоть: по этому имени вы будете выбирать тест в -run, и по нему же о падении узнает тот, кто вашего кода не видел.


Если репродьюсер не падает

Красный тест на шаге 2 — не формальность, а единственное доказательство, что вы поняли дефект. Поэтому зелёный репродьюсер — повод остановиться и разобраться, а не идти чинить.

Ситуация. Дефект тот же — qty > 12 вместо qty >= 12, — а случай для теста взят рядом с границей, не на ней: 13 штук вместо 12. 13 × 4500 = 58500, минус десятая часть 5850 — 52650.

Код для чтения · разбираем, набирать не нужно
func TestPriceOnThreshold(t *testing.T) {
	got := price(13)
	want := 52650
	if got != want {
		t.Errorf("price(13) = %d, хотели %d", got, want)
	}
}
Пример · только посмотреть, набирать не нужно
stagiaire@lab:~/gocourse/day14/scratch$ go test -v -run TestPriceOnThreshold .
=== RUN   TestPriceOnThreshold
--- PASS: TestPriceOnThreshold (0.00s)
PASS
ok  	day14/scratch	0.005s

PASS на сломанной функции. И это правда: на 13 штуках обе версии считают одинаково — скидка есть и там, и там. Дефект живёт ровно в одной точке, а тест смотрит мимо неё на одну штуку.

Вторая причина зелёного теста обиднее: тест вообще не запускался. Опечатка в имени после -run даёт почти такой же вывод:

Пример · только посмотреть, набирать не нужно
stagiaire@lab:~/gocourse/day14/scratch$ go test -run TestPriceOnTreshold .
ok  	day14/scratch	0.006s [no tests to run]

Отличие — [no tests to run] в конце строки. Поэтому репродьюсер запускают с -v: === RUN с именем теста в выводе значит, что он действительно выполнился.

Что видите Что это значит Что делать
--- PASS на ещё не починенной функции случай взят не там, где дефект пересчитать по условию и взять ровно ту точку, где числа расходятся
ok … [no tests to run] тест не запускался: опечатка в имени после -run сверить имя с тем, что написано в файле, или запустить без -run
--- FAIL, но числа в сообщении оба неверные ожидаемое посчитано неправильно считать по условию на бумаге, а не по выводу программы
тест падает и после починки, и до неё тест проверяет не ту функцию либо ожидаемое неверно запустить go test -v . и прочитать сообщение целиком

Попробуйте сейчас: задание deliver — починка по упавшему тесту.

Цель: в ~/gocourse/day14/deliver есть тест, который падал до починки, а сама программа считает верно.

1. Прочитайте условие и запустите программу на том количестве, с которого начинается бесплатная доставка (число — в TASK.txt):

▶ Выполните
cd ~/gocourse/day14/deliver
cat TASK.txt
go run .

Программа ждёт число со ввода: наберите его и нажмите Enter.

2. Посчитайте по условию, сколько должно получиться, и сравните. Нашли количество, на котором не сходится, — напишите total_test.go с тестом на этот случай. Функция называется total, main.go она не трогает — тест проверяет функцию, а не вывод программы.

3. Запустите тест и убедитесь, что он падает:

▶ Выполните
go test .

4. Закоммитьте тест, пока он падает, — отдельным коммитом:

▶ Выполните
cd ~/gocourse/day14
git add .
git commit -m "тест на границу бесплатной доставки, пока падает"
cd ~/gocourse/day14/deliver

5. Почините total.go — правка одна-две строки, больше двух проверка не примет. Запустите тест снова и прогоните программу:

▶ Выполните
go test .
printf '1\n' | go run .

6. Посмотрите на свою правку и закоммитьте починку:

▶ Выполните
diff ~/.course/orig/day14/deliver/total.go total.go
cd ~/gocourse/day14
git add .
git commit -m "починка границы в total"
git log --oneline

Два коммита вместо одного — не для красоты: между ними в истории видно, что тест сначала падал. Это лучшее доказательство, что починка настоящая.

Готово, когда: go test . зелёный, программа на обеих сторонах границы печатает верные суммы, а diff показывает одну-две изменённые строки — пункты fix, fixtest, reprod и fixsmall.


Чего репродьюсер не делает

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

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

Ожидаемое для нуля берите из условия, а не из общих соображений: в deliver заказ 0 шт. считается по той же формуле, что остальные, — 0 коп. за товар плюс доставка. «Ноль штук — ноль к оплате» здесь было бы другим правилом, и в TASK.txt его нет.


Что может пойти не так

Что видите Что это значит Что делать
репродьюсер зелёный до починки взят не тот вход или не то ожидаемое пересчитать по условию; дефект часто ровно на границе, а не рядом
после починки тест всё ещё красный, числа странные ожидаемое в тесте посчитано неверно считать по условию, а не по выводу программы
после починки красным стал другой тест правка сломала соседний случай вернуть файл из ~/.course/orig/day14 и чинить точечно
undefined: total в тесте тест лежит не в том каталоге или в нём другой package файл _test.go — рядом с total.go, package main
правка вышла на 5 строк переписали функцию вместо починки cp ~/.course/orig/day14/deliver/total.go total.go и поменять один знак
go run . ничего не печатает и ждёт программа читает число со ввода набрать число и нажать Enter или подать ввод конвейером: printf '1\n' | go run .

Дальше: ритуал перед коммитом — course next