Съдържание на курса
Урок 5 — Модули и тестове
От Dorian Chávez · основател на Hábil и архитект на интеграции ·
Продължителност: 90 минути или 2 сесии по 45.
Като приключиш, ще можеш:
- Да обясниш какво решава
go.modи защоrevisorняма нужда отgo.sum. - Да преустроиш програма от един файл в пакети под
internal/и да обясниш какво ти дава тази папка, което една обикновена папка не дава. - Да пишеш тестове с таблица със случаи, без никаква външна библиотека, и да четеш какво докладват, когато се провалят.
- Да пускаш
go testс-v,-run,-coverи-raceи да обясниш какво измерва всеки флаг. - Да разпознаваш капана на "нула теста", който изглежда идентично с "всички минаха".
- Да решаваш, с истинско число за покритието пред себе си, коя част от тази празнина си струва да се оправи и коя не.
Защо е важно
До урок 4 имаше един-единствен файл, main.go, с всичко вътре: structs Servicio и
Estado, интерфейса Revisor, функцията, която сглобява отчета. Работеше и работеше добре — но един
файл има таван. Щом поискаш да тестваш една част, без да изпълняваш цялата програма
(без да докосваш мрежата, без да четеш истински файл), един-единствен main.go не ти позволява: всичко е смесено с всичко.
Това е урокът, в който revisor престава да бъде упражнение от един файл и се превръща в истински
проект: няколко пакета, всеки с една отговорност, и набор от тестове, който
доказва, че всяка част прави това, което казва, без да има нужда от останалите. Това е същият скок, който направи в
урок 1 от "програма, която се побира в главата" към "програма, която живее в папка с go.mod" —
сега този go.mod ще организира повече от един файл.
🔑 И това не е каприз на организацията. Тест, който има нужда от мрежа, файл на диска или пуснат сървър, за да се изпълни, е бавен, крехък тест, който никой не пуска често. Разделянето на пакети е това, което ти позволява да пишеш тестове, които се изпълняват за милисекунди, без да докосват нищо външно — и точно това те кара наистина да ги пускаш, при всяка промяна, а не само когато се сетиш.
Понятията
5.1 go.mod и защо този проект няма go.sum
Вече използва go mod init в урок 1 за програмата hola. go.mod на revisor е също толкова
прост:
module github.com/habil/revisor
go 1.27
Два реда: името на модула (така друга програма би го импортирала, ако някой ден публикуваш някой от пакетите му) и минималната версия на Go, от която има нужда.
Ако потърсиш уроци за модули в интернет, почти всички ще споменат go.sum веднага —
файлът с криптографските отпечатъци на всяка външна зависимост, за да не може никой да ти пробута различна
версия на пакет, който използваш. revisor няма go.sum и това не е грешка, нито небрежност:
$ ls go.sum
ls: go.sum: No such file or directory
Не съществува, защото revisor не импортира нито един външен пакет. Прегледай import на който и да е
файл от проекта и ще намериш единствено пакети от стандартната библиотека: net/http,
encoding/json, context, sync, flag, os, time, strings, sort. Това е умишлено решение,
а не ограничение: урок 0 вече предупреждаваше — "научи стандартната библиотека преди който и да е
framework" — и revisor е доказателството, че стандартната стига за пълна програма, с
конкурентност, HTTP, JSON и тестове, без да се добави нито една зависимост от трети страни. Ако някой ден добавиш
такава (например истински клиент за YAML), в този момент go get ще създаде go.sum вместо теб и
двата файла — go.mod и go.sum — отиват в хранилището.
За да видя наистина какво би се променило, направих пробата в отделен проект (не в revisor, който
остава без зависимости): истински go get на малък външен пакет, gopkg.in/yaml.v3.
$ go get gopkg.in/yaml.v3
go: downloading gopkg.in/yaml.v3 v3.0.1
go: added gopkg.in/yaml.v3 v3.0.1
$ cat go.mod
module demo
go 1.27.1
require gopkg.in/yaml.v3 v3.0.1 // indirect
$ cat go.sum
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Това е, което се появява, щом добавиш една-единствена външна зависимост: go.mod получава ред
require, а go.sum се ражда с криптографските отпечатъци (дългите текстове, които започват с h1:) на
тази зависимост и на нейните собствени (check.v1 е непряка зависимост на yaml.v3, а не нещо, което
ти си поискал). Тези отпечатъци са онова, заради което, ако някой се опита да ти пробута различна версия на
пакета със същото име и версия, go build ще откаже да компилира — това е гаранция за цялост,
а не просто запис. revisor ги няма, защото не му трябват: нула външни зависимости, нула
повърхност за този вид риск.
5.2 Пакети по отговорност, а не по слой
Ето как беше организиран revisor в този урок:
revisor/
go.mod
cmd/
revisor/ → package main: arranca, parsea banderas, imprime
servidor-demo/ → package main: un servidor de prueba, no parte del programa
internal/
servicio/ → package servicio: los tipos (Servicio, Estado) y sus métodos
config/ → package config: lee y valida el archivo de servicios
revisar/ → package revisar: la interfaz Revisor y quien la implementa
reporte/ → package reporte: convierte estados en tabla o en JSON
По отговорност, а не по слой. Няма пакет modelos с всички structs на програмата, нито
пакет utilidades с разпилени функции: всеки пакет има един-единствен въпрос, на който знае да отговори.
servicio знае какво е услуга и състояние. config знае как да чете конфигурацията. revisar знае как да
проверява. reporte знае как да отпечатва. Ако утре промениш как изглежда таблицата, пипаш един файл, а не пет.
🔑 internal/ е правило на компилатора, а не конвенция на добрия тон. Всеки пакет,
който живее под папка, наречена internal/, може да бъде импортиран само от код, който е в рамките на
същия модул, на което и да е ниво над този internal/. Провери го: ако друг модул на Go — който и да е,
не само твой — опита import "github.com/habil/revisor/internal/servicio", компилаторът отказва
да компилира, с изрично съобщение, че този пакет е вътрешен. Това не е препоръка, която можеш да
пренебрегнеш под напрежение: това е истинско ограничение, същият вид гаранция, който главната буква ти дава вътре
в struct (урок 3), но на ниво цял пакет.
⚠️ Онова, което НЕ направихме, нарочно: пакет utils, helpers или common. Това е най-често
повтаряният антипатърн в истинските проекти: някой създава папка за "неща, които не пасват другаде", и тази
папка расте без ограничение, докато никой не знае какво има вътре, нито защо. Всеки път, когато изпиташ изкушението
да сложиш нещо в такъв пакет, запитай се на коя отговорност е тази функция, и я сложи в
пакета, който притежава тази отговорност — а ако наистина не пасва никъде, това е знак, че липсва
име за ново понятие, а не че липсва чекмедже за всичко.
5.3 Конфигурационният файл и истинските му грешки
config в този урок чете прост текстов формат, по един ред на услуга:
nombre url [tiempo-limite]
# las lineas que empiezan con # se ignoran
catalogo https://catalogo.interno.mx
pagos https://pagos.interno.mx 500ms
Това го прави функцията Interpretar, която никога не чете файл: получава вече прочетените байтове и
затова може да се тества със сто варианта на съдържание, без да се създава нито един временен файл (Cargar, която
наистина докосва диска, е тънък слой отгоре, който само чете файла и подава съдържанието на Interpretar).
Всеки зле написан ред създава истинска грешка, с номера на реда и какво се е очаквало — проверено, а не
предположено:
$ (línea: "catalogo")
prueba.txt:1: esperaba «nombre url [tiempo]», hay 1 campo(s)
$ (línea: "catalogo catalogo.interno.mx")
prueba.txt:1: la URL "catalogo.interno.mx" debe empezar con http:// o https://
$ (línea: "catalogo https://a.mx nombas")
prueba.txt:1: tiempo limite "nombas" invalido (usa 500ms, 2s, 1m): time: invalid duration "nombas"
$ (dos líneas con el mismo nombre "catalogo")
prueba.txt:2: el nombre "catalogo" ya estaba en la linea 1
Обърни внимание на последната: обвива грешката от time.ParseDuration с %w (урок 3), вместо да
измисля собствен текст — така, ако някога ти се наложи да различиш програмно "невалидна продължителност"
от друг вид грешка с errors.As, оригиналната информация продължава да е там.
5.4 Тестове: без библиотеки, с таблица със случаи
Ето как изглежда истински тест на пакета servicio (целият файл живее в
programas/revisor/internal/servicio/servicio_test.go):
func TestTimeoutEfectivo(t *testing.T) {
casos := []struct {
nombre string
timeout time.Duration
quiere time.Duration
}{
{"declarado", 5 * time.Second, 5 * time.Second},
{"cero (valor por omision)", 0, TimeoutPorOmision},
{"negativo (dato corrupto, no debe pasar)", -1 * time.Second, TimeoutPorOmision},
}
for _, c := range casos {
t.Run(c.nombre, func(t *testing.T) {
s := Servicio{Timeout: c.timeout}
if got := s.TimeoutEfectivo(); got != c.quiere {
t.Errorf("TimeoutEfectivo() = %v, quería %v", got, c.quiere)
}
})
}
}
Няма assert, нито expect, нито каквато и да е библиотека за твърдения (assertions) — и това е нарочно. Go сравнява
стойности с обикновен if и докладва с t.Errorf, като ти сам пишеш какво си очаквал и какво си получил.
В началото изглежда по-многословно от assert.Equal(t, esperado, obtenido) в други езици;
печалбата е, че съобщението при провал го контролираш ти, вместо да наследиш общия формат на някоя
библиотека, и че не е нужно да инсталираш, нито да учиш нищо допълнително, за да напишеш и най-простия тест.
t.Run дава име на всеки случай от таблицата и това има значение, когато нещо се провали: вместо общото
"TestTimeoutEfectivo се провали", отчетът казва точно кой от трите случая е бил:
$ go test ./internal/servicio/... -run TestTimeoutEfectivo -v
=== RUN TestTimeoutEfectivo
=== RUN TestTimeoutEfectivo/declarado
=== RUN TestTimeoutEfectivo/cero_(valor_por_omision)
=== RUN TestTimeoutEfectivo/negativo_(dato_corrupto,_no_debe_pasar)
--- PASS: TestTimeoutEfectivo (0.00s)
--- PASS: TestTimeoutEfectivo/declarado (0.00s)
--- PASS: TestTimeoutEfectivo/cero_(valor_por_omision) (0.00s)
--- PASS: TestTimeoutEfectivo/negativo_(dato_corrupto,_no_debe_pasar) (0.00s)
PASS
ok github.com/habil/revisor/internal/servicio 0.382s
Този изход е истински: пуснах го върху кода на същия този проект, преди да напиша този ред.
Добавянето на нов случай в таблицата е добавяне на един ред в slice-а casos — не нова функция, не
повтаряне на тялото на теста. Това е идиоматичният начин да се тества функция с много входове в Go и
ще го използваш във всеки пакет оттук нататък.
5.5 Тестване на грешките, а не само на успехите
Таблицата със случаи служи и за да се тества, че нещо се проваля както трябва, а не само че работи (съкратена
версия тук, с 3 от истинските 6 случая и позиционен struct литерал вместо с имена на полета,
за да се побере; пълната живее в internal/config/config_test.go):
func TestInterpretar_casosDeError(t *testing.T) {
casos := []struct {
nombre string
entrada string
contexto string // fragmento que el mensaje de error debe contener
}{
{"nombre repetido", "catalogo https://a.mx\ncatalogo https://b.mx\n", "ya estaba en la linea"},
{"url sin esquema", "catalogo catalogo.interno.mx\n", "debe empezar con http"},
{"tiempo limite invalido", "catalogo https://a.mx nombas\n", "invalido"},
// ...
}
for _, c := range casos {
t.Run(c.nombre, func(t *testing.T) {
_, err := Interpretar([]byte(c.entrada), "prueba.txt")
if err == nil {
t.Fatalf("Interpretar() no devolvió error, y debía contener %q", c.contexto)
}
if !strings.Contains(err.Error(), c.contexto) {
t.Errorf("Interpretar() error = %q, quería que contuviera %q", err.Error(), c.contexto)
}
})
}
}
t.Fatalf вместо t.Errorf в първия if: ако Interpretar не е върнала грешка, когато е трябвало,
продължаването с проверка на err.Error() на следващия ред би предизвикало panic (err би бил nil). Fatalf спира
точно този тест ето там; Errorf оставя теста да продължи и да натрупа още провали, преди
да докладва. Практическото правило: използвай Fatalf, когато продължаването няма смисъл без онова, което току-що си
проверил, и Errorf, когато има.
5.6 Командите, които ще използваш винаги
go test ./... # todo el proyecto
go test ./internal/config/... -v # verboso, un paquete
go test ./... -run TestInterpretar # solo las pruebas cuyo nombre haga match
go test ./... -race # detector de carreras (se explica a fondo en la lección 6)
go test ./... -cover # porcentaje de líneas ejercitadas por las pruebas
Пуснати наистина върху revisor на 30 септ. 2026 г.:
$ go test ./internal/servicio/... ./internal/config/... -cover
ok github.com/habil/revisor/internal/servicio 0.195s coverage: 92.3% of statements
ok github.com/habil/revisor/internal/config 0.192s coverage: 97.4% of statements
5.7 Какво да правиш с число за покритие
92.3% и 97.4% не са цели, а отправни точки за един въпрос: какво са тези 8% и тези 3%, които не са
били изпълнени, и има ли това значение за мен? С go test -coverprofile можеш да видиш точно кои редове са останали
незасегнати:
go test ./internal/servicio/... -coverprofile=/tmp/cobertura.out
go tool cover -func=/tmp/cobertura.out
В revisor празнината в servicio е разклонението на Motivo(), което сглобява съобщението, когато кодът не
е нито 0, нито успех с конкретен текст (fmt.Sprintf("codigo %d", ...) за 404 без допълнителен
контекст) — разклонение, което съществуващите тестове не упражняват с точно този код. Правилното решение
не е да гониш 100%, като запълваш всяко разклонение с насилен тест, който не учи нищо ново: то е да погледнеш
празнината, да решиш дали има значение (тук — малко: би добавил случай с 404 в таблицата) и да я запишеш, вместо
да се правиш, че не съществува.
🔴 И един истински капан, измерен в същия този проект: покритието по пакети може да подцени една
централна функция, без да те предупреди. Пуснат сам, пакетът revisar на revisor (който ще опознаеш
в дълбочина в урок 6) докладва:
$ go test ./internal/revisar/... -cover
ok github.com/habil/revisor/internal/revisar 1.33s coverage: 61.9% of statements
61.9% звучи така, сякаш повече от една трета от този пакет никога не се изпълнява в нито един тест — и това е невярно. Неговата
най-важна функция, Revisar (онази, която наистина говори HTTP), е добре тествана: само че тестът,
който наистина я упражнява — TestEjecutar_reportaOKyFalla, с истински сървър httptest, в урок
7 — живее в пакета cmd/revisor, а не в internal/revisar. go test ./internal/revisar/... брои само
онова, което тестовете ОТ ТОЗИ ПАКЕТ упражняват; не вижда онова, което тест от друг пакет минава по
пътя, макар да минава през същия код. С -coverpkg, който казва на Go да измери покритието на един
пакет, като брои всички тестове на проекта, а не само неговите:
$ go test ./... -coverpkg=./... -coverprofile=/tmp/cov.out
$ go tool cover -func=/tmp/cov.out | grep 'revisar.go.*Revisar'
github.com/habil/revisor/internal/revisar/revisar.go:48: Revisar 86.4%
$ go tool cover -func=/tmp/cov.out | tail -1
total: (statements) 81.5%
86.4% за Revisar, 81.5% за целия проект — а не 61.9%. Поуката не е "пренебрегвай числото
по пакети": тя е, че едно число за покритие винаги отговаря на неявен въпрос — покритие на какво,
измерено спрямо тестовете от къде? — и go test ./paquete/... -cover премълчава втората половина на
въпроса. Преди да решиш, че нещо "не е тествано" заради ниско число, пусни -coverpkg=./... върху
целия проект и сравни.
5.8 Предизвикване на провал, за да знаеш, че тестът върши работа
Тест, който никога не си виждал да се проваля, е тест, за който не знаеш дали работи — може да
сравнява две неща, които по случайност винаги са еднакви. Счупи го нарочно: промени
TimeoutEfectivo(), така че винаги да връща s.Timeout, без if, и пусни теста:
$ go test ./internal/servicio/... -run TestTimeoutEfectivo -v
=== RUN TestTimeoutEfectivo/cero_(valor_por_omision)
servicio_test.go:64: TimeoutEfectivo() = 0s, quería 2s
--- FAIL: TestTimeoutEfectivo (0.00s)
--- FAIL: TestTimeoutEfectivo/cero_(valor_por_omision) (0.00s)
FAIL
Този FAIL, с точния случай, който се е счупил, и стойността, която е получил, срещу онази, която е очаквал, е доказателството,
че тестът работи. Върни if и потвърди, че отново е зелен, преди да продължиш.
5.9 Проектиране, за да може да се тества: отделяне на логиката от входа/изхода
Обърни внимание на нещо, което вече споменахме мимоходом в раздел 5.3 и което заслужава собствено място: config
има две функции, а не една.
func Cargar(ruta string) ([]servicio.Servicio, error) {
datos, err := os.ReadFile(ruta)
if err != nil {
return nil, fmt.Errorf("leyendo la configuracion: %w", err)
}
return Interpretar(datos, ruta)
}
Interpretar (само сигнатурата; цялото тяло, с цялата логика за разбор, е в раздел
5.3, с истинските ѝ грешки):
func Interpretar(datos []byte, origen string) ([]servicio.Servicio, error) {
// ... toda la lógica de parseo, sin tocar el disco ...
}
Ако имаше една-единствена функция Cargar(ruta string), която чете файла и разбира всичко наведнъж, всеки
тест за "зле написан ред", "повторено име" или "невалидно времево ограничение" би трябвало да започва със създаване на
временен файл на диска с os.CreateTemp, да записва в него съдържанието на случая, да подава пътя и
да го изтрива накрая. Работи, но е бавно (докосва истинската файлова система) и замърсява теста с
код, който няма нищо общо с това, което всъщност се тества: дали конфигурацията ти се
тълкува правилно, или не, а не дали знаеш как да създаваш временни файлове.
Като се отдели частта, която решава (Interpretar, чиста функция: едни и същи входни байтове, един и същ
резултат винаги, без достъп до нищо външно), от частта, която получава байтовете (Cargar, единствената,
която докосва диска), деветте теста от раздел 5.5 се изпълняват за микросекунди и без да създават нито един файл.
Самата Cargar почти няма нужда от собствени тестове: достатъчно е да се провери, че връща грешка, ако файлът не
съществува (упражнение 6), защото цялата интересна логика вече е в Interpretar и вече е тествана.
🔑 Общото правило, полезно много отвъд този проект: когато една функция е трудна за тестване, почти винаги е така, защото смесва "решаването на нещо" с "докосването на външния свят" (файл, мрежата, часовника). Разделянето им не е стилово правило: то е онова, което определя дали ще можеш да напишеш теста на три реда или на двадесет.
5.10 Бенчмаркове: измерване, а не гадаене
Освен Test..., Go разпознава функции Benchmark..., които измерват колко време отнема кодът ти, а не дали е
правилен:
func BenchmarkInterpretar(b *testing.B) {
entrada := []byte(`
catalogo https://catalogo.interno.mx
pagos https://pagos.interno.mx 500ms
inventario https://inventario.interno.mx 1s
reportes https://reportes.interno.mx
`)
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := Interpretar(entrada, "bench.txt"); err != nil {
b.Fatal(err)
}
}
}
b.N не го избираш ти: Go изпълнява цикъла с все по-големи стойности на N, докато измерването стане
стабилно, и докладва времето за една операция. Пуснат наистина върху revisor (Apple M5, 200,000
повторения):
$ go test ./internal/config/... -bench=. -run '^$'
goos: darwin
goarch: arm64
pkg: github.com/habil/revisor/internal/config
cpu: Apple M5
BenchmarkInterpretar-10 200000 432.5 ns/op
PASS
-run '^$' казва на go test да не пуска нито един обикновен тест (регулярен израз, който не
съвпада с нито едно име), за да не се смеси отчетът на бенчмарка (benchmark) с този на тестовете.
Числото — 432.5 наносекунди на извикване, на тази машина, в този момент — не е за запаметяване: то е, за да
го сравниш със самото себе си след промяна. Ако утре пренапишеш Interpretar и бенчмаркът
се качи на 4,000 ns/op, имаш обективен сигнал, че нещо е станало по-бавно, без да е нужно да имаш мнение
по въпроса.
5.11 go vet: онзи, който намира компилиращото се, но грешно
go test ти казва дали логиката ти прави това, което си очаквал. go vet ти казва дали кодът ти има грешка, която
компилаторът не хваща, защото технически тя е валидна — но почти сигурно не е това, което си искал да
напишеш. Най-честият случай е форматиращ глагол (урок 2), който не съответства на типа на аргумента:
puerto := 443
fmt.Printf("servicio %s en el puerto %s\n", nombre, puerto) // %s para un int
Това се компилира и се изпълнява, без panic и без грешка — и създава счупен изход (пълна програма в
programas/revisor/ejemplos/05-vet-printf/main.go):
$ go run ./ejemplos/05-vet-printf/
servicio catalogo en el puerto %!s(int=443)
А go vet върху същия файл наистина го открива:
$ go vet ./ejemplos/05-vet-printf/
ejemplos/05-vet-printf/main.go:11:39: fmt.Printf format %s has arg puerto of wrong type int
go vet го открива, защото анализира форматиращия низ спрямо истинските типове на аргументите — стъпка,
която компилаторът на Go не прави сам. Пускай go vet ./... заедно с go test ./... като
рутина: повечето редактори с разширението за Go (урок 1) вече го правят вместо теб, докато
пишеш, като подчертават проблема, преди да си стигнал до изпълнение на каквото и да е.
5.12 Гранични тестове: където наистина живеят бъговете
Estado.OK() решава, че един код е наред, ако попада между 200 и 299. Изкушаващо е да се тества с един
"очевиден" случай (200) и един "очевиден" случай на провал (500) и да се приеме за готово. Истинската таблица на revisor
тества и двете стойности, които са точно на ръба на диапазона:
{"199, justo debajo del rango", Estado{Codigo: 199}, false},
{"300, justo arriba del rango", Estado{Codigo: 300}, false},
$ go test ./internal/servicio/... -run TestEstadoOK -v
=== RUN TestEstadoOK/199,_justo_debajo_del_rango
=== RUN TestEstadoOK/300,_justo_arriba_del_rango
--- PASS: TestEstadoOK (0.00s)
--- PASS: TestEstadoOK/199,_justo_debajo_del_rango (0.00s)
--- PASS: TestEstadoOK/300,_justo_arriba_del_rango (0.00s)
Защо има значение, щом 199 и 300 "очевидно" не са OK? Защото грешка от един-единствен знак в
условието — >= вместо > или <= вместо < — е точно видът бъг, който един "очевиден" случай
никога не открива, а граничният случай открива винаги. Ако някой промени OK() на
e.Codigo >= 200 && e.Codigo <= 300 (включвайки 300 по грешка), случаите 200/299/404/500 биха продължили
да минават също толкова добре — само случаят с 300 би го издал. Това е дълбоката причина зад "тествай
ръбовете, а не само центъра": ръбовете са мястото, където се крият грешките при сравнение, и те са
невидими за всеки тест, който използва само стойности дълбоко вътре или далеч извън диапазона.
Грешката, която ще видиш
| Симптомът | Дословно съобщение | Какво става и какво да направиш |
|---|---|---|
Импорт на чужд пакет internal |
use of internal package github.com/habil/revisor/internal/servicio not allowed |
Компилаторът не позволява да се импортира нещо под internal/ извън модула. Това не е разрешение, което можеш да дадеш: трябва да изложиш типа от публичен пакет, ако наистина е нужно |
| Повторена услуга в конфигурацията | prueba.txt:2: el nombre "catalogo" ya estaba en la linea 1 |
Две услуги с едно и също име; поправи конфигурационния файл |
| URL без схема | prueba.txt:1: la URL "catalogo.interno.mx" debe empezar con http:// o https:// |
Липсва http:// или https:// в началото на URL-а |
| Зле написано времево ограничение | prueba.txt:1: tiempo limite "nombas" invalido (usa 500ms, 2s, 1m): time: invalid duration "nombas" |
Форматът за продължителност в Go не е свободен: използвай число, последвано от единица (ms, s, m, h) |
| Тест, който си счупил нарочно | servicio_test.go:64: TimeoutEfectivo() = 0s, quería 2s |
Виж раздел 5.8: така изглежда t.Errorf, който посочва точно кой случай се е провалил и защо |
| Нула теста в пакет | ? github.com/habil/revisor/cmd/servidor-demo [no test files] |
Това не е провал: go test предупреждава изрично, когато пакет няма нито един файл _test.go, вместо да се прави, че е изпълнил нещо |
И най-важното за научаване как да се чете, защото не е грешка, а липсата на такава:
$ go test ./...
ok github.com/habil/revisor/internal/vacio 0.001s
Ако internal/vacio нямаше нито една функция Test..., този ред щеше да изглежда точно по същия начин:
ok, в зелено, без никакъв знак, че не е изпълнено нищо. Единственият начин да различиш "всичко мина, наистина"
от "нямаше какво да се изпълни" е да погледнеш броя с -v (който отпечатва всяко RUN) или, още по-добре,
никога да не се доверяваш на пакет, който няма нито един файл _test.go — това go test го казва, както в
реда от таблицата по-горе.
Какво се прави погрешно
- Създаване на пакет
utils,helpersилиcommon. Вече го видя в раздел 5.2: това е най-често повтаряният антипатърн и онзи, който най-бързо губи предназначението си. Назови отговорността, а не факта, че "не пасва другаде". - Объркване на "компилира се" с "тестовете минаха".
go buildпроверява, че кодът е валиден; не изпълнява нито един тест. Това са две различни команди с два различни въпроса. - Четене на кода на изход на
go testвместо броя. Пакет без тестови файлове излиза с код 0 (успех), също като пакет с 50 теста, които наистина са минали. Кодът на изход отговаря на "провали ли се нещо?", а не на "тествано ли е нещо?" — за втория въпрос трябва да се прочете изходът, а не само$?. - Гонене на 100% покритие, сякаш това е целта. Високо покритие със слаби твърдения
(проверяваш, че нещо не гърми, без да проверяваш какво връща) дава красива цифра и тест, който не
открива почти нищо. Числото е ориентир къде да се погледне, а не цел само по себе си — раздел 5.7 го
показва с истинска празнина в самия
revisor. - Да напишеш тест и никога да не го видиш да се проваля. Ако никога не си чупил кода нарочно, за да потвърдиш, че тестът става червен, не знаеш дали този тест тества нещо. Раздел 5.8 го направи с истински данни от проекта: направи го и ти с поне един собствен тест, преди да го приемеш за добър.
Упражнения
- Клонирай структурата от пакети от раздел 5.2 (
internal/servicio,internal/config) за своето собствено копие наrevisor, като преместиш кода, който вече имаше от уроци 2 до 4. - Напиши таблицата със случаи на
TestEtiquetaза методаEtiqueta()наServicio, с поне един случай с нормално име и един с празен struct. - Добави случай към
TestInterpretar_casosDeErrorза ред с четири полета (повече от трите, които форматът позволява). Провери точното съобщение спрямо кода наconfig.go. - Пусни
go test ./... -coverвърху своето копие и запиши процента на всеки пакет. Избери една празнина в покритието и реши, писмено в дневника си, дали те интересува да я затвориш и защо. - (Както в раздел 5.8) Счупи нарочно функция, която вече си тествал, пусни теста, прочети
целия
FAILи я поправи. Постави двата резултата — червения и зеления — в дневника си. - (Малко по-трудно) Напиши
TestCargar_archivoInexistente, който потвърждава, чеCargar(неInterpretar) връща грешка, когато пътят не съществува. Подсказка: не е нужно да създаваш никакъв файл за този тест, само да подадеш път, за който знаеш, че не съществува.
Решения
1 и 2 нямат едно-единствено еталонно решение: зависи от това как беше организирана собствената ти програма от
урок 4. Сравни резултата си с истинския код на programas/revisor/internal/servicio/servicio.go от
проекта на този курс.
С
"catalogo https://a.mx 500ms extra\n"очакваното съобщение еprueba.txt:1: esperaba «nombre url [tiempo]», hay 4 campo(s)— същият път в кода, който вече обработва "липсват полета", защотоlen(campos) > 3покрива двата случая с една-единствена проверка.Няма един-единствен отговор: важното е решението да остане записано с причината си, а не процентът сам по себе си.
Виж целия раздел 5.8: шаблонът винаги е "промени кода, пусни теста, прочети
FAILс точния случай, поправи, пусни отново".Ето така:
func TestCargar_archivoInexistente(t *testing.T) { _, err := Cargar("/ruta/que/no/existe.txt") if err == nil { t.Fatal("Cargar() no devolvió error con una ruta inexistente") } }Този тест наистина съществува в истинския проект (
config_test.go) и минава, защотоos.ReadFileвътре вCargarвръща грешка на операционната система, коятоCargarобвива с%w, преди да я предаде нататък.
Как разбирам, че съм успял
- Моят
revisorе организиран в пакети подinternal/, всеки с една-единствена отговорност. -
go test ./...се изпълнява и броят, а не само цветът, ми казва колко теста са изпълнени. - Написах поне една таблица със случаи с
t.Runи знам как да прочета кой случай се е провалил, когато нещо се счупи. - Пуснах
go test -coverи мога да кажа какъв процент излезе и какво има в празнината. - Счупих функция нарочно, видях точния
FAILи я поправих — имам го в дневника си. - Знам как да обясня защо
revisorнямаgo.sumи какво би го създало, ако някой ден ми потрябва. - Знам защо не бива да създавам пакет
utils.
За допълнително четене
- Writing tests — официалният урок за тестове, включително с таблица със случаи.
- Go Wiki: TableDrivenTests — каноничният справочник за шаблона, използван в целия този урок.
- Package internal — оригиналното обявяване на правилото за
internal/, направо от бележките към версията на Go. - Go Blog: The Cover Story — как работи
go test -coverотвътре и защо високото число не винаги означава добри тестове.
Термини от този урок
| Термин | Какво означава |
|---|---|
go.sum |
файл с криптографските отпечатъци на външните зависимости; revisor го няма, защото не използва нито една |
internal/ |
специална папка, която компилаторът на Go не позволява да се импортира извън модула |
| таблица със случаи | тестов шаблон, при който списък от входове и очаквани изходи се обхожда с едно-единствено тяло на теста |
t.Run |
изпълнява подслучай със собствено име, за да може отчетът да каже точно кой се е провалил |
| покритие | процентът от редовете на кода, които тестовете са упражнили при изпълнението си |
Предпочитате имейл? Пишете ни на hola@habil.mx