Тест, который всегда зелёный
Самая опасная ошибка сегодняшнего дня выглядит как удача: go test доволен, тест есть, галочка стоит. А проверяет он ничего. Такой тест хуже, чем отсутствие теста: он даёт уверенность, за которой ничего не стоит.
Вызвать — не значит проверить
Ситуация. Тест написан быстро: функцию вызвали дважды, программа не упала — значит, работает.
package main
import "testing"
func TestPrice(t *testing.T) {
price(3)
price(12)
}Попробуйте сейчас: что напечатает
go testдля теста без сравнения.Цель: предсказать вывод и сдать ответ.
1. Не запуская: в каталоге лежит верная
price.goи тест выше — две строки с вызовами и ни одногоif. Какое первое слово напечатаетgo test .? Ответ напечатан в разделе «Что увидите» сразу под этим блоком — смотрите туда после того, как отправите свой.2. Сдать ответ одним словом, как его печатает
go test:▶ Выполните · выделенное замените своимcourse answer day14.q3 СЛОВО3. Потом проверьте запуском: временно замените тело
TestPriceна два вызова безif— целиком файл выглядит так:✎ Наберите в файлscratch/price_test.gopackage main import "testing" func TestPrice(t *testing.T) { price(3) price(12) }▶ Выполнитеcd ~/gocourse/day14/scratch go test .Потом верните тест обратно — с
got,wantиif.Готово, когда: пункт
q3вcourse check 14зелёный, а тест в вашем файле снова сравниваетgotиwant.
Что увидите.
stagiaire@lab:~/gocourse/day14/scratch$ go test .
ok day14/scratch 0.006s
stagiaire@lab:~/gocourse/day14/scratch$ go vet .Как это читать. ok — и ни одного повода усомниться. go vet молчит. Компилятор тоже: вызов функции отдельной строкой — законная команда, результат разрешено не использовать. Это правило Go из дня 04: ругается компилятор только на встроенные вроде len(s), а ваша price ему не интересна.
Что на самом деле проверил этот тест: что функция не падает. Всё. Вернёт она 13500, 0 или минус миллион — тест будет зелёным.
Сравнения нет — проверки нет. Проверка живёт в if, а не в вызове.
Как узнать, проверяет ли тест хоть что-нибудь
Ответ простой и неожиданный: сломайте функцию нарочно и посмотрите, покраснеет ли тест. Если после поломки тест остался зелёным — он не проверяет то, что вы сломали.
Возьмём price.go и временно сделаем её заведомо неверной:
package main
func price(qty int) int {
return 0
}Со слабым тестом из предыдущего раздела:
stagiaire@lab:~/gocourse/day14/scratch$ go test .
ok day14/scratch 0.005sФункция врёт про каждый заказ, а тест доволен. С тестом из страницы 1, где есть if got != want:
stagiaire@lab:~/gocourse/day14/scratch$ go test .
--- FAIL: TestPrice (0.00s)
price_test.go:9: price(3) = 0, хотели 13500
FAIL
FAIL day14/scratch 0.005s
FAILВот это и значит «тест ловит ошибку». Правило, которое стоит запомнить на весь курс:
Тест, который не может упасть, — не тест.
Приём называется «сломать нарочно». Он занимает полминуты и отвечает на вопрос, на который иначе ответить нечем: новый зелёный тест зелёный потому, что код верен, или потому, что тест слепой?
Как это проверяет курс
Сегодняшняя проверка делает ровно то же самое, только сама. По пунктам cost, table, tablezero и reprod она:
- запускает
go testв каталоге задания на вашей программе — тест обязан пройти; - делает копию каталога, кладёт в неё вместо вашего файла с функцией заведомо неверную версию — и запускает ваш тест ещё раз. Теперь он обязан упасть.
Ваши файлы при этом не трогаются: подмена живёт во временной копии и пропадает сразу после проверки. Если тест зелёный в обоих случаях, пункт останется красным с сообщением «тест проходит и на заведомо неверной программе».
Неверные версии у каждого задания свои, и они разные: одна возвращает всегда одно и то же число, другая ошибается только на границе, третья — только на нулевой партии. Поэтому «догадаться» не выйдет: проверку проходит тест, который действительно сравнивает результат с ожидаемым на нужных случаях.
Ещё три способа написать слепой тест
Слепота бывает тоньше, чем «забыл if». Все три случая ниже собираются, проходят и ничего не проверяют.
1. Сравнение с самим собой. Ожидаемое не посчитано по условию, а взято у той же функции:
got := price(3)
want := price(3)
if got != want {
t.Errorf("price(3) = %d, хотели %d", got, want)
}Любая реализация сравняется сама с собой: сломайте price как угодно, тест останется зелёным. Ожидаемое значение считает человек по условию, и в тесте оно стоит числом.
2. Результат спрятан в _. Так глушат сообщение declared and not used:
got := price(3)
_ = got_ — «мне это не нужно» из дня 12. Компилятор доволен, проверки нет.
3. Проверка внутри условия, которое никогда не истинно.
got := price(3)
if got == 13500 {
t.Errorf("price(3) = %d, хотели %d", got, 13500)
}Сравнение перевёрнуто: сообщение об ошибке печатается, когда всё верно. Пока функция считает правильно, тест зелёный; сломается — останется зелёным. В тесте if всегда читается так: «если получилось не то, что ждали, — жалуйся».
Компилятор не поймает ни один из трёх: с точки зрения языка всё безупречно. Ловит их только приём «сломать нарочно».
Сколько поломок держит ваш тест
Ситуация. Тест подрос: три проверки — обычный заказ, порог и ноль, написанные в лоб, одна за другой, как на странице 2.
1 package main
2
3 import "testing"
4
5 func TestPrice(t *testing.T) {
6 got := price(3)
7 if got != 13500 {
8 t.Errorf("price(3) = %d, хотели %d", got, 13500)
9 }
10 got = price(12)
11 if got != 48600 {
12 t.Errorf("price(12) = %d, хотели %d", got, 48600)
13 }
14 got = price(0)
15 if got != 0 {
16 t.Errorf("price(0) = %d, хотели %d", got, 0)
17 }
18 }Теперь сломаем price тремя разными способами по очереди и каждый раз прогоним тест. Поломка A — функция всегда возвращает 0:
stagiaire@lab:~/gocourse/day14/scratch$ go test .
--- FAIL: TestPrice (0.00s)
price_test.go:8: price(3) = 0, хотели 13500
price_test.go:12: price(12) = 0, хотели 48600
FAIL
FAIL day14/scratch 0.006s
FAILПоломка B — в условии qty > 12 вместо qty >= 12:
stagiaire@lab:~/gocourse/day14/scratch$ go test .
--- FAIL: TestPrice (0.00s)
price_test.go:12: price(12) = 54000, хотели 48600
FAIL
FAIL day14/scratch 0.006s
FAILПоломка C — лишняя штука в количестве: (qty + 1) * 4500:
stagiaire@lab:~/gocourse/day14/scratch$ go test .
--- FAIL: TestPrice (0.00s)
price_test.go:8: price(3) = 18000, хотели 13500
price_test.go:12: price(12) = 52650, хотели 48600
price_test.go:16: price(0) = 4500, хотели 0
FAIL
FAIL day14/scratch 0.006s
FAILКак это читать. Номера строк складываются в таблицу: видно, какая проверка какую поломку поймала.
| Поломка | Что в ней не так | Ловят проверки | Не ловит |
|---|---|---|---|
A: return 0 |
функция врёт про всё | 3 шт. (строка 8) и 12 шт. (строка 12) | ноль: price(0) и так 0 |
B: qty > 12 |
скидка опаздывает на одну штуку | только 12 шт. (строка 12) | 3 шт. и ноль |
C: (qty + 1) * 4500 |
считает на одну штуку больше | все три (строки 8, 12, 16) | — |
Главное здесь — последний столбец. Поломку B ловит ровно один случай из трёх, тот, что стоит на границе: убери его — и ошибка проходит молча, хотя тест из двух проверок выглядит солидно. А нулевой случай, в поломках A и B бесполезный, единственный поймал бы функцию, которая на пустом заказе возвращает не ноль.
Тот же опыт со слабым тестом — тремя вызовами без if — даёт один и тот же ответ на все три поломки:
stagiaire@lab:~/gocourse/day14/scratch$ go test .
ok day14/scratch 0.006sОтсюда мерка силы теста: сколько поломок он ловит. Вызов без сравнения — ноль поломок, одна проверка обычного случая — поломку A, три проверки — все три. Страница 4 — про то, как выбирать случаи, не перебирая поломки наугад.
Попробуйте сейчас: задание cost — первый тест к своей функции.
Цель: файл
cost_test.goв каталоге~/gocourse/day14/costпроходит на верной функции и падает на подменённой.1. Прочитайте условие — цена за штуку и порог скидки у вас свои:
▶ Выполнитеcd ~/gocourse/day14/cost cat TASK.txt go run .2. Посчитайте на бумаге стоимость для одного количества штук — берите такое, чтобы скидка уже действовала, — и напишите
cost_test.goпо образцу со страницы 1:got,want,if,t.Errorf. Имя файла ровноcost_test.go, пакетmain, импортtesting.3. Запустите тест:
▶ Выполнитеgo test . go test -v .4. Сломайте нарочно: временно допишите в начало функции
costвcost.goстрокуreturn 0, запуститеgo test .— тест обязан упасть. После опыта строку уберите и снова получитеok.Пока временная строка на месте,
go vet .будет жаловаться на недостижимый код —cost.go:N:2: unreachable code. Это ожидаемо: послеreturn 0остальные строки функции не выполняются. Уберёте строку — жалоба пропадёт. Самgo testпро недостижимый код молчит: в набор проверок, встроенный в него, эта не входит.5. Задание готово — закоммитьте тест вместе с кодом:
▶ Выполнитеcd ~/gocourse/day14 git status -s git add . git commit -m "первый тест к функции cost"Готово, когда:
go test .печатаетok, а с временной строкойreturn 0печаталFAIL— пунктыcostиcostfn. Файлcost.goпосле опыта вернулся к исходному виду.
Что может пойти не так
| Что видите | Что это значит | Что делать |
|---|---|---|
ok, и после поломки функции тоже ok |
тест ничего не сравнивает | добавить if got != want и t.Errorf |
./price_test.go:6:2: declared and not used: got |
результат положили в переменную и не использовали | сравнить got с ожидаемым, а не глушить _ = |
| проверка пишет «тест проходит и на заведомо неверной программе» | в тесте нет случая, на котором подмена видна | добавить случай: граница, ноль, большое количество |
| тест падает и на верной функции | ожидаемое посчитано неверно | пересчитать по условию: цена за штуку и порог у вас свои, они в TASK.txt |
после опыта «сломать нарочно» пункт costfn красный |
забыли убрать временную строку из cost.go |
вернуть файл к исходному виду: diff ~/.course/orig/day14/cost/cost.go cost.go |
пункт costfn красный, а cost.go вы не трогали |
теста ещё нет: пункт показывают только вместе с работой — он про то, что вы правили тест, а не функцию | написать cost_test.go и запустить go test . |
тест зелёный, а go run . печатает ерунду |
тест проверяет не ту функцию или не тот случай | сверить, что в тесте вызывается та же функция и с тем аргументом, что вы считали |
Дальше: какие случаи проверять — course next