Go Tutorials: Go Testing, Benchmarking, and Profiling

These three topics become much easier once you separate the questions they answer:

TopicMain questionMain Go toolResult
TestingDoes my code work correctly?go testPASS / FAIL
BenchmarkingHow fast is this code?go test -benchns/op, allocations
ProfilingWhere is my program spending CPU/memory?go tool pprofHot functions / bottlenecks

A useful analogy:

  • Testing = examiner → “Is the answer correct?”
  • Benchmarking = stopwatch → “How long does it take?”
  • Profiling = heat map → “Where is the time being spent?”

Go has built-in support for all three, so the examples below require no third-party testing or profiling library. go test, the testing package, runtime/pprof, and go tool pprof are part of the Go toolchain. (Go.dev)


0. Before starting

Verify Go:

go version

That’s essentially all the installation you need for this tutorial.

There is:

  • no testing framework to install
  • no benchmark package to install
  • no profiler to install
  • no configuration file needed

For each example we’ll create a tiny Go module using:

go mod init ...

1. Testing

What?

Testing checks whether your program produces the correct result.

For example:

2 + 3 should equal 5

A test automatically calls your function and checks that expectation.

In Go, test files normally end with:

_test.go

and test functions use *testing.T. The go test command discovers and runs them automatically. (Go.dev)


Why?

Without automated tests, you might change some code and accidentally break something that worked yesterday.

Testing gives you confidence that:

change code
    ↓
run tests
    ↓
still works

When?

Run tests:

  • while developing
  • after changing code
  • before committing code
  • before releasing software
  • after fixing bugs

Where?

Normally tests sit beside the code they test:

testing-demo/
├── go.mod
├── calculator.go
└── calculator_test.go

How?

Step 1 — Create the project

mkdir testing-demo
cd testing-demo
go mod init example.com/testing-demo

Complete example

calculator.go

package calculator

// Add returns the sum of two integers.
func Add(a, b int) int {
	return a + b
}

calculator_test.go

package calculator

import "testing"

func TestAdd(t *testing.T) {
	// Call the real function.
	got := Add(2, 3)

	// This is the result we expect.
	want := 5

	// Compare actual result with expected result.
	if got != want {
		t.Fatalf("Add(2, 3) = %d; want %d", got, want)
	}
}

That’s the entire test.


Run it

go test

You should see something similar to:

PASS
ok      example.com/testing-demo

For more detail:

go test -v

Example:

=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
PASS
ok      example.com/testing-demo

-v requests verbose test output. (Go Packages)


Understand the important lines

This creates a test

func TestAdd(t *testing.T)

Think:

Test + FunctionName

Call your real code

got := Add(2, 3)

got means:

What did my program actually produce?


Define what you expect

want := 5

want means:

What should the program produce?


Compare them

if got != want {

If:

got = 5
want = 5

everything is fine.

If:

got = 4
want = 5

the test fails.


Fail the test

t.Fatalf(...)

This tells Go:

STOP — this test failed

Testing mental model

Remember:

INPUT
  ↓
FUNCTION
  ↓
ACTUAL RESULT
  ↓
compare
  ↓
EXPECTED RESULT

Testing cares about correctness, not speed.


Testing commands to remember

# Run tests in current package
go test

# Detailed output
go test -v

# Test every package below current directory
go test ./...

Go’s tooling recognizes _test.go files and runs compatible test functions through go test. (Go.dev)


2. Benchmarking

Now suppose your function is correct.

Next question:

How fast is it?

That is benchmarking.


What?

A benchmark repeatedly executes some code and measures its performance.

Instead of:

Does Add() return the correct answer?

you ask:

How long does BuildMessage() take?

Why?

Imagine you have two implementations:

Version A → 5 milliseconds
Version B → 1 millisecond

Both may produce exactly the same answer.

Testing cannot tell you which is faster.

Benchmarking can.


When?

Use benchmarking when:

  • optimizing code
  • comparing two implementations
  • investigating performance
  • checking allocations
  • evaluating a performance change

Don’t benchmark everything automatically.

Benchmark code where performance actually matters.


Where?

Benchmarks also normally live in _test.go files.

Example:

benchmark-demo/
├── go.mod
├── message.go
└── message_test.go

Benchmark functions use:

func BenchmarkSomething(b *testing.B)

The go test -bench=. command runs benchmarks; benchmarks are not run by default. (Go.dev)


Complete example

Step 1 — Create project

mkdir benchmark-demo
cd benchmark-demo
go mod init example.com/benchmark-demo

message.go

package message

import "strings"

// BuildMessage creates a string containing "Go"
// repeated n times.
func BuildMessage(n int) string {
	var builder strings.Builder

	for i := 0; i < n; i++ {
		builder.WriteString("Go")
	}

	return builder.String()
}

message_test.go

package message

import "testing"

// Store the result so the compiler cannot simply
// throw away the work being benchmarked.
var benchmarkResult string

func BenchmarkBuildMessage(b *testing.B) {
	for i := 0; i < b.N; i++ {
		benchmarkResult = BuildMessage(100)
	}
}

Run the benchmark

go test -bench=.

You may see something like:

BenchmarkBuildMessage-8    1200000    850 ns/op
PASS

Your numbers will probably be different.

That’s expected.

CPU, OS, Go version and machine workload all affect benchmark results.


The mysterious b.N

This confuses many people initially:

for i := 0; i < b.N; i++ {

You don’t normally choose b.N.

Go’s benchmark runner controls it.

Conceptually:

Go: Try 1 iteration
Go: Too quick.

Go: Try more iterations
Go: Still quick.

Go: Try many iterations
Go: Good. Now I have enough data.

So your benchmark simply says:

for i := 0; i < b.N; i++ {
	doThingBeingMeasured()
}

Measure memory too

Run:

go test -bench=. -benchmem

-benchmem tells go test to print memory-allocation statistics for benchmarks. (Go Packages)

You might see:

BenchmarkBuildMessage-8    1200000    850 ns/op    224 B/op    1 allocs/op

Read it approximately like this:

ValueMeaning
1200000Number of benchmark iterations
850 ns/opAbout 850 nanoseconds per operation
224 B/opAbout 224 bytes allocated per operation
1 allocs/opAbout 1 allocation per operation

Benchmarking mental model

FUNCTION
   ↓
run
run
run
run
run
run
   ↓
measure
   ↓
time/op
memory/op
allocations/op

Benchmarking cares about performance measurements.

It does not tell you why something is slow.

That brings us to profiling.


Benchmark commands to remember

# Run all benchmarks
go test -bench=.

# Benchmark + memory allocations
go test -bench=. -benchmem

The . in:

-bench=.

is a regular expression matching all benchmark names. Go’s current test command documentation explicitly describes -bench=. as the way to run all benchmarks. (Go.dev)


3. Profiling

This is the one people usually confuse with benchmarking.

Suppose your benchmark tells you:

This operation takes 500 ms.

Great.

But why does it take 500 ms?

Where is that time going?

Function A?
Function B?
JSON parsing?
Database work?
String processing?
Garbage collection?

Profiling helps answer that.


What?

Profiling observes a running program and identifies where resources are being consumed.

For CPU profiling:

Program uses lots of CPU
       ↓
Profiler samples execution
       ↓
Function A → 5%
Function B → 10%
Function C → 75%
       ↓
Function C is suspicious

Go provides runtime profiling data that can be examined with go tool pprof. CPU profiles tell you where the program spends CPU time. (Go.dev)


Why?

Benchmarking tells you:

The operation is slow.

Profiling tells you:

This particular function is consuming most of the CPU.

That’s the critical difference.


When?

Use profiling when:

  • an application is slow
  • CPU usage is high
  • memory usage is high
  • you know there is a performance problem but don’t know where it is
  • benchmarks show regression and you need the cause

Where?

Profiling is normally performed against a realistic workload.

For learning, we’ll intentionally create a CPU-heavy program and profile it.

Our folder:

profile-demo/
├── go.mod
└── main.go

Complete CPU profiling example

Create project

mkdir profile-demo
cd profile-demo
go mod init example.com/profile-demo

main.go

package main

import (
	"fmt"
	"log"
	"os"
	"runtime/pprof"
	"time"
)

// isPrime intentionally performs CPU work.
// The profiler should identify this as expensive.
func isPrime(n int) bool {
	if n < 2 {
		return false
	}

	for divisor := 2; divisor*divisor <= n; divisor++ {
		if n%divisor == 0 {
			return false
		}
	}

	return true
}

// countPrimes calls isPrime many times.
func countPrimes(limit int) int {
	count := 0

	for n := 2; n <= limit; n++ {
		if isPrime(n) {
			count++
		}
	}

	return count
}

func main() {
	// Create the file where CPU profiling data will be stored.
	file, err := os.Create("cpu.prof")
	if err != nil {
		log.Fatal(err)
	}

	// Start collecting CPU profiling data.
	if err := pprof.StartCPUProfile(file); err != nil {
		log.Fatal(err)
	}

	// Run CPU-heavy work for about 2 seconds.
	deadline := time.Now().Add(2 * time.Second)

	result := 0
	runs := 0

	for time.Now().Before(deadline) {
		result += countPrimes(50000)
		runs++
	}

	// Stop profiling so all profiling data is written.
	pprof.StopCPUProfile()

	if err := file.Close(); err != nil {
		log.Fatal(err)
	}

	fmt.Printf("Completed %d runs; result=%d\n", runs, result)
	fmt.Println("CPU profile written to cpu.prof")
}

The important profiling calls are:

pprof.StartCPUProfile(file)

and:

pprof.StopCPUProfile()

Stopping the profile before the program exits is important so pending profiling information can be written. This is the standard pattern documented by Go. (Go.dev)


Step 1 — Build the program

go build -o profile-demo .

You’ll now have:

profile-demo

Step 2 — Run it

./profile-demo

Example:

Completed 42 runs; result=215292
CPU profile written to cpu.prof

You’ll now see:

profile-demo/
├── go.mod
├── main.go
├── profile-demo
└── cpu.prof

cpu.prof contains the profiling information.


Step 3 — Analyze the profile

Here is our profiling tool:

go tool pprof

Run:

go tool pprof -top ./profile-demo cpu.prof

The documented pprof command accepts the program binary and profile data for analysis. (Go Packages)

You should see output roughly resembling:

Showing nodes accounting for 1.92s, 96% of 2.00s total

      flat  flat%   sum%        cum   cum%
     1.70s 85.00% 85.00%      1.70s 85.00%  main.isPrime
     0.15s  7.50% 92.50%      1.85s 92.50%  main.countPrimes

Your exact numbers will differ.

The important part is:

main.isPrime

If that function consumes most of the CPU, you have discovered your hotspot.


Understanding flat and cum

Two useful columns are:

flat

CPU time spent directly inside that function.

Example:

main.isPrime    1.70s

cum

Cumulative time:

function itself
+
functions it called

You don’t need to memorize every pprof column yet.

Initially ask:

Which functions are at the top?

That’s enough to get started.


Profiling mental model

             PROGRAM
                │
                ▼
        ┌────────────────┐
        │ CPU activity   │
        └────────────────┘
                │
                ▼
          runtime/pprof
                │
                ▼
            cpu.prof
                │
                ▼
        go tool pprof
                │
                ▼
        HOT FUNCTIONS

Profiling cares about where resources are being spent.

Go’s diagnostics documentation describes profiling specifically as a way to identify expensive or frequently executed parts of a program. (Go.dev)


The three together

This is the workflow I recommend remembering:

flowchart TD
    A[Write Go code] --> B[Testing]
    B --> C{Correct?}
    C -->|No| D[Fix code]
    D --> B
    C -->|Yes| E[Benchmarking]
    E --> F{Performance good?}
    F -->|Yes| G[Done]
    F -->|No| H[Profiling]
    H --> I[Find hotspot]
    I --> J[Optimize hotspot]
    J --> B

Notice something important:

Profiling does not replace benchmarking. Benchmarking does not replace testing.

They solve different problems.


Testing vs Benchmarking vs Profiling

TestingBenchmarkingProfiling
Main purposeCorrectnessMeasure performanceFind performance bottleneck
Main questionDoes it work?How fast is it?Where is time/resources going?
Standard Go toolgo testgo test -benchgo tool pprof
Go APItesting.Ttesting.Bruntime/pprof
Typical file_test.go_test.goapplication code/profile
OutputPASS/FAILns/ophotspots
Memory infoNot primary purpose-benchmemmemory profiles possible
Runs code repeatedly?Usually a defined test caseYes, many timesObserves executing program
Finds bugs?YesNot primarilyNot primarily
Finds slow code?NoTells you something is slowTells you where it is slow
Best time to useConstantlyPerformance workPerformance investigation

One example situation

Imagine an HTTP endpoint:

GET /products

Users say it is slow.

First: Testing

You test:

Does /products return the correct products?

Result:

PASS

So correctness looks good.


Second: Benchmarking

You benchmark:

How long does product processing take?

Result:

50 ms/op

That’s slower than you’d like.

But you still don’t know why.


Third: Profiling

Profile the program.

You discover:

parseProducts()        5%
sortProducts()        80%
formatResponse()       10%
other                  5%

Now you know:

sortProducts()

is where you should investigate.

That’s the relationship between the three.


The commands you actually need to memorize

For now, memorize only these six:

Testing

go test
go test -v
go test ./...

Benchmarking

go test -bench=.
go test -bench=. -benchmem

Profiling

go tool pprof -top ./program cpu.prof

That’s enough for a solid foundation.


Final mental cheat sheet

When confused, ask yourself which question you are trying to answer:

"IS MY CODE CORRECT?"
        │
        ▼
      TEST
   go test


"HOW FAST IS MY CODE?"
        │
        ▼
    BENCHMARK
 go test -bench=.


"WHY/WHERE IS IT SLOW?"
        │
        ▼
     PROFILE
 go tool pprof

Or even shorter:

Testing      = correctness
Benchmarking = measurement
Profiling    = investigation

Recommended learning order

Don’t try to master all three simultaneously. Learn them in exactly this sequence:

1. testing.T
      ↓
2. testing.B
      ↓
3. runtime/pprof + go tool pprof

Once that distinction is completely clear, more advanced topics such as table-driven tests, mocks, coverage, subtests, benchmark comparisons, memory profiling, HTTP pprof, tracing, race detection, and fuzzing become much easier to understand.

Related Posts

Go Tutorials: Go Methods

Go methods become much easier once you understand one idea: A method is simply a function attached to a type. And the important rule is: A receiver…

Read More

Go Tutorials: Modules, Packages, Subpackages, Submodules, and Workspaces

This tutorial gives you one complete mental model for: The most important idea is: And sometimes: These two designs are very different. 1. What is a Go…

Read More

Go Tutorials: Numeric Types Beginner Tutorial

Go has four main families of numeric types: The simplest way to remember them is: Type family Stores Example int Whole numbers, positive or negative -10, 0,…

Read More

Go Tutorials: Generics

1. What are Generics? Generics let you write one piece of code that works with multiple types while keeping Go’s compile-time type safety. Suppose you want a…

Read More

Go Concurrency Management Made Simple

This tutorial covers: The goal is: One concept → one purpose → one complete runnable example. Every example is independent. Save any example as: and run: 0….

Read More

Go Tutorials: Concurrency Made Simple

This tutorial covers: The goal is simple: One concept → one mental model → one complete example. Every example is independent. You can copy it into main.go…

Read More