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

Тест, который всегда зелёный

Самая опасная ошибка сегодняшнего дня выглядит как удача: 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.go
package 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 она:

  1. запускает go test в каталоге задания на вашей программе — тест обязан пройти;
  2. делает копию каталога, кладёт в неё вместо вашего файла с функцией заведомо неверную версию — и запускает ваш тест ещё раз. Теперь он обязан упасть.

Ваши файлы при этом не трогаются: подмена живёт во временной копии и пропадает сразу после проверки. Если тест зелёный в обоих случаях, пункт останется красным с сообщением «тест проходит и на заведомо неверной программе».

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


Ещё три способа написать слепой тест

Слепота бывает тоньше, чем «забыл 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