От задачи к функциям и тестам
Сегодня контрольная точка блока 3. Новых конструкций Go на ней нет: функции, несколько результатов, error, тесты и репозиторий были в днях 11–14. Эта страница — не новая тема, а порядок работы: как разрезать задачу на функции так, чтобы вывод не изменился, и как проверять себя тестом, а не глазами. Тем же порядком решаются задачи контрольной.
Простыня и что с ней не так
Ситуация. Программа считает приёмку партии: читает количество штук в коробках, печатает отчёт. Всё написано в одном main:
func main() {
norm := 40
scanner := bufio.NewScanner(os.Stdin)
count := 0
total := 0
for scanner.Scan() {
text := strings.TrimSpace(scanner.Text())
if text == "" {
continue
}
qty, err := strconv.Atoi(text)
if err != nil {
fmt.Fprintln(os.Stderr, "ошибка: не число:", text)
os.Exit(1)
}
count++
total += qty
}
average := total / count
load := total * 100 / (count * norm)
fmt.Println("Коробок:", count)
fmt.Println("Среднее:", average, "шт.")
fmt.Printf("Загрузка: %d%%\n", load)
}Программа работает. Мешает другое: чтобы ответить на вопрос «как считается загрузка», приходится прочитать всё — от bufio до последнего Printf. Проверить расчёт загрузки отдельно нельзя: он живёт внутри чтения ввода. А если завтра нужно то же самое, но с другим отчётом, придётся копировать половину.
Три признака, что кусок просится в функцию:
| Признак | Пример в этой программе |
|---|---|
| у куска есть имя, и оно просится в комментарий-заголовок | «разбор строки», «печать отчёта» |
| кусок повторяется | два похожих разбора числа |
| кусок можно проверить отдельно от остального | расчёт загрузки: числа на входе, число на выходе |
Чего не выносят: строку ради строки (func plusOne(n int) int не нужна); кусок, которому нужны пять параметров, чтобы сделать сразу два дела, — значит, шов проходит не там; кусок, который читает ввод и печатает отчёт — это два дела, а не одно. Само по себе число параметров ни о чём не говорит: у функции печати отчёта их бывает и пять, и шесть, и это нормально — она делает одно дело, просто ей нужны все числа.
Как резать: по швам, а не поперёк
Правило одно: одна функция — одно дело, и имя рассказывает, какое.
| Что за функция | Как называют | Пример |
|---|---|---|
| считает значение | существительное или вопрос | average, isUnder |
| делает действие | глагол | printReport, readQty |
| разбирает вход | глагол + что | parseNumber |
Что уходит в параметры — то, что кусок получает снаружи; что возвращается — то, ради чего его звали. Если функции нужно сообщить о неудаче, второй результат — error (день 12). Если нужно сказать «тут ничего нет», хватает bool.
Данные функция получает аргументами, а не берёт откуда-то сама: так её можно позвать с любыми числами, в том числе из теста. В этом весь смысл разбиения — кусок становится проверяемым.
Вынос не меняет поведение
Разбиение на функции — не переписывание. Вывод программы до и после обязан совпасть буква в букву. Порядок такой:
- прогон на примерах из условия — записать, что печатается сейчас;
- вынести один кусок в функцию;
- тот же прогон — вывод не изменился;
- коммит.
И заново с пункта 2. Если после выноса вывод изменился, откатываться некуда — кроме последнего коммита, поэтому коммит и стоит в списке.
stagiaire@lab:~/gocourse/day15/kt3$ printf '50\n45\n60\n49\n' | go run . > before.txt
stagiaire@lab:~/gocourse/day15/kt3$ printf '50\n45\n60\n49\n' | go run . | diff - before.txt
stagiaire@lab:~/gocourse/day15/kt3$Тишина diff — вывод прежний. Знак - вместо имени файла значит «сравнить с тем, что пришло по конвейеру».
Тест вместо глаз
Прогонять примеры руками после каждого выноса надоедает уже к третьему разу, а пропущенный случай так и остаётся непроверенным. Тест — это программа, которая делает тот же прогон за вас.
Ситуация. В черновике — функция «сколько коробок нужно на партию»:
package main
import "fmt"
func boxCount(qty int, size int) int {
return (qty + size - 1) / size
}
func main() {
fmt.Println("Коробок:", boxCount(250, 24))
}Коробок: 11Тест кладут рядом, в файл, имя которого кончается на _test.go:
package main
import "testing"
func TestBoxCount(t *testing.T) {
got := boxCount(250, 24)
if got != 11 {
t.Errorf("boxCount(250, 24) = %d, хотели %d", got, 11)
}
got = boxCount(48, 24)
if got != 2 {
t.Errorf("boxCount(48, 24) = %d, хотели %d", got, 2)
}
}Что увидите.
ok day15/scratch 0.006sКак это читать. go test . нашёл в каталоге все файлы _test.go, собрал их вместе с программой и вызвал каждую функцию, чьё имя начинается с Test. Ни одной жалобы не было — значит, ok и имя пакета. Подробнее показывает -v:
=== RUN TestBoxCount
--- PASS: TestBoxCount (0.00s)
PASS
ok day15/scratch 0.004sТеперь испортим функцию — пусть считает qty / size, без округления вверх:
--- FAIL: TestBoxCount (0.00s)
main_test.go:8: boxCount(250, 24) = 10, хотели 11
FAIL
FAIL day15/scratch 0.005s
FAILСтрока main_test.go:8 — это строка теста, а не программы: там стоит t.Errorf. Дальше — текст, который написали вы сами: что вызвали, что получили, что хотели. Упавший тест читают как сообщение компилятора: сначала где, потом что.
t.Errorf записывает провал и идёт дальше — поэтому в отчёте видно все несошедшиеся случаи сразу. t.Fatalf останавливает этот тест на месте: он нужен, когда дальше проверять бессмысленно.
Что проверяет тест
Те же виды случаев, что и набор вводов из дня 10, только теперь их проверяет машина:
| Случай | Зачем |
|---|---|
| обычный | ошибка в самой формуле |
| ровно на границе | < против <=, «включительно» |
| на единицу по обе стороны от границы | сдвиг границы |
| край: 0, 1, пусто | деление на ноль, счёт с нуля |
Главная ловушка. Тест, который ничего не сравнивает:
func TestBoxCount(t *testing.T) {
boxCount(250, 24)
}ok day15/scratch 0.004sok — и на верной функции, и на любой неверной. Такой тест не проверяет ничего: он только вызывает. Проверка курса это ловит — она подменяет вашу функцию заведомо неверной и смотрит, упадёт ли тест.
И второе: ожидаемое значение в тесте — число, посчитанное вами. Строка if got != boxCount(250, 24) сравнивает функцию саму с собой и сойдётся всегда.
Ритуал перед коммитом
Четыре команды в каталоге задачи, всегда в одном порядке:
| Команда | Что отвечает |
|---|---|
gofmt -l . |
тишина — форматирование в порядке; имя файла — нажмите Ctrl+S или gofmt -w . |
go vet . |
тишина — подозрительных мест нет |
go test . |
ok — тесты прошли |
git diff |
что именно вы поменяли с прошлого коммита |
И только потом git add . и git commit -m "…" — про них третья страница.
Попробуйте сейчас: функция и тест в черновике.
Цель: в черновике лежат функция и тест к ней,
go testпроходит, а на испорченной функции — падает.1. Наберите функцию и
main:▶ Выполнитеcd ~/gocourse/day15/scratch✎ Наберите в файлscratch/main.gopackage main import "fmt" func boxCount(qty int, size int) int { return (qty + size - 1) / size } func main() { fmt.Println("Коробок:", boxCount(250, 24)) }2. Рядом — тест:
✎ Наберите в файлscratch/main_test.gopackage main import "testing" func TestBoxCount(t *testing.T) { got := boxCount(250, 24) if got != 11 { t.Errorf("boxCount(250, 24) = %d, хотели %d", got, 11) } got = boxCount(48, 24) if got != 2 { t.Errorf("boxCount(48, 24) = %d, хотели %d", got, 2) } }3. Весь ритуал:
▶ Выполнитеgofmt -l . go vet . go test . go test -v .4. Испортите функцию: замените её строку на
return qty / sizeи запустите тест ещё раз.▶ Выполнитеgo test .5. Верните функцию как было и убедитесь, что снова
ok.6. Допишите в тест случай, которого там нет:
boxCount(1, 24). Сколько коробок должно получиться? Проверьте.Готово, когда: на верной функции
go test .печатаетok, на испорченной —FAILсо строкойboxCount(250, 24) = 10, хотели 11, а в отчётеcourse check 15зелёные пунктыt_gotestиt_vet.
Что может пойти не так
| Что видите | Что это значит | Что делать |
|---|---|---|
? day15/scratch [no test files] |
файл теста назван без _test.go |
переименовать: main_test.go |
ok day15/kt4 0.005s [no tests to run] |
имя функции теста не начинается с Test |
func TestOrderCost(t *testing.T) |
./main_test.go:3:22: undefined: testing |
в файле теста нет import "testing" |
добавить импорт |
wrong signature for TestBoxCount, must be: func TestBoxCount(t *testing.T) |
у параметра потерялась звёздочка | t *testing.T |
ok, хотя функция заведомо неверная |
тест ничего не сравнивает или сравнивает функцию с собой | сравнивать с числом, посчитанным вами |
undefined: boxCount в тесте |
тест лежит в другом каталоге или другой package |
файл теста — рядом, package main |
| после выноса функции вывод поменялся | шов прошёл не там: в функцию уехала часть расчёта | вернуться к последнему коммиту и вынести меньший кусок |
go test печатает (cached) |
ничего не менялось, Go отдал прошлый результат | это норма; чтобы прогнать заново — go test -count=1 . |
Дальше: памятка блока 3 — функции, тесты и ошибки одной страницей — course next