Какие случаи проверять
Проверить все возможные значения нельзя: у одного 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.goqty > 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