Course contents
Lesson 5 — Modules and tests
By Dorian Chávez · founder of Hábil and integration architect ·
Duration: 90 minutes, or 2 sessions of 45.
By the end you will be able to:
- Explain what
go.modsolves and why therevisordoesn't needgo.sum. - Reorganize a one-file program into packages under
internal/, and explain what that folder gives you that a normal folder doesn't. - Write tests with a table of cases, without any external library, and read what they report when they fail.
- Run
go testwith-v,-run,-cover, and-race, and explain what each flag measures. - Recognize the "zero tests" trap that looks identical to "all of them passed".
- Decide, with a real coverage number in front of you, which part of that gap is worth fixing and which isn't.
Why it matters
Up to lesson 4 you had a single file, main.go, with everything inside: the Servicio and Estado
structs, the Revisor interface, the function that builds the report. It worked, and it worked well —but
a single file has a ceiling. As soon as you want to test one piece without running the whole program
(without touching the network, without reading a real file), a single main.go won't let you: everything
is mixed up with everything else.
This is the lesson where the revisor stops being a one-file exercise and becomes a real project:
several packages, each one with one responsibility, and a test suite that shows that each piece does what
it says without needing the others. It is the same leap you took in lesson 1 from "a program that fits in
your head" to "a program that lives in a folder with go.mod" — now that go.mod is going to organize
more than one file.
🔑 And it is not an organizational whim. A test that needs the network, a file on disk, or a running server in order to run is a slow, fragile test that nobody runs often. Splitting into packages is what lets you write tests that run in milliseconds, without touching anything external — and that is what makes you actually run them, on every change, not only when you remember.
The concepts
5.1 go.mod, and why this project has no go.sum
You already used go mod init in lesson 1 for the hola program. The revisor's go.mod is just as
simple:
module github.com/habil/revisor
go 1.27
Two lines: the name of the module (that is how another program would import it, if some day you publish one of its packages) and the minimum Go version it needs.
If you search for module tutorials on the internet, almost all of them are going to mention go.sum
right away — the file with the cryptographic hashes of each external dependency, so that nobody can slip
you a different version of a package you use. The revisor has no go.sum, and it is neither an
error nor an oversight:
$ ls go.sum
ls: go.sum: No such file or directory
It doesn't exist because the revisor doesn't import a single external package. Check the imports
of any file in the project and you are going to find only packages from the standard library: net/http,
encoding/json, context, sync, flag, os, time, strings, sort. It is a deliberate decision,
not a limitation: lesson 0 already warned about it —"learn the standard library before any
framework"— and the revisor is the proof that the standard library is enough for a complete program,
with concurrency, HTTP, JSON, and tests, without adding a single third-party dependency. If some day you
add one (for example, a real YAML client), at that moment go get is going to create go.sum for you,
and both files —go.mod and go.sum— go into the repository.
To really see what would have changed, I ran the test in a separate project (not in the revisor, which
still has no dependencies): a real go get of a small external package, 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=
That is what appears as soon as you add a single external dependency: go.mod gains a require
line, and go.sum is born with the cryptographic hashes (the long texts that start with h1:) of that
dependency and of its own dependencies (check.v1 is an indirect dependency of yaml.v3, not something
you asked for). Those hashes are what make go build refuse to compile if someone tried to slip you a
different version of the package with the same name and version — it is an integrity guarantee, not just
a record. The revisor doesn't have them because it doesn't need them: zero external dependencies, zero
surface for that kind of risk.
5.2 Packages by responsibility, not by layer
This is how the revisor ended up organized in this lesson:
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
By responsibility, not by layer. There is no modelos package with all the program's structs, nor a
utilidades package with loose functions: each package has a single question it knows how to answer.
servicio knows what a service and a state are. config knows how to read the configuration. revisar
knows how to query. reporte knows how to print. If tomorrow you change how the table looks, you touch one
file, not five.
🔑 internal/ is a compiler rule, not a convention of good manners. Any package that lives under a
folder called internal/ can only be imported by code that is inside the same module, at any level
above that internal/. Check it: if another Go module —any of them, not only one of yours— tries
import "github.com/habil/revisor/internal/servicio", the compiler refuses to compile, with an explicit
message that this package is internal. It is not a recommendation you can ignore under pressure: it is a
real restriction, the same kind of guarantee that the capital letter gives you inside a struct (lesson 3),
but at the level of a whole package.
⚠️ What we did NOT do, on purpose: a utils, helpers, or common package. It is the most repeated
antipattern in real projects: someone creates a folder for "things that don't fit anywhere else", and that
folder grows without limit until nobody knows what is inside or why. Every time you feel tempted to put
something in a package like that, ask yourself which responsibility that function belongs to, and put
it in the package that owns that responsibility — or if it really doesn't fit in any of them, that is a
sign that a new concept is missing a name, not that a catch-all drawer is missing.
5.3 The configuration file, and its real errors
This lesson's config reads a simple text format, one line per service:
nombre url [tiempo-limite]
# las lineas que empiezan con # se ignoran
catalogo https://catalogo.interno.mx
pagos https://pagos.interno.mx 500ms
This is done by the Interpretar function, which never reads a file: it receives the bytes already
read, and that is why it can be tested with a hundred content variants without creating a single temporary
file (Cargar, which does touch the disk, is a thin layer on top that only reads the file and passes the
content to Interpretar). Each badly written line produces a real error, with the line number and what
was expected — tested, not assumed:
$ (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
Notice the last one: it wraps the error from time.ParseDuration with %w (lesson 3) instead of
inventing its own text — that way, if you ever need to programmatically distinguish "invalid duration"
from another kind of error with errors.As, the original information is still there.
5.4 Tests: no libraries, with a table of cases
This is what a real test of the servicio package looks like (the complete file lives in
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)
}
})
}
}
There is no assert, no expect, no assertion library of any kind — and it is on purpose. Go compares
values with a normal if and reports with t.Errorf, with you writing what you expected and what you
got. At first it feels more verbose than an assert.Equal(t, esperado, obtenido) from other languages; the
gain is that you control the failure message, instead of inheriting a library's generic format, and that
there is nothing extra to install or learn in order to write the simplest test.
t.Run gives a name to each case in the table, and that matters when something fails: instead of a
generic "TestTimeoutEfectivo failed", the report says exactly which of the three cases it was:
$ 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
That output is real: I ran it on the code of this very project before writing this line. Adding a new
case to the table is adding a line to the casos slice — not a new function, not repeating the body of
the test. It is the idiomatic way of testing a function with many inputs in Go, and you are going to use it
in every package from here on.
5.5 Testing the errors, not only the successes
A table of cases also serves to test that something fails as it should, not only that it works
(abbreviated version here, with 3 of the 6 real cases and a positional struct literal instead of one with
field names, so that it fits; the complete one lives in 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 instead of t.Errorf in the first if: if Interpretar didn't return an error when it
should have, continuing to check err.Error() on the next line would panic (err would be nil).
Fatalf stops that particular test right there; Errorf lets the test keep running and accumulate more
failures before reporting. The practical rule: use Fatalf when continuing makes no sense without what
you just checked, Errorf when it does.
5.6 The commands you are always going to use
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
Actually run on the revisor, on 30-sep-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 What to do with a coverage number
92.3% and 97.4% are not goals, they are starting points for a question: what is that 8% and that 3%
that wasn't executed, and do I care? With go test -coverprofile you can see exactly which lines were
left untouched:
go test ./internal/servicio/... -coverprofile=/tmp/cobertura.out
go tool cover -func=/tmp/cobertura.out
In the revisor, the gap in servicio is the branch of Motivo() that builds the message when the code
is neither 0 nor a success with a specific text (fmt.Sprintf("codigo %d", ...) for a 404 with no further
context) — a branch the existing tests don't exercise with that exact code. The right decision is not
to chase 100% by filling each branch with a forced test that teaches nothing new: it is to look at the
gap, decide whether it matters (here, a little: you would add a case with 404 to the table), and write it
down, instead of pretending it doesn't exist.
🔴 And a real trap, measured in this very project: per-package coverage can underestimate a central
function without warning you. Run on its own, the revisor's revisar package (which you are going to
get to know in depth in lesson 6) reports:
$ go test ./internal/revisar/... -cover
ok github.com/habil/revisor/internal/revisar 1.33s coverage: 61.9% of statements
61.9% sounds like more than a third of that package is never executed in any test — and it is false.
Its most important function, Revisar (the one that really speaks HTTP), is well tested: it is just that
the test that really exercises it —TestEjecutar_reportaOKyFalla, with a real httptest server, in
lesson 7— lives in the cmd/revisor package, not in internal/revisar. go test ./internal/revisar/...
only counts what the tests OF THAT PACKAGE exercise; it doesn't see what a test from another package
goes through along the way, even if it passes through the same code. With -coverpkg, which asks Go to
measure a package's coverage counting all the project's tests, not only its own:
$ 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% for Revisar, 81.5% for the whole project — not 61.9%. The lesson is not "ignore the
per-package number": it is that a coverage number always answers an implicit question —coverage of what,
measured against the tests from where?— and go test ./paquete/... -cover keeps quiet about that second
half of the question. Before deciding that something "isn't tested" because of a low number, run
-coverpkg=./... over the whole project and compare.
5.8 Causing a failure, to know the test works
A test you have never seen fail is a test you don't know works — it could be comparing two things that are
always equal by accident. Break it on purpose: change TimeoutEfectivo() so it always returns
s.Timeout, without the if, and run the test:
$ 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
That FAIL, with the exact case that broke and the value it got against the one it expected, is the
proof that the test works. Put the if back and confirm it is green again before moving on.
5.9 Designing to be able to test: separating logic from I/O
Notice something we already mentioned in passing in section 5.3 and that deserves its own space: config
has two functions, not one.
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 (just the signature; the complete body, with all the parsing logic, is in section 5.3, with
its real errors):
func Interpretar(datos []byte, origen string) ([]servicio.Servicio, error) {
// ... toda la lógica de parseo, sin tocar el disco ...
}
If there were a single function Cargar(ruta string) that read the file and parsed everything
together, each test for "badly written line", "repeated name", or "invalid timeout" would have to start
by creating a temporary file on disk with os.CreateTemp, writing the case's content to it, passing it the
path, and deleting it at the end. It works, but it is slow (it touches the real file system) and it clutters
the test with code that has nothing to do with what is really being tested: whether your configuration
is interpreted correctly or not, not whether you know how to create temporary files.
By separating the part that decides (Interpretar, a pure function: same input bytes, same result
always, without access to anything external) from the part that obtains the bytes (Cargar, the only
one that touches the disk), the nine tests in section 5.5 run in microseconds and without creating a single
file. Cargar itself hardly needs tests of its own: only checking that it returns an error if the file
doesn't exist (exercise 6), because all the interesting logic is already in Interpretar and is already
tested.
🔑 The general rule, useful far beyond this project: when a function is hard to test, it is almost always because it mixes "deciding something" with "touching the outside world" (a file, the network, the clock). Separating them is not a style rule: it is what determines whether you are going to be able to write the test in three lines or in twenty.
5.10 Benchmarks: measuring, not guessing
Besides Test..., Go recognizes Benchmark... functions that measure how long your code takes, not
whether it is correct:
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)
}
}
}
You don't choose b.N: Go runs the loop with larger and larger values of N until the measurement is
stable, and reports the time per operation. Actually run on the revisor (Apple M5, 200,000 repetitions):
$ 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 '^$' tells go test not to run any normal test (a regular expression that doesn't match any
name), so that the benchmark report doesn't get mixed with the tests'. The number —432.5 nanoseconds per
call, on this machine, at this moment— is not for memorizing: it is for comparing against itself after
a change. If tomorrow you rewrite Interpretar and the benchmark goes up to 4,000 ns/op, you have an
objective signal that something got slower, without needing to have an opinion about it.
5.11 go vet: the one that finds what compiles but is wrong
go test tells you whether your logic does what you expected. go vet tells you whether your code has
an error that the compiler doesn't catch because, technically, it is valid — but it is almost certainly
not what you meant to write. The most common case is a formatting verb (lesson 2) that doesn't match the
type of the argument:
puerto := 443
fmt.Printf("servicio %s en el puerto %s\n", nombre, puerto) // %s para un int
This compiles and runs, without a panic or an error — and produces broken output (complete
program in programas/revisor/ejemplos/05-vet-printf/main.go):
$ go run ./ejemplos/05-vet-printf/
servicio catalogo en el puerto %!s(int=443)
And go vet, on that same file, does detect it:
$ 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 does detect it, because it analyzes the format string against the real types of the arguments, a
step the Go compiler doesn't take on its own. Run go vet ./... together with go test ./... as a
routine: most editors with the Go extension (lesson 1) already do it for you while you type, underlining
the problem before you even get to run anything.
5.12 Boundary tests: where bugs really live
Estado.OK() decides that a code is fine if it falls between 200 and 299. It is tempting to test it
with an "obvious" case (200) and an "obvious" failure case (500) and call it done. The revisor's real
table also tests the two values that sit right on the edge of the range:
{"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)
Why does it matter, if 199 and 300 "obviously" are not OK? Because a one-character error in the
condition —>= instead of >, or <= instead of <— is exactly the kind of bug an "obvious" case never
detects, and a boundary case always detects. If someone changed OK() to
e.Codigo >= 200 && e.Codigo <= 300 (including 300 by mistake), the 200/299/404/500 cases would keep
passing just as well — only the 300 case would give it away. That is the underlying reason behind
"test the edges, not only the center": the edges are where comparison errors hide, and they are invisible
to any test that only uses values far inside or far outside the range.
The error you will see
| The symptom | Literal message | What happens and what to do |
|---|---|---|
Import of someone else's internal package |
use of internal package github.com/habil/revisor/internal/servicio not allowed |
The compiler prevents importing something under internal/ from outside the module. It is not a permission you can grant: you have to expose the type from a public package if it is really needed |
| Duplicate service in the configuration | prueba.txt:2: el nombre "catalogo" ya estaba en la linea 1 |
Two services with the same name; fix the configuration file |
| URL without a scheme | prueba.txt:1: la URL "catalogo.interno.mx" debe empezar con http:// o https:// |
http:// or https:// is missing at the start of the URL |
| Badly written timeout | prueba.txt:1: tiempo limite "nombas" invalido (usa 500ms, 2s, 1m): time: invalid duration "nombas" |
Go's duration format is not free-form: use a number followed by a unit (ms, s, m, h) |
| A test you broke on purpose | servicio_test.go:64: TimeoutEfectivo() = 0s, quería 2s |
See section 5.8: this is what a t.Errorf looks like, pointing out exactly which case failed and why |
| Zero tests in a package | ? github.com/habil/revisor/cmd/servidor-demo [no test files] |
It is not a failure: go test warns explicitly when a package has no _test.go file at all, instead of pretending it ran something |
And the most important one to learn to read, because it is not an error but the absence of one:
$ go test ./...
ok github.com/habil/revisor/internal/vacio 0.001s
If internal/vacio had no Test... function at all, that line would look exactly the same: ok, in
green, with no mark that nothing was executed. The only way to distinguish "everything passed, for real"
from "there was nothing to run" is to look at the count with -v (which does print each RUN) or, better,
never trust a package that has no _test.go file at all — that go test does say, as in the row of the
table above.
What goes wrong
- Creating a
utils,helpers, orcommonpackage. You already saw it in section 5.2: it is the most repeated antipattern and the one that loses its purpose fastest. Name the responsibility, not the fact that it "doesn't fit anywhere else". - Confusing "it compiled" with "the tests passed".
go buildverifies that the code is valid; it doesn't run a single test. They are two different commands with two different questions. - Reading the exit code of
go testinstead of the count. A package without test files exits with code 0 (success), just like one with 50 tests that did pass. The exit code answers "did something fail?", not "was something tested?" — for the second question you need to read the output, not just the$?. - Chasing 100% coverage as if it were the goal. High coverage with weak assertions (checking that
something doesn't blow up, without checking what it returns) gives a pretty figure and a test that
detects almost nothing. The number is a guide to where to look, not a goal in itself — section 5.7 shows
it with a real gap in the
revisoritself. - Writing a test and never seeing it fail. If you never broke the code on purpose to confirm that the test turns red, you don't know whether that test tests anything. Section 5.8 did it with real data from the project: do it yourself too with at least one test of your own before calling it good.
Exercises
- Clone the package structure from section 5.2 (
internal/servicio,internal/config) for your own copy of therevisor, moving the code you already had from lessons 2 to 4. - Write the table of cases for
TestEtiquetafor theEtiqueta()method ofServicio, with at least one case with a normal name and one with an empty struct. - Add a case to
TestInterpretar_casosDeErrorfor a line with four fields (more than the three the format allows). Check the exact message against the code inconfig.go. - Run
go test ./... -coveron your copy and write down the percentage of each package. Choose one coverage gap and decide, in writing in your logbook, whether you care about closing it and why. - (As in section 5.8) Break on purpose a function you already tested, run the test, read the full
FAIL, and fix it. Paste both results —the red one and the green one— in your logbook. - (A bit harder) Write
TestCargar_archivoInexistente, which confirms thatCargar(notInterpretar) returns an error when the path doesn't exist. Hint: you don't need to create any file for this test, just pass a path you know doesn't exist.
Solutions
1 and 2 don't have a single reference solution: it depends on how you had your own lesson 4 program
organized. Compare your result against the real code of programas/revisor/internal/servicio/servicio.go
in this course's project.
With
"catalogo https://a.mx 500ms extra\n", the expected message isprueba.txt:1: esperaba «nombre url [tiempo]», hay 4 campo(s)— the same code path that already handles "missing fields", becauselen(campos) > 3covers both cases with a single check.There is no single answer: what matters is that the decision is written down with its reason, not the percentage itself.
See the whole of section 5.8: the pattern is always "change the code, run the test, read the
FAILwith the exact case, fix it, run it again".Like this:
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") } }This test does exist in the real project (
config_test.go) and it passes becauseos.ReadFile, insideCargar, returns an operating system error thatCargarwraps with%wbefore propagating it.
How I know I got it
- My
revisoris organized in packages underinternal/, each one with a single responsibility. -
go test ./...runs and the count, not just the color, tells me how many tests were executed. - I wrote at least one table of cases with
t.Run, and I know how to read which case failed when something breaks. - I ran
go test -coverand I can say what percentage came out and what is in the gap. - I broke a function on purpose, saw the exact
FAIL, and fixed it — I have it in my logbook. - I can explain why the
revisorhas nogo.sumand what would generate it if some day it needed one. - I know why I shouldn't create a
utilspackage.
Further reading
- Writing tests — the official testing tutorial, with a table of cases included.
- Go Wiki: TableDrivenTests — the canonical reference for the pattern used throughout this lesson.
- Package internal — the original announcement of the
internal/rule, straight from the Go release notes. - Go Blog: The Cover Story — how
go test -coverworks inside, and why a high number doesn't always mean good tests.
Terms from this lesson
| Term | What it means |
|---|---|
go.sum |
file with the cryptographic hashes of the external dependencies; the revisor doesn't have one because it uses none |
internal/ |
special folder that the Go compiler prevents from being imported from outside the module |
| table of cases | test pattern where a list of inputs and expected outputs is iterated over with a single test body |
t.Run |
runs a subcase with its own name, so the report says exactly which one failed |
| coverage | percentage of the code's lines that the tests exercised when running |
Prefer email? Write to us at hola@habil.mx