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

Какие случаи проверять

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


Четыре вида случаев

Вид случая Вопрос На функции price
обычный как оно работает в будни? заказ 3 шт. — без скидки
граница что происходит ровно на пороге и рядом с ним? 11 шт. и 12 шт.: скидка начинается с 12
пусто / ноль а если считать нечего? заказ 0 шт.
ошибочный вход что будет, если дали ерунду? отрицательное количество или строка вместо числа

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


Граница: там, где в коде стоит сравнение

Границу не выдумывают — её находят в коде функции. Каждое сравнение даёт одну границу, и проверять надо пару значений: последнее «до» и первое «после».

Код для чтения · разбираем, набирать не нужно
func price(qty int) int {
	total := qty * 4500
	if qty >= 12 {
		total = total - total/10
	}
	return total
}

Сравнение здесь одно: qty >= 12. Значит, интересны 11 и 12. Считаем ожидаемое по условию: 11 × 4500 = 49500, скидки нет; 12 × 4500 = 54000, минус десятая часть 5400 — 48600. Оба числа в тест:

Код для чтения · разбираем, набирать не нужно
 1  package main
 2
 3  import "testing"
 4
 5  func TestPriceBorder(t *testing.T) {
 6  	got := price(11)
 7  	if got != 49500 {
 8  		t.Errorf("price(11) = %d, хотели %d", got, 49500)
 9  	}
10  	got = price(12)
11  	if got != 48600 {
12  		t.Errorf("price(12) = %d, хотели %d", got, 48600)
13  	}
14  }

Что увидите, если в функции стоит qty > 12 — то самое сравнение, ошибающееся на одну штуку:

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

Как это читать. Упала одна проверка из двух — та, что стоит ровно на пороге: 54000 значит, что скидку не дали. Проверка на 11 штуках прошла, и на 3 штуках прошла бы тоже: там обе версии функции считают одинаково. Такую ошибку видно только в одной точке.

Отсюда правило: на каждое сравнение в функции — пара случаев. >= и > различаются ровно в одной точке, и увидеть разницу можно только в ней.

В коде Границы, которые надо проверить
if qty >= 12 11 и 12
if qty > 12 12 и 13
if qty == 0 0 и 1
for i := 1; i <= n; i++ n и n+1 — та же ошибка на единицу из дня 08

Ноль и пустота

Нулевой случай — отдельный вид, потому что ноль часто идёт другим путём: делится, вычитается, обнуляет накопитель. price(0) по формуле — ноль, и это стоит зафиксировать тестом:

Код для чтения · разбираем, набирать не нужно
	got := price(0)
	if got != 0 {
		t.Errorf("price(0) = %d, хотели 0", got)
	}

В дне 08 такой случай уже кусался: среднее по пустому вводу давало NaN или панику integer divide by zero. Функция, которая делит на количество, на нуле ломается всегда — и узнать об этом лучше от теста, чем от склада.


Ошибочный вход

Функция, которая сама читает или разбирает ввод, возвращает два значения — результат и error, как в дне 12. Для такой функции «ошибочный вход» — полноценный случай: проверяется не число, а то, что ошибка вообще есть.

Код для чтения · разбираем, набирать не нужно
func parseQty(s string) (int, error) {
	s = strings.TrimSpace(s)
	if s == "" {
		return 0, errors.New("пустая строка")
	}
	n, err := strconv.Atoi(s)
	if err != nil {
		return 0, err
	}
	return n, nil
}

Тест к ней — две функции, на хороший вход и на плохой:

Код для чтения · разбираем, набирать не нужно
func TestParseQtyGood(t *testing.T) {
	got, err := parseQty(" 12 ")
	if err != nil {
		t.Fatalf("parseQty(\" 12 \") вернула ошибку: %v", err)
	}
	if got != 12 {
		t.Errorf("parseQty(\" 12 \") = %d, хотели 12", got)
	}
}

func TestParseQtyBad(t *testing.T) {
	_, err := parseQty("три")
	if err == nil {
		t.Errorf("parseQty(\"три\"): ошибки нет, а она должна быть")
	}
}

Здесь пригодился t.Fatalf со страницы 2: если функция вернула ошибку там, где не должна, смотреть на её первый результат бессмысленно — по соглашению дня 12 при ошибке он нулевой.

Обратите внимание на второй тест: он проверяет err == nil, а не текст ошибки. Текст — дело автора функции, он может поменяться завтра; факт «на такой вход функция обязана сообщить об ошибке» не поменяется.

У сегодняшних заданий функции без error, поэтому этот вид случая понадобится в дне 15. Но знать про него надо сейчас: без него список из четырёх пунктов превращается в список из трёх.


Два сравнения — значит, случаев больше

Ситуация. Функция считает, сколько коробок нужно на партию, если в коробку входит 6 штук. Неполная коробка — тоже коробка.

Код для чтения · разбираем, набирать не нужно
func boxes(qty int) int {
	if qty == 0 {
		return 0
	}
	n := qty / 6
	if qty%6 != 0 {
		n++
	}
	return n
}

Сравнений здесь два, и они про разное: qty == 0 — про пустую партию, qty%6 != 0 — про остаток от деления, то есть про неполную коробку. По правилу «пара случаев на сравнение» получается четыре: 5 штук (остаток есть), 6 штук (остатка нет), 7 штук (снова остаток, но уже вторая коробка) и 0.

Код для чтения · разбираем, набирать не нужно
 1  package main
 2
 3  import "testing"
 4
 5  func TestBoxes(t *testing.T) {
 6  	got := boxes(5)
 7  	if got != 1 {
 8  		t.Errorf("boxes(5) = %d, хотели %d", got, 1)
 9  	}
10  	got = boxes(6)
11  	if got != 1 {
12  		t.Errorf("boxes(6) = %d, хотели %d", got, 1)
13  	}
14  	got = boxes(7)
15  	if got != 2 {
16  		t.Errorf("boxes(7) = %d, хотели %d", got, 2)
17  	}
18  	got = boxes(0)
19  	if got != 0 {
20  		t.Errorf("boxes(0) = %d, хотели %d", got, 0)
21  	}
22  }

Что увидите на верной функции и на двух её поломках. Первая поломка — return qty/6 + 1 вместо деления с проверкой остатка: коробка всегда округляется вверх, даже когда партия делится ровно.

Пример · только посмотреть, набирать не нужно
stagiaire@lab:~/gocourse/day14/scratch$ go test .
--- FAIL: TestBoxes (0.00s)
    boxes_test.go:12: boxes(6) = 2, хотели 1
FAIL
FAIL	day14/scratch	0.004s
FAIL

Вторая — return 1 на пустой партии вместо return 0:

Пример · только посмотреть, набирать не нужно
stagiaire@lab:~/gocourse/day14/scratch$ go test .
--- FAIL: TestBoxes (0.00s)
    boxes_test.go:20: boxes(0) = 1, хотели 0
FAIL
FAIL	day14/scratch	0.005s
FAIL

Как это читать. Каждую поломку поймал ровно один случай, и это разные случаи: строка 12 — партия, которая делится ровно, строка 20 — пустая партия. Убери из теста любой из них — и соответствующая поломка пройдёт молча, а go test напечатает ok.

Сравнение в коде Какие случаи оно требует Какую поломку они ловят
qty == 0 0 и 1 лишняя коробка на пустой партии
qty%6 != 0 6 (делится ровно) и 5 либо 7 (не делится) округление вверх там, где оно не нужно

Так и выбирают случаи: не «сколько не жалко», а по числу сравнений в функции. Два сравнения — четыре случая; ни один нельзя выкинуть, потому что каждый отвечает за свою ошибку.


Попробуйте сейчас: граница и ноль в своём тесте.

Цель: в scratch/price_test.go три проверки — обычный случай, граница и ноль, — и все зелёные.

1. Допишите в TestPrice две проверки к той, что уже есть: для 11 штук и для 0 штук. Ожидаемые числа посчитайте сами по формуле из price.go, а не подглядывая в вывод программы.

2. Запустите:

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

3. Если какая-то проверка покраснела — сначала решите, кто неправ: функция или ваше ожидаемое число. Это разные починки.

4. Проверьте, что тест стал сильнее: временно поставьте в price.go qty > 12 вместо qty >= 12 и запустите снова.

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

Готово, когда: с верной функцией go test . печатает ok, а с qty > 12 — FAIL со строкой про 12 штук, если вы добавили и такую проверку. После опыта верните qty >= 12.


Сколько случаев достаточно

Короткого ответа нет, но есть рабочая мерка: по одному случаю на каждую ветку функции плюс границы.

У price две ветки: скидка есть и скидки нет. Плюс граница (11 и 12), плюс ноль. Получается четыре-пять случаев — этого хватает.

Признак, что случаев мало: вы меняете функцию наугад, и тест всё равно зелёный. Признак, что много: в тесте десяток почти одинаковых проверок вроде 3, 4, 5 и 6 штук — все они идут по одной ветке и ловят одно и то же.

Случаев мало Случаев в самый раз Случаев много
только обычный обычный + обе стороны каждой границы + ноль десяток значений из одной ветки
дефект на границе проходит незамеченным подмена реализации падает тест долго читать и лень дописывать

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


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

Что видите Что это значит Что делать
тест зелёный, а на складе считает неверно случай из жизни в тест не попал добавить именно тот вход, на котором ошиблись
подмена реализации проходит ваш тест нет случая на границе или на нуле найти в функции сравнения и взять пары значений вокруг них
два случая дают одинаковое ожидаемое число они идут по одной ветке и проверяют одно и то же взять значения по разные стороны сравнения
проверка на ноль падает: panic: runtime error: integer divide by zero функция делит на количество, а оно ноль это дефект функции, а не теста: обработать ноль до деления
проверяете текст ошибки, и тест краснеет после правки сообщения текст ошибки — не поведение проверять err != nil, а не строку
ожидаемое взято из вывода программы тест закрепит текущее поведение, верное оно или нет считать ожидаемое по условию на бумаге

Дальше: таблица случаев — course next