A minimal Go project that prints a greeting, accepts a name from the command line, includes a test suite, and uses a Makefile to drive common tasks. This tutorial covers project structure, flag parsing, table-driven tests, and build automation.
Prerequisites
- Go 1.22 or later
- A Unix-like terminal (macOS, Linux, or WSL on Windows)
makeinstalled (pre-installed on macOS and most Linux distributions)- No third-party packages required
Step 1: Set up the project structure
Create the file
mkdir -p hello-world
touch hello-world/.gitignore
Add the code: hello-world/.gitignore
bin/
*.test
*.out
.DS_Store
*.log
tmp/
Detailed breakdown
bin/excludes compiled binaries produced by the build step.*.testand*.outexclude Go test binaries and coverage output files..DS_Store,*.log, andtmp/cover common OS and temporary artifacts.
Step 2: Initialize the Go module
Create the file
cd hello-world
go mod init hello-world
Detailed breakdown
go mod init hello-worldcreates ago.modfile that declares the module path ashello-world. All import paths within the project use this prefix.- Since the project uses only the standard library, no dependencies are added to
go.mod.
Step 3: Create the greeter package
This package contains the core greeting logic, separated from the entry point so it can be tested independently.
Create the file
mkdir -p greeter
touch greeter/greeter.go
Add the code: greeter/greeter.go
// Package greeter provides greeting functions.
package greeter
import "fmt"
// Greet returns a greeting string for the given name.
// If name is empty, it defaults to "World".
func Greet(name string) string {
if name == "" {
name = "World"
}
return fmt.Sprintf("Hello, %s!", name)
}
Detailed breakdown
Greetis exported (capitalized) so it can be called from other packages, includingmainand test files.- The function handles the empty-string edge case by defaulting to
"World". This keeps the default behavior inside the library rather than pushing it to the caller. fmt.Sprintfbuilds the greeting string without printing it, making the function easy to test by comparing return values.
Step 4: Create the command-line entry point
The entry point parses an optional -name flag and prints the greeting.
Create the file
touch main.go
Add the code: main.go
// Command hello-world prints a greeting.
package main
import (
"flag"
"fmt"
"hello-world/greeter"
)
func main() {
name := flag.String("name", "", "name to greet (default: World)")
flag.Parse()
fmt.Println(greeter.Greet(*name))
}
Detailed breakdown
flag.Stringdefines a-nameflag with an empty default. When no flag is provided,Greetreceives an empty string and falls back to"World".- The import path
hello-world/greetermatches the module name declared ingo.modplus the package directory. flag.Parse()must be called before accessing flag values. Without it, all flags retain their zero values.fmt.Printlnadds a trailing newline, matching standard CLI output conventions.
Step 5: Create the test suite
Tests use Go’s table-driven test pattern to verify default, custom, and edge-case greetings.
Create the file
touch greeter/greeter_test.go
Add the code: greeter/greeter_test.go
package greeter
import "testing"
func TestGreet(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{name: "default greeting", input: "", want: "Hello, World!"},
{name: "custom name", input: "Go", want: "Hello, Go!"},
{name: "name with spaces", input: "Jane Doe", want: "Hello, Jane Doe!"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := Greet(tc.input)
if got != tc.want {
t.Errorf("Greet(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
}
Detailed breakdown
- Table-driven tests are the standard Go pattern for testing multiple inputs against a single function. Each struct in the
testsslice defines a named sub-test. t.Runcreates a named sub-test for each case, so failures report which specific case failed.- The
"default greeting"case passes an empty string to verify the fallback to"World". - The
"name with spaces"case confirms that multi-word names are handled without issue. - The test file lives in the same package (
package greeter) so it has access to exported symbols without a separate import.
Step 6: Create the Makefile
The Makefile provides a single command interface for building, running, testing, and cleaning the project. Running make with no arguments prints a help screen.
Create the file
touch Makefile
Add the code: Makefile
BINARY := bin/hello-world
.DEFAULT_GOAL := help
.PHONY: help build run test clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " %-15s %s\n", $$1, $$2}'
build: ## Build the binary to bin/
go build -o $(BINARY) .
run: build ## Build and run the application (use NAME= to set a custom name)
./$(BINARY) $(if $(NAME),-name "$(NAME)",)
test: ## Run the test suite
go test ./... -v
clean: ## Remove build artifacts
rm -rf bin/
Detailed breakdown
BINARY := bin/hello-worldcentralizes the output path so it only needs to change in one place..DEFAULT_GOAL := helpensures that running plainmakeprints the help screen rather than triggering a build.- The
helptarget usesgrepandawkto extract targets annotated with##comments and format them into a readable list. buildcompiles the project intobin/hello-world. The-oflag controls the output location.rundepends onbuild, so it always compiles before executing. The optionalNAMEvariable lets users runmake run NAME=Go.testrunsgo test ./... -vto discover and execute all tests in the module with verbose output.cleanremoves thebin/directory. Sincebin/is in.gitignore, this only affects local build artifacts.
Step 7: Run and validate
Execute the following commands from the hello-world/ directory.
Verify the help screen
cd hello-world && make
Expected output:
help Show this help screen
build Build the binary to bin/
run Build and run the application (use NAME= to set a custom name)
test Run the test suite
clean Remove build artifacts
Build and run the application
make run
Expected output:
Hello, World!
Run with a custom name
make run NAME=Go
Expected output:
Hello, Go!
Run the tests
make test
Expected output:
? hello-world [no test files]
=== RUN TestGreet
=== RUN TestGreet/default_greeting
=== RUN TestGreet/custom_name
=== RUN TestGreet/name_with_spaces
--- PASS: TestGreet (0.00s)
--- PASS: TestGreet/default_greeting (0.00s)
--- PASS: TestGreet/custom_name (0.00s)
--- PASS: TestGreet/name_with_spaces (0.00s)
PASS
ok hello-world/greeter 0.182s
Clean build artifacts
make clean
Troubleshooting
package hello-world/greeter is not in std: Make surego mod init hello-worldwas run from the project root before building.make: *** No rule to make target ...: Verify the Makefile uses tabs for indentation, not spaces. Many editors convert tabs to spaces by default.flag provided but not defined: -name: Ensureflag.Parse()is called inmain()before flag values are accessed.
Recap
This tutorial built a complete Go hello-world project with:
- A
greeterpackage with an exportedGreetfunction - A command-line entry point using the
flagpackage - Table-driven tests with three sub-test cases
- A Makefile with a default help screen, build, run, test, and clean targets
Next improvements could include adding a -greeting flag to customize the greeting word, a lint Makefile target using golangci-lint, or a Dockerfile for containerized builds.