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.
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.
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
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())
}
}