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)
  • make installed (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.
  • *.test and *.out exclude Go test binaries and coverage output files.
  • .DS_Store, *.log, and tmp/ 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-world creates a go.mod file that declares the module path as hello-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

  • Greet is exported (capitalized) so it can be called from other packages, including main and 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.Sprintf builds 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.String defines a -name flag with an empty default. When no flag is provided, Greet receives an empty string and falls back to "World".
  • The import path hello-world/greeter matches the module name declared in go.mod plus the package directory.
  • flag.Parse() must be called before accessing flag values. Without it, all flags retain their zero values.
  • fmt.Println adds 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 tests slice defines a named sub-test.
  • t.Run creates 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-world centralizes the output path so it only needs to change in one place.
  • .DEFAULT_GOAL := help ensures that running plain make prints the help screen rather than triggering a build.
  • The help target uses grep and awk to extract targets annotated with ## comments and format them into a readable list.
  • build compiles the project into bin/hello-world. The -o flag controls the output location.
  • run depends on build, so it always compiles before executing. The optional NAME variable lets users run make run NAME=Go.
  • test runs go test ./... -v to discover and execute all tests in the module with verbose output.
  • clean removes the bin/ directory. Since bin/ 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 sure go mod init hello-world was 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: Ensure flag.Parse() is called in main() before flag values are accessed.

Recap

This tutorial built a complete Go hello-world project with:

  1. A greeter package with an exported Greet function
  2. A command-line entry point using the flag package
  3. Table-driven tests with three sub-test cases
  4. 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.