День 15 · Контрольная точка 3: функции, тесты и репозиторий · страница 1 из 4

От задачи к функциям и тестам

Сегодня контрольная точка блока 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.

Данные функция получает аргументами, а не берёт откуда-то сама: так её можно позвать с любыми числами, в том числе из теста. В этом весь смысл разбиения — кусок становится проверяемым.


Вынос не меняет поведение

Разбиение на функции — не переписывание. Вывод программы до и после обязан совпасть буква в букву. Порядок такой:

  1. прогон на примерах из условия — записать, что печатается сейчас;
  2. вынести один кусок в функцию;
  3. тот же прогон — вывод не изменился;
  4. коммит.

И заново с пункта 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.004s

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

И второе: ожидаемое значение в тесте — число, посчитанное вами. Строка 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.go
package 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.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)
	}
}

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