Design - Software Engineering

Purpose of func main()

The purpose of func main() is to translate commandline arguments to application startup state. Once the state is prepared a specific entry function is called. More often than not, logging verbosity is one such state that needs to be configured early on.
Go provides the builtin flag package to define, document and parse the arguments.

Example CountStars(galaxy string)

Imagine an application that counts the stars in a named galaxy. The main function should then make sure the options are correct and forward them as arguments to the function doing the actual work. The name of the galaxy would be such an option and perhaps a verbosity flag for debugging purposes.

func main() {

	galaxy := "milkyway"
	flag.StringVar(
		&galaxy, "galaxy",
		galaxy, "name of galaxy to count stars in",
	)

	verbose := false
	flag.BoolVar(
		&verbose, "verbose",
		verbose, "enables verbose logging",
	)

	flag.Parse() // exits on error

	SetVerbose(verbose)
	CountStars(os.Stdout, galaxy)
}

Now that you know what the main function should do, let us take a look at how to do it, apart of the option definition and argument passing.
First, the cyclomatic complexity of the main function is one. Ie. there is only one path through this program. There are however two exit points, apart from the obvious one flag.Parse() exits if the parsed options do not match the predefined. The single pathway means that testing the main function is simple. Execute this application with valid arguments and all lines are covered, leaving all other code for unittesting.
Also, if you execute the program you would note that second, the order of the options are sorted in the same way as the help output.

Cyclomatic complexity should be one.
Option order should match output.

Benefits

Adhering to the “keep it simple principle” and only doing one thing in each function, works out nicely for the main function as well. One could argue that, if you moved everything inside main into a start function, the option definitions would also be tested. Think about it for a minute and figure out what exactly you would be testing. If the flag package already makes sure it's functions work as expected the only thing left is testing which options you have defined. They would need to be updated each time you add or remove an option which is a sign of a poor test.
You could potentially refactor main and separate the option definitions into smaller functions for readability but you still wouldn't need to write unittests for them. However, when your application grows and command line arguments start having relations you ought to verify that. More on this in the next section.

But start of and keep main simple, constrain it to only set global startup state before calling the one function that does the actual work.
This works great for services and simpler commands that only do one thing.

More advanced commands

When the commands get more complex with many more options the above approach has its limits. Number of arguments to CountStars will grow and become hard to verify any relations between them. One solution is to turn func CountStars into a command. Advanced commands may also have logic for combination of options which would suggest you should verify command execution with various options. This is impossible to do with the above approach while tracking coverage.

Run is now testable and complexity can grow slightly
package main

import (
	"fmt"
)

// NewStarCounter returns a new star counter command with logging
// enabled to cmd.Stderr.
func NewStarCounter() *StarCounter {
	return &StarCounter{}
}

type StarCounter struct {
	size   string
	weight int
}

func (s *StarCounter) SetSize(v string) { s.size = v }
func (s *StarCounter) SetWeight(v int)  { s.weight = v }

// Run starts the application and waits for it to complete.
func (s *StarCounter) Run() error {
	// count stars using filters
	if s.weight < 0 {
		return fmt.Errorf("bad weight")
	}
	// do the actual counting...
	return nil
}

Complexity of func main has grown slightly, looking like

Alternate cmdline package for parsing arguments.
package main

import (
	"github.com/gregoryv/cmdline"
)

func main() {
	var (
		cli    = cmdline.NewBasicParser()
		size   = cli.Option("-size").String("all")
		weight = cli.Option("-weight").Int(0)
	)
	cli.Parse()

	sc := NewStarCounter()
	sc.SetSize(size)
	sc.SetWeight(weight)

	if err := sc.Run(); err != nil {
		cmdline.DefaultShell.Fatal(err)
	}
}

Testing complex patterns is still doable by testing the main func. Although with this design unit tests of func StarCounter.Run() are probably even simpler once variation increase.

package main

import (
	"strings"
	"testing"

	"github.com/gregoryv/cmdline"
	"github.com/gregoryv/cmdline/clitest"
)

func TestStarCounter(t *testing.T) {
	okCases := map[string]string{
		"no args":    "",
		"short help": "-h",
		"long help":  "--help",
		"small size": "-size small",
	}
	for name, args := range okCases {
		t.Run(name, func(t *testing.T) {
			checkMain(t, args, 0)
		})
	}

	badCases := map[string]string{
		"heavy weight":     "-weight heavy",
		"unknown argument": "-nosuch",
	}
	for name, args := range badCases {
		t.Run(name, func(t *testing.T) {
			checkMain(t, args, 1)
		})
	}
}

func checkMain(t *testing.T, in string, expectedExitCode int) {
	t.Helper()
	args := strings.Split("test "+in, " ")
	sh := clitest.NewShellT(args...)
	cmdline.DefaultShell = sh
	defer sh.Cleanup()

	main()

	if sh.ExitCode != expectedExitCode {
		t.Error(sh.Dump())
	}
}