This episode sharpens your Go error handling idioms with error, fmt.Errorf, errors.Is, and errors.As. You will also write unit tests with the testing package, table-driven tests, and benchmarks, then debug code with go test and delve.

In episode 3 you already learned the if err != nil pattern. Now we deepen it into a complete system: how errors are created, compared, and debugged. On the other side, an untested program is not a production program — Go provides testing and benchmarking tooling directly in the standard library.
Episode 6 covers three pillars of Go code quality: advanced error handling idioms with errors.Is and errors.As, writing unit tests with the testing package including table-driven tests and benchmarks, and debugging techniques with go test and delve.
An error in Go is an ordinary value implemented through the error interface with a single method Error() string. To create an error with context, use fmt.Errorf and wrap the original error with %w:
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("data tidak ditemukan")
func cariData(id int) (string, error) {
if id != 42 {
return "", fmt.Errorf("cari id %d: %w", id, ErrNotFound)
}
return "data rahasia", nil
}Sentinel variables like ErrNotFound let callers compare errors without unpacking their strings.
errors.Is walks the chain of %w-wrapped errors to check whether a specific error is present. errors.As extracts an error from the chain into a target type:
_, err := cariData(1)
if errors.Is(err, ErrNotFound) {
fmt.Println("kategori: not found")
}
var target *TipeCustomError
if errors.As(err, &target) {
fmt.Println("detail kustom:", target.Kode)
}The golden rule: use errors.Is for sentinel errors, and errors.As for custom-typed errors. Never compare errors with == unless the variables are truly identical.
Wrap an error every time it crosses an architectural layer boundary — from repository to service, from service to handler. Don't wrap errors that are already informative enough, and avoid redundant wording like "error when ... an error occurred". Common convention: start messages with a lowercase letter because messages are often concatenated.
Test files end in _test.go and live in the same directory as the source. Test functions start with Test and accept a *testing.T parameter. Run them with go test ./....
package kalkulator
import "testing"
func TestTambah(t *testing.T) {
hasil := Tambah(2, 3)
if hasil != 5 {
t.Errorf("Tambah(2, 3) = %d, harap 5", hasil)
}
}t.Errorf marks a test as failed and continues execution, while t.Fatalf stops the test immediately. For regular logging while a test runs, use t.Logf.
This pattern is the de facto standard in the Go ecosystem: one slice containing test cases, then a single loop running them all. Adding a new case is just adding one row to the table.
func TestBagi(t *testing.T) {
cases := []struct {
nama string
a, b float64
ingin float64
harapErr bool
}{
{nama: "pembagian normal", a: 10, b: 2, ingin: 5},
{nama: "pembagian nol", a: 10, b: 0, harapErr: true},
}
for _, tc := range cases {
t.Run(tc.nama, func(t *testing.T) {
hasil, err := Bagi(tc.a, tc.b)
if tc.harapErr && err == nil {
t.Fatal("harapkan error")
}
if !tc.harapErr && hasil != tc.ingin {
t.Errorf("hasil = %v, harap %v", hasil, tc.ingin)
}
})
}
}The t.Run subtest makes each case reported separately, so a failing test is immediately visible in the go test -v output.
Benchmark functions start with Benchmark and accept *testing.B. The b.N loop is run adaptively by the framework until it is accurate.
func BenchmarkTambah(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = Tambah(i, 1)
}
}Run them with the -bench and -benchmem flags to see memory allocations:
go test -bench=. -benchmem ./...The metrics shown: number of iterations, duration per operation, bytes allocated per operation, and number of allocations. These benchmarks become the starting point for episode 13 on performance optimization.
go test -v shows the names of running tests and t.Logf output. -run filters tests by name pattern, which is very useful when a package contains hundreds of tests.
go test -run TestBagi -v ./...When fmt.Println is not enough, use delve — the official debugger for Go. Install it with go install github.com/go-delve/delve/cmd/dlv@latest, then start a debugging session. Delve integrates with editors through DAP, so you can set breakpoints directly from VS Code or Neovim.
dlv debug main.goInside the session: break main.go:10 for a breakpoint, continue to keep going, print variabel to inspect, and next to execute line by line. Exit with exit.
Episode 6 completed the quality pillars of Go code: creating and comparing errors with errors.Is and errors.As, writing unit tests and table-driven tests with the testing package, measuring performance with benchmarks, and debugging with go test and delve.
Key takeaways:
%w to preserve the chain.errors.Is for sentinels, errors.As for custom types._test.go and run with go test.t.Run subtests are the industry standard.*testing.B and the -benchmem flag.In the next episode we will discuss application configuration, environment variables, and flags — reading configuration from the environment and .env files, handling CLI flags with the flag package, modern frameworks like cobra, and configuration best practices for local, staging, and production.