Writing Command-Line Applications
In this chapter, you will learn about the building blocks of writing command-line applications. You will use standard library packages to construct command-line interfaces, accept user input, and learn techniques to test your applications. Let's get started!
Your First Application
All command-line applications essentially perform the following steps:
- Accept user input
- Perform some validation
- Use the input to perform some custom task
- Present the result to the user; that is, a success or a failure
In a command-line application, an input can be specified by the user in several ways. Two common ways are as arguments when executing the program and interactively by typing it in. First you will implement a greeter command-line application that will ask the user to specify their name and the number of times they want to be greeted. The name will be input by the user when asked, and the number of times will be specified as an argument when executing the application. The program will then display a custom message the specified number of times. Once you have written the complete application, a sample execution will appear as follows:
$ ./application 6Your name please? Press the Enter key when done.Joe CoolNice to meet you Joe CoolNice to meet you Joe CoolNice to meet you Joe CoolNice to meet you Joe CoolNice to meet you Joe CoolNice to meet you Joe Cool
First, let's look at the function asking a user to input their name:
func getName(r io.Reader, w io.Writer) (string, error) { msg := "Your name please? Press the Enter key when done.\n" fmt.Fprintf(w, msg) scanner := bufio.NewScanner(r) scanner.Scan() if err := scanner.Err(); err != nil { return "", err } name := scanner.Text() if len(name) == 0 { return "", errors.New("You didn't enter your name") } return name, nil}
The getName()
function accepts two arguments. The first argument, r
, is a variable whose value satisfies the Reader
interface defined in the io
package. An example of such a variable is Stdin
, as defined in the os
package. It represents the standard input for the program—usually the terminal session in which you are executing the program.
The second argument, w
, is a variable whose value satisfies the Writer
interface, as defined in the io
package. An example of such a variable is the Stdout
variable, as defined in the os
package. It represents the standard output for the application—usually the terminal session in which you are executing the program.
You may be wondering why we do not refer to the Stdin
and Stdout
variables from the os
package directly. The reason is that doing so will make our function very unfriendly when we want to write unit tests for it. We will not be able to specify a customized input to the application, nor will we be able to verify the application's output. Hence, we inject the writer and the reader into the function so that we have control over what the reader, r
, and writer, w
, values refer to.
The function starts by using the Fprintf()
function from the fmt
package to write a prompt to the specified writer, w
. Then, a variable of Scanner
type, as defined in the bufio
package, is created by calling the NewScanner()
function with the reader, r
. This lets you scan the reader for any input data using the Scan()
function. The default behavior of the Scan()
function is to return once it has read the newline character. Subsequently, the Text()
function returns the read data as a string. To ensure that the user didn't enter an empty string as input, the len()
function is used and an error is returned if the user indeed entered an empty string as input.
The getName()
function returns two values: one of type string
and the other of type error
. If the user's input name was read successfully, the name is returned along with a nil
error. However, if there was an error, an empty string and the error is returned.
The next key function is parseArgs()
. It takes as input a slice of strings and returns two values: one of type config
and a second of type error
:
type config struct { numTimes int printUsage bool} func parseArgs(args []string) (config, error) { var numTimes int var err error c := config{} if len(args) != 1 { return c, errors.New("Invalid number of arguments") } if args[0] == "-h" || args[0] == "--help" { c.printUsage = true return c, nil } numTimes, err = strconv.Atoi(args[0]) if err != nil { return c, err } c.numTimes = numTimes return c, nil}
The parseArgs()
function creates an object, c
, of config
type to store this data. The config
structure is used for in-memory representation of data on which the application will rely for the runtime behavior. It has two fields: an integer field, numTimes
, containing the number of the times the greeting is to be printed, and a bool field, printUsage
, indicating whether the user has specified for the help message to be printed instead.
Command-line arguments supplied to a program are available via the Args
slice defined in the os
package. The first element of the slice is the name of the program itself, and the slice os.Args[1:]
contains the arguments that your program may care about. This is the slice of strings with which parseArgs()
is called. The function first checks to see if the number of command-line arguments is not equal to 1, and if so, it returns an empty config object and an error using the following snippet:
if len(args) != 1 { return c, errors.New("Invalid number of arguments")}
If only one argument is specified, and it is -h
or -help
, the printUsage
field is specified to true
and the object, c
, and a nil
error are returned using the following snippet:
if args[0] == "-h" || args[0] == "-help" { c.printUsage = true return c, nil}
Finally, the argument specified is assumed to be the number of times to print the greeting, and the Atoi()
function from the strconv
package is used to convert the argument—a string—to its integer equivalent:
numTimes, err = strconv.Atoi(args[0])if err != nil { return c, err}
If the Atoi()
function returns a non-nil error value, it is returned; else numTimes
is set to the converted integer:
c.numTimes = numTimes
So far, we have seen how you can read the input from the user and read command-line arguments. The next step is to ensure that the input is logically valid; in other words, whether or not it makes sense for the application. For example, if the user specified 0
for the number of times to print the greeting, it is a logically incorrect value. The validateArgs()
function performs this validation:
func validateArgs(c config) error { if !(c.numTimes> 0) { return errors.New("Must specify a number greater than 0") } return nil}
If the value of the numTimes
field is not greater than 0
, an error is returned by the validateArgs()
function.
After processing and validating the command- line arguments, the application invokes the runCmd()
function to perform the relevant action based on the value in the config
object, c
:
func runCmd(r io.Reader, w io.Writer, c config) error { if c.printUsage { printUsage(w) return nil } name, err := getName(r, w) if err != nil { return err } greetUser(c, name, w) return nil}
If the field printUsage
is set to true
(-help
or -h
specified by the user), the printUsage()
function is called and a nil
error is returned. Otherwise, the getName()
function is called to ask the user to input their name.
If getName()
returned a non-nil error, it is returned. Else, the greetUser()
function is called. The greetUser()
function displays a greeting to the user based on the configuration supplied:
func greetUser(c config, name string, w io.Writer { msg := fmt.Sprintf("Nice to meet you %s\n", name) for i := 0; i < c.numTimes; i++ { fmt.Fprintf(w, msg) } }
The complete greeter application is shown in Listing 1.1.
Listing 1.1: A greeter application
// chap1/manual-parse/main.gopackage main import ( "bufio" "errors" "fmt" "io" "os" "strconv") type config struct { numTimes int printUsage bool} var usageString = fmt.Sprintf(`Usage: %s <integer> [-h|--help] A greeter application which prints the name you entered <integer> number of times.`, os.Args[0]) func printUsage(w io.Writer) { fmt.Fprintf(w, usageString)} func validateArgs(c config) error { if !(c.numTimes> 0) { return errors.New("Must specify a number greater than 0") } return nil} // TODO – Insert definition of parseArgs() as earlier// TODO – Insert definition of getName() as earlier// TODO – Insert definition of greetUser() as earlier// TODO – Insert definition of runCmd() as earlier func main() { c, err := parseArgs(os.Args[1:]) if err != nil { fmt.Fprintln(os.Stdout, err) printUsage(os.Stdout) os.Exit(1) } err = validateArgs(c) if err != nil { fmt.Fprintln(os.Stdout, err) printUsage(os.Stdout) os.Exit(1) } err = runCmd(os.Stdin, os.Stdout, c) if err != nil { fmt.Fprintln(os.Stdout, err) os.Exit(1) }}
The main()
function first calls the parseArgs()
function with the slice of the command-line arguments, starting from the second argument. We get back two values from the function: c
, a config object, and err
, an error value. If a non- nil
error is returned, the following steps are performed:
- Print the error.
- Print a usage message by calling the
printUsage()
function, passing inos.Stdout
as the writer. - Terminate the program execution with exit code
1
by calling theExit()
function from theos
package.
If the arguments have been parsed correctly, the validateArgs()
function is called with the config object, c
, that is returned by parseArgs()
.
Finally, if the validateArgs()
function returned a nil
error value, the runCmd()
function is called, passing it a reader, os.Stdin
; a writer, os.Stdout
; and the config object, c
.
Create a new directory, chap1/manual-parse/
, and initialize a module inside it:
$ mkdir -p chap1/manual-parse$ cd chap1/manual-parse$ go mod init github.com/username/manual-parse
Next, save Listing 1.1 to a file called main.go
, and build it:
$ go build -o application
Run the command without specifying any arguments. You will see an error and the following usage message:
$ ./applicationInvalid number of argumentsUsage: ./application <integer> [-h|--help] A greeter application which prints the name you entered <integer> number of times.
In addition, you will also see that the exit code of the program is 1
.
$ echo $?1
If you are using PowerShell on Windows, you can use echo $LastExitCode
to see the exit code.
This is another notable behavior of command-line applications that you should look to preserve. Any non-successful execution should result in a non-zero exit code upon termination using the Exit()
function defined in the os
package.
Specifying -h
or -help
will print a usage message:
$ ./application -helpUsage: ./application <integer> [-h|-help] A greeter application which prints the name you entered <integer> number of times.
Finally, let's see what a successful execution of the program looks like:
$ ./application 5Your name please? Press the Enter key when done.Joe CoolNice to meet you Joe CoolNice to meet you Joe CoolNice to meet you Joe CoolNice to meet you Joe CoolNice to meet you Joe Cool
You have manually tested that your application behaves as expected under three different input scenarios:
- No command-line argument specified.
-h
or-help
is specified as a command-line argument.- A greeting is displayed to the user a specified number of times.
Manual testing is error prone and cumbersome, however. Next, you will learn to write automated tests for your application.
Writing Unit Tests
The standard library's testing
package contains everything you need to write tests to verify the behavior of your application.
Let's consider the parseArgs()
function first. It is defined as follows:
func parseArgs(args []string) (config, error) {}
It has one input: a slice of strings representing the command-line arguments specified to the program during invocation. The return values are a value of type config
and a value of type error
.
The testConfig
structure will be used to encapsulate a specific test case: a slice of strings representing the input command-line arguments in the args
field, expected error value returned in the err
field, and the expected config
value returned in the embedded config
struct field:
type testConfig struct { args []string err error config}
An example test case is
{ args: []string{"-h"}, err: nil, config: config{printUsage: true, numTimes: 0},},
This test case verifies the behavior when -h
is specified as the command-line argument when executing the application.
We add a few more test cases and initialize a slice of test cases as follows:
tests := []testConfig{ { args: []string{"-h"}, err: nil, config: config{printUsage: true, numTimes: 0}, }, { args: []string{"10"}, err: nil, config: config{printUsage: false, numTimes: 10}, }, { args: []string{"abc"}, err: errors.New("strconv.Atoi: parsing \"abc\": invalid syntax"), config: config{printUsage: false, numTimes: 0}, }, { args: []string{"1", "foo"}, err: errors.New("Invalid number of arguments"), config: config{printUsage: false, numTimes: 0}, },}
Once we have defined the slice of test configurations above, we will iterate over them, invoke the parseArgs()
function with the value in args
, and check whether the returned values, c
and err
, match the expected values of type config
and error
, respectively. The complete test will appear as shown in Listing 1.2.
Listing 1.2: Test for the parseArgs()
function
// chap1/manual-parse/parse_args_test.gopackage main import ( "errors" "testing") func TestParseArgs(t *testing.T) { // TODO Insert definition tests[] array as earlier for _, tc := range tests { c, err := parseArgs(tc.args) if tc.result.err != nil && err.Error() != tc.result.err.Error() { t.Fatalf("Expected error to be: %v, got: %v\n", tc.result.err, err) } if tc.result.err == nil && err != nil { t.Errorf("Expected nil error, got: %v\n", err) } if c.printUsage != tc.result.printUsage { t.Errorf("Expected printUsage to be: %v, got: %v\n", tc.result.printUsage, c.printUsage) } if c.numTimes != tc.result.numTimes { t.Errorf("Expected numTimes to be: %v, got: %v\n", tc.result.numTimes, c.numTimes) } }}
In the same directory as you saved Listing 1.1, save Listing 1.2 into a file called parse_flags_test.go
. Now run the test using the go test
command:
$ go test -v=== RUN TestParseArgs--- PASS: TestParseArgs (0.00s)PASSok github.com/practicalgo/code/chap1/manual-parse 0.093
Passing in the -v
flag when running go test
also displays the test functions that are being run and the result.
Next, consider the validateArgs()
function defined as func validateArgs(c config) error
. Based on the function specification, we will once again define a slice of test cases. However, instead of defining a named struct
type, we will use an anonymousstruct
type instead as follows:
tests := []struct { c config err error }{ { c: config{}, err: errors.New("Must specify a number greater than 0"), }, { c: config{numTimes: -1}, err: errors.New("Must specify a number greater than 0"), }, { c: config{numTimes: 10}, err: nil, }, }
Each test case consists of two fields: an input object, c
, of type config
, and the expected error
value, err
. The test function is shown in Listing 1.3.
Listing 1.3: Test for the validateArgs()
function
// chap1/manual-parse/validate_args_test.gopackage main import ( "errors" "testing") func TestValidateArgs(t *testing.T) { // TODO Insert definition tests[] slice as above for _, tc := range tests { err := validateArgs(tc.c) if tc. err != nil && err.Error() != tc.err.Error() { t.Errorf("Expected error to be: %v, got: %v\n", tc.err, err) } if tc.err == nil && err != nil { t.Errorf("Expected nil error, got: %v\n", err) } }}
In the same subdirectory as Listing 1.2, save Listing 1.3 to a file called validate_args_test.go
. Now run the tests using the go test
command. It will now run both the TestParseFlags
and TestValidateArgs
tests.
Finally, you will write a unit test for the runCmd()
function. This function has the signature runCmd(r io.Reader, w io.Writer, c config)
. We will define a set of test cases as follows:
tests := []struct { c config input string output string err error }{ { c: config{printUsage: true}, output: usageString, }, { c: config{numTimes: 5}, input: "", output: strings.Repeat("Your name please? Press the Enter key when done.\n", 1), err: errors.New("You didn't enter your name"), }, { c: config{numTimes: 5}, input: "Bill Bryson", output: "Your name please? Press the Enter key when done.\n" + strings.Repeat("Nice to meet you Bill Bryson\n", 5), }, }
The field c
is a config
object representing the incoming configuration, input
is the test input received by the program from the user interactively, output
is the expected output, and err
represents any error that is expected based on the test input and configuration.
When you write a test for a program where you have to mimic an input from the user, this is how you can create a io.Reader
from a string:
r := strings.NewReader(tc.input)
Thus, when the getName()
function is called with io.Reader r
as created above, calling scanner.Text()
will return the string in tc.input
.
To mimic the standard output, we create an empty Buffer
object that implements the Writer
interface using new(bytes.Buffer)
. We can then obtain the message that was written to this Buffer
using the byteBuf.String()
method. The complete test is shown in Listing 1.4.
Listing 1.4: Test for the runCmd()
function
// chap1/manual-parse/run_cmd_test.gopackage main import ( "bytes" "errors" "strings" "testing") func TestRunCmd(t *testing.T) { // TODO Insert definition tests[] array as earlier byteBuf := new(bytes.Buffer) for _, tc := range tests { rd := strings.NewReader(tc.input) err := runCmd(rd, byteBuf, tc.c) if err != nil && tc.err == nil { t.Fatalf("Expected nil error, got: %v\n", err) } if tc.err != nil && err.Error() != tc.err.Error() { t.Fatalf("Expected error: %v, Got error: %v\n", tc.err.Error(), err.Error()) } gotMsg := byteBuf.String() if gotMsg != tc.output { t.Errorf("Expected stdout message to be: %v, Got: %v\n", tc.output, gotMsg) } byteBuf.Reset() }}
We call the byteBuf.Reset()
method so that the buffer is emptied before executing the next test case. Save Listing 1.4 into the same directory as Listings 1.1, 1.2, and 1.3. Name the file run_cmd_test.go
and run all of the tests:
$ go test -v=== RUN TestParseArgs--- PASS: TestParseArgs (0.00s)=== RUN TestRunCmd--- PASS: TestRunCmd (0.00s)PASSok github.com/practicalgo/code/chap1/manual-parse 0.529s
You may be curious to find out what the test coverage looks like and visually see which parts of your code are not tested. To do so, run the following command first to create a coverage profile:
$ go test -coverprofile cover.outPASScoverage: 71.7% of statementsok github.com/practicalgo/code/chap1/manual-parse 0.084s
The above output tells us that our tests cover 71.7 percent of the code in main.go
. To see which parts of the code are covered, run the following:
$ go tool cover -html=cover.out
This will open your default browser application and show the coverage of your code in an HTML file. Notably, you will see that the main()
function is reported as uncovered since we didn't write a test for it. This leads nicely to Exercise 1.1.
EXERCISE 1.1: TESTING THE MAIN()
FUNCTION In this exercise, you will write a test for themain()
function. However, unlike with other functions, you will need to test the exit status for different input arguments. To do so, your test should do the following:
- Build the application. You will find using the special
TestMain()
function useful here. - Execute the application with different command-line arguments using the
os.Exec()
function. This will allow you to verify both the standard output and the exit code.
Congratulations! You have written your first command-line application. You parsed the os.Args
slice to allow the user to provide input to the application. You learned how to make use of the io
. Reader
and io
. Writer
interfaces to write code that is unit testable.
Next, we will see how the standard library's flag
package automatically takes care of the command-line argument parsing, validation of the type of data, and more.
Using the Flag Package
Before we dive into the flag
package, let's refresh our memory of what a typical command-line application's user interface looks like. Let's consider a command-line application called application
. Typically, it will have an interface similar to the following:
application [-h] [-n <value>] –silent <arg1> <arg2>
The user interface has the following components:
-h
is a Boolean option usually specified to print a help text.-n <value>
expects the user to specify a value for the option,n
. The application's logic determines the expected data type for the value.-silent
is another Boolean option. Specifying it sets the value totrue
.arg1
andarg2
are referred to as positional arguments. A positional argument’s data type and interpretation is completely determined by the application.
The flag
package implements types and methods to write command-line applications with standard behavior as above. When you specify the -h
option while executing the application, all of the other arguments, if specified, will be ignored and a help message will be printed.
An application will have a mix of required and optional options.
It is also worth noting here that any positional argument must be specified after you have specified all of the required options. The flag
package stops parsing the arguments once it encounters a positional argument, -
or --
.
Table 1.1 summarizes the package's parsing behavior for a sample of command-line arguments.
Table 1.1: Parsing of command-line arguments via flag
COMMAND-LINE ARGUMENTS | FLAG PARSING BEHAVIOR |
---|---|
|
|
Let's see an example by rewriting the greeter application so that the number of times the user's name is printed is specified by the option -n
. After the rewrite, the user interface will be as follows:
$ ./application -n 2Your name please? Press the Enter key when done.Joe CoolNice to meet you Joe CoolNice to meet you Joe Cool
Comparing the above to Listing 1.1, the key change is in how the parseArgs()
function is written:
func parseArgs(w io.Writer, args []string) (config, error) { c := config{} fs := flag.NewFlagSet("greeter", flag.ContinueOnError) fs.SetOutput(w) fs.IntVar(&c.numTimes, "n", 0, "Number of times to greet") err := fs.Parse(args) if err != nil { return c, err } if fs.NArg() != 0 { return c, errors.New("Positional arguments specified") } return c, nil}
The function takes two parameters: a variable, w
, whose value satisfies the io.Writer
interface, and an array of strings representing the arguments to parse. It returns a config
object and an error
value. To parse the arguments, a new FlagSet
object is created as follows:
fs := flag.NewFlagSet("greeter", flag.ContinueOnError)
The NewFlagSet()
function defined in the flag
package is used to create a FlagSet
object. Think of it as an abstraction used to handle the arguments a command-line application can accept. The first argument to the NewFlagSet()
function is the name of the command that will be shown in help messages. The second argument configures what happens when an error is encountered while parsing the command-line arguments; that is, when the fs.Parse()
function is called. When the ContinueOnError
option is specified, the execution of the program will continue, even if a non- nil
error is returned by the Parse()
function. This is useful when you want to perform your own processing if there is a parsing error. Other possible values are ExitOnError
, which halts the execution of the program, and PanicOnError
, which invokes the panic()
function. The difference between ExitOnError
and PanicOnError
is that you can make use of the recover()
function in the latter case to perform any cleanup actions before the program terminates.
The SetOutput()
method specifies the writer that will be used by the initialized FlagSet
object for writing any diagnostic or output messages. By default, it is set to the standard error, os.Stderr
. Setting it to the specified writer, w
, allows us write unit tests to verify the behavior.
Next, we define the first option:
fs.IntVar(&c.numTimes, "n", 0, "Number of times to greet")
The IntVar()
method is used to create an option whose value is expected to be of type int
. The first parameter of the method is the address of the variable in which the integer specified is stored. The second parameter of the method is the name of the option itself, n
. The third parameter is the default value for the option, and the last parameter is a string that describes the purpose of the parameter to the program's user. It automatically gets displayed in the help text for the program. Similar methods are defined for other data types— float
, string
, and bool
. You can also define a flag option for a custom type.
Next, we call the Parse()
function, passing the args[]
slice:
err := fs.Parse(args)if err != nil { return c, err}
This is the function that reads the elements of the slice and examines them against the flag options defined.
During the examination, it will attempt to fill in the values indicated in the specified variables, and if there is an error, it will either return an error to the calling function or terminate the execution, depending on the second argument specified to NewFlagSet()
function. If a non-nil error is returned, the parseArgs()
function returns the empty config
object and the error value.
If a nil
error is returned, we check to see if there was any positional argument specified, and if so, we return the object, c
, and an error value:
if fs.NArg() != 0 { return c, errors.New("Positional arguments specified")}
Since the greeter program doesn't expect any positional arguments to be specified, it checks for that and displays an error if one or more arguments are specified. The NArg()
method returns the number of positional arguments after the options have been parsed.
The complete program is shown in Listing 1.5.
Listing 1.5: Greeter using flag
// chap1/flag-parse/main.gopackage main import ( "bufio" "errors" "flag" "fmt" "io" "os") type config struct { numTimes int} // TODO Insert definition of getName() as Listing 1.1// TODO Insert definition of greetUser() as Listing 1.1// TODO Insert definition of runCmd() as Listing 1.1// TODO Insert definition of validateArgs as Listing 1.1func parseArgs(w io.Writer, args []string) (config, error) { c := config{} fs := flag.NewFlagSet("greeter", flag.ContinueOnError) fs.SetOutput(w) fs.IntVar(&c.numTimes, "n", 0, "Number of times to greet") err := fs.Parse(args) if err != nil { return c, err } if fs.NArg() != 0 { return c, errors.New("Positional arguments specified") } return c, nil}func main() { c, err := parseArgs(os.Stderr, os.Args[1:]) if err != nil { fmt.Fprintln(os.Stdout, err) os.Exit(1) } err = validateArgs(c) if err != nil { fmt.Fprintln(os.Stdout, err) os.Exit(1) } err = runCmd(os.Stdin, os.Stdout, c) if err != nil { fmt.Fprintln(os.Stdout, err) os.Exit(1) }}
The config
struct type is modified so that it doesn't have the printUsage
field since the parseArgs()
function now automatically handles the -h
or -help
argument. Create a new directory, chap1/flag-parse/
, and initialize a module inside it:
$ mkdir -p chap1/flag-parse$ cd chap1/flag-parse$ go mod init github.com/username/flag-parse
Next, save Listing 1.5 to a file called main.go
and build it:
$ go build -o application
Run the command without specifying any arguments. You will see the following error message:
$ ./applicationMust specify a number greater than 0
Now run the command specifying the -h
option:
$ ./application -hUsage of greeter: -n int Number of times to greetflag: help requested
The flag parsing logic recognized the -h
option and displayed a default usage message consisting of the name that was specified when calling the NewFlagSet()
function and the options along with their name, type, and description. The last line of the above output is seen here because when we haven't explicitly defined an -h
option, the Parse()
function returns an error, which is displayed as part of the error handling logic in main()
. In the next section, you will see how we can improve this behavior.
Next, let's invoke the program specifying a non-integral value for the -n
option:
$ ./application -n abcinvalid value "abc" for flag -n: parse errorUsage of greeter: -n int Number of times to greetinvalid value "abc" for flag -n: parse error
Note how we automatically get the type validation error since we tried specifying a non-integral value. In addition, note here again that we get the error twice. We will fix this later in the chapter.
Finally, let's run the program with a valid value for the -n
option:
$ ./application -n 4Your name please? Press the Enter key when done.John DoeNice to meet you John DoeNice to meet you John DoeNice to meet you John DoeNice to meet you John Doe
Testing the Parsing Logic
The primary change in our greeter program, as compared to the first version, is in how we are parsing the command-line arguments using the flag
package. You will notice that you have already written the greeter program, specifically the parseArgs()
function, in a unit testing friendly fashion:
- A new
FlagSet
object is created in the function. - Using the
Output()
method of theFlagSet
object,
you made sure that any messages from theFlagSet
methods were written to the specifiedio.Writer
object,w.
- The arguments to parse were being passed as a parameter,
args
.
The function is well encapsulated and avoids using any global state. A test for the function is shown in Listing 1.6.
Listing 1.6: Test for the parseArgs()
function
//chap1/flag-parse/parse_args_test.gopackage mainimport ( "bytes" "errors" "testing") func TestParseArgs(t *testing.T) { tests := []struct { args []string err error numTimes int }{ { args: []string{"-h"}, err: errors.New("flag: help requested"), numTimes: 0, }, { args: []string{"-n", "10"}, err: nil, numTimes: 10, }, { args: []string{"-n", "abc"}, err: errors.New("invalid value \"abc\" for flag -n: parse error"), numTimes: 0, }, { args: []string{"-n", "1", "foo"}, err: errors.New("Positional arguments specified"), numTimes: 1, }, } byteBuf := new(bytes.Buffer) for _, tc := range tests { c, err := parseArgs(byteBuf, tc.args) if tc.result.err == nil && err != nil { t.Errorf("Expected nil error, got: %v\n", err) } if tc.result.err != nil && err.Error() != tc.result.err.Error() { t.Errorf("Expected error to be: %v, got: %v\n", tc.result.err, err) } if c.numTimes != tc.result.numTimes { t.Errorf("Expected numTimes to be: %v, got: %v\n", tc.result.numTimes, c.numTimes) } byteBuf.Reset() }}
Save Listing 1.6 into the directory in which you saved Listing 1.5. Name the file parse_args_test.go
.
The unit test for the runCmd()
function remains the same as that seen in Listing 1.4, except for the absence of the first test, which was used to test the behavior of runCmd()
when printUsage
was set to true. The test cases we want to test are as follows:
tests := []struct { c config input string output string err error }{ { c: config{numTimes: 5}, input: "", output: strings.Repeat("Your name please? Press the Enter key when done.\n", 1), err: errors.New("You didn't enter your name"), }, { c: config{numTimes: 5}, input: "Bill Bryson", output: "Your name please? Press the Enter key when done.\n" + strings.Repeat("Nice to meet you Bill Bryson\n", 5), }, }
You can find the complete test in the run_cmd_test.go
file in the flag-parse
subdirectory of the book's code.
The test for the validateArgs()
function is the same as the one used in Listing 1.3. You can find it in the validate_args_test.go
file in the flag-parse
subdirectory of the book's code. Now, run all of the tests:
$ go test -v=== RUN TestSetupFlagSet--- PASS: TestSetupFlagSet (0.00s)=== RUN TestRunCmd--- PASS: TestRunCmd (0.00s)=== RUN TestValidateArgs--- PASS: TestValidateArgs (0.00s)PASSok github.com/practicalgo/code/chap1/flag-parse 0.610s
Great. Now you have rewritten the parsing logic of the greeter application to use the flag
package and then updated the unit tests so that they test the new behavior. Next, you are going to work on improving the user interface of the application in a few ways. Before doing that, however, let's complete Exercise 1.2.
EXERCISE 1.2: HTML GREETER PAGE CREATOR In this exercise, you will update the greeter program to create an HTML page, which will serve as the home page for the user. Add a new option,-o
, to the application, which will accept the filesystem path as a value. If the-o
is specified, the greeter program will create an HTML page at the path specified with the following contents:<h1>Hello Jane Clancy</h1>
, where Jane Clancy is the name entered. You may choose to use thehtml/template
package for this exercise.
Improving the User Interface
In the following sections, you are going to improve the user interface of the greeter application in three ways:
- Remove the duplicate error messages
- Customize the help usage message
- Allow the user to enter their name via a positional argument
While implementing these improvements, you will learn how to create custom error values, customize a FlagSet
object to print a customized usage message, and access positional arguments from your application.
Removing Duplicate Error Messages
You may have noticed that errors were being displayed twice. This is caused by the following code snippet in the main()
function:
c, err := parseArgs(os.Stderr, os.Args[1:])if err != nil {. fmt.Println(err) os.Exit(1)}
When the Parse()
function call encountered an error, it was displaying that error to the output writer instance set in the fs.SetOutput()
call. Subsequently, the returned error was also being printed in the main()
function via the snippet above. It may seem like an easy fix not to print the error in the main()
function. However, that will mean that any custom errors returned, such as when positional arguments are specified, will also not be shown. Hence, what we will do is create a custom error value and return that instead. We will only print the error if it matches that custom error, else we will skip printing it.
A custom error value can be created as follows:
var errPosArgSpecified = errors.New("Positional arguments specified")
Then, in the parseArgs()
function, we return the following error:
if fs.NArg() != 0 {. return c, errPosArgSpecified}
Then in main()
, we update the code as follows:
c, err := parseArgs(os.Stderr, os.Args[1:]) if err != nil { if errors.Is(err, errPosArgSpecified) { fmt.Fprintln(os.Stdout, err) } os.Exit(1) }
The errors.Is()
function is used to check whether the error value err
matches the error value errPosArgSpecified
. The error is displayed only if a match is found.
Customizing Usage Message
If you compare Listing 1.5 to Listing 1.1, you will notice that there is no custom usageString
specified. This is because the flag
package automatically constructs one based on the FlagSet
name and the options defined. However, what if you wanted to customize it? You can do so by setting the Usage
attribute of the FlagSet
object to a function as follows:
fs.Usage = func() { var usageString = `A greeter application which prints the name you entered a specified number of times. Usage of %s: ` fmt.Fprintf(w, usageString, fs.Name()) fmt.Fprintln(w) fs.PrintDefaults()}
Once we set the Usage
attribute of the FlagSet
object to a custom function, it is called whenever there is an error parsing the specified options. Note that the preceding function is defined as an anonymous function so that it can access the specified writer object, w
, to display the custom usage message. Inside the function, we access the name of the FlagSet
using the Name()
method. Then we print a new line and call the PrintDefaults()
method, which prints the various options that have been defined along with their type and default values. The updated parseArgs()
function is as follows:
func parseArgs(w io.Writer, args []string) (config, error) { c := config{} fs := flag.NewFlagSet("greeter", flag.ContinueOnError) fs.SetOutput(w) fs.Usage = func() { var usageString = `A greeter application which prints the name you entered a specified number of times. Usage of %s: <options> [name]` fmt.Fprintf(w, usageString, fs.Name()) fmt.Fprintln(w) fmt.Fprintln(w, "Options: ") fs.PrintDefaults() } fs.IntVar(&c.numTimes, "n", 0, "Number of times to greet") err := fs.Parse(args) if err != nil { return c, err } if fs.NArg()> 1 { return c, errInvalidPosArgSpecified } if fs.NArg() == 1 { c.name = fs.Arg(0) } return c, nil}
Next, you will implement the final improvement. The greeter program will now allow specifying the name via a positional argument as well. If one is not specified, you will ask for the name interactively.
Accept Name via a Positional Argument
First, update the config
struct to have a name
field of type string
as follows:
type config struct { numTimes int name string}
Then the greetUser()
function will be updated to the following:
func greetUser(c config, w io.Writer) { msg := fmt.Sprintf("Nice to meet you %s\n", c.name) for i := 0; i < c.numTimes; i++ { fmt.Fprintf(w, msg) }}
Next, we update the custom error value as follows:
var errInvalidPosArgSpecified = errors.New("More than one positional argument specified")
We update the parseArgs()
function now to look for a positional argument and, if one is found, set the name
attribute of the config
object appropriately:
if fs.NArg()> 1 { return c, errInvalidPosArgSpecified } if fs.NArg() == 1 { c.name = fs.Arg(0) }
The runCmd()
function is updated so that it only asks the user to input the name interactively if not specified, or if an empty string was specified:
func runCmd(rd io.Reader, w io.Writer, c config) error { var err error if len(c.name) == 0 { c.name, err = getName(rd, w) if err != nil { return err } } greetUser(c, w) return nil}
The complete program with all of the preceding changes is shown in Listing 1.7.
Listing 1.7: Greeter program with user interface updates
// chap1/flag-improvements/main.gopackage mainimport ( "bufio" "errors" "flag" "fmt" "io" "os") type config struct { numTimes int name string} var errInvalidPosArgSpecified = errors.New("More than one positional argument specified") // TODO Insert definition of getName() as Listing 1.5// TODO Insert definition of greetUser() as above// TODO Insert updated definition of runCmd() as above// TODO Insert definition of validateArgs as Listing 1.5// TODO Insert definition of parseArgs() as above func main() { c, err := parseArgs(os.Stderr, os.Args[1:]) if err != nil { if errors.Is(err, errInvalidPosArgSpecified) { fmt.Fprintln(os.Stdout, err) } os.Exit(1) } err = validateArgs(c) if err != nil { fmt.Fprintln(os.Stdout, err) os.Exit(1) } err = runCmd(os.Stdin, os.Stdout, c) if err != nil { fmt.Fprintln(os.Stdout, err) os.Exit(1) }}
Create a new directory, chap1/flag-improvements/
, and initialize a module inside it:
$ mkdir -p chap1/flag-improvements$ cd chap1/flag-improvements$ go mod init github.com/username/flag-improvements
Next, save Listing 1.7 as main.go
. Build it as follows:
$ go build -o application
Run the built application code with -help
, and you will see the custom usage message:
$ ./application -help A greeter application which prints the name you entered a specified number of times. Usage of greeter: <options> [name] Options: -n int Number of times to greet
Now let's specify a name as a positional argument:
$ ./application -n 1 "Jane Doe"Nice to meet you Jane Doe
Next let's specify a bad input—a string as value to the -n
option:
$ ./flag-improvements -n a "Jane Doe"invalid value "a" for flag -n: parse error A greeter application which prints the name you entered a specified number of times. Usage of greeter: <options> [name] Options: -n int Number of times to greet
Two points are worth noting here:
- The error is displayed only once now instead of being displayed twice.
- Our custom usage is displayed instead of the default.
Try a few input combinations before moving on to updating the unit tests.
Updating the Unit Tests
We are going to finish off the chapter by updating the unit tests for the functions that we modified. Consider the parseArgs()
function first. We will define a new anonymous struct
for the test cases:
tests := []struct { args []string config output string err error}{..} The fields are as follows:
args
: A slice of strings that contains the command-line arguments to parse.config
: An embedded field representing the expectedconfig
object value.output
: A string that will store the expected standard output.err
: An error value that will store the expected error.
Next, we define a slice of test cases representing the various test cases. The first one is as follows:
{ args: []string{"-h"}, output: `A greeter application which prints the name you entered a specified number of times. Usage of greeter: <options> [name] Options: -n int Number of times to greet`, err: errors.New("flag: help requested"), config: config{numTimes: 0}, },
The preceding test cases test the behavior when the program is run with the -h
argument. In other words, it prints the usage message. Then we have two test configs testing the behavior of the parseArgs()
function for different values specified in the -n
option:
{ args: []string{"-n", "10"}, err: nil, config: config{numTimes: 10}, }, { args: []string{"-n", "abc"}, err: errors.New("invalid value \"abc\" for flag -n: parse error"), config: config{numTimes: 0}, },
The final two test configs test the name specified as a positional argument:
{ args: []string{"-n", "1", "John Doe"}, err: nil, config: config{numTimes: 1, name: "John Doe"}, }, { args: []string{"-n", "1", "John", "Doe"}, err: errors.New("More than one positional argument specified"), config: config{numTimes: 1}, },
When “John Doe” is specified in quotes, it is considered valid. However, when John Doe is specified without quotes, they are interpreted as two positional arguments and hence the function returns an error. The complete test is provided in Listing 1.8.
Listing 1.8: Test for parseArgs()
function
// chap1/flag-improvements/parse_args_test.gopackage main import ( "bufio" "bytes" "errors" "testing")func TestParseArgs(t *testing.T) { // TODO insert the test configs as per above tests := []struct { args []string config output string err error }{..} byteBuf := new(bytes.Buffer) for _, tc := range tests { c, err := parseArgs(byteBuf, tc.args) if tc.err == nil && err != nil { t.Fatalf("Expected nil error, got: %v\n", err) } if tc.err != nil && err.Error() != tc.err.Error() { t.Fatalf("Expected error to be: %v, got: %v\n", tc.err, err) } if c.numTimes != tc.numTimes { t.Errorf("Expected numTimes to be: %v, got: %v\n", tc.numTimes, c.numTimes) } gotMsg := byteBuf.String() if len(tc.output) != 0 && gotMsg != tc.output { t.Errorf("Expected stdout message to be: %#v, Got: %#v\n", tc.output, gotMsg) } byteBuf.Reset() }}
Save Listing 1.8 into a new file, parse_args_test.go
, in the same directory that you used for Listing 1.7. The test for the validateArgs()
function is the same as Listing 1.3, and you can find it in the validate_args_test.go
file in the flag-improvements
subdirectory of the book's code.
The unit test for the runCmd()
function remains the same as that of Listing 1.4, except for a new test configuration where the name is specified by the user via a positional argument. The tests slice is defined as follows:
tests := []struct { c config input string output string err error }{ // Tests the behavior when an empty string is // entered interactively as input. { c: config{numTimes: 5}, input: "", output: strings.Repeat("Your name please? Press the Enter key when done.\n", 1), err: errors.New("You didn't enter your name"), }, // Tests the behavior when a positional argument // is not specified and the input is asked from the user { c: config{numTimes: 5}, input: "Bill Bryson", output: "Your name please? Press the Enter key when done.\n" + strings.Repeat("Nice to meet you Bill Bryson\n", 5), }, // Tests the new behavior where the user has entered their name // as a positional argument { c: config{numTimes: 5, name: "Bill Bryson"}, input: "", output: strings.Repeat("Nice to meet you Bill Bryson\n", 5), },}
The complete test is shown in Listing 1.9.
Listing 1.9: Test for runCmd()
function
// chap1/flag-improvements/run_cmd_test.gopackage main import ( "bytes" "errors" "strings" "testing") func TestRunCmd(t *testing.T) { // TODO Insert test cases from above tests := []struct{..} byteBuf := new(bytes.Buffer) for _, tc := range tests { r := strings.NewReader(tc.input) err := runCmd(r, byteBuf, tc.c) if err != nil && tc.err == nil { t.Fatalf("Expected nil error, got: %v\n", err) } if tc.err != nil && err.Error() != tc.err.Error() { t.Fatalf("Expected error: %v, Got error: %v\n", tc.err.Error(), err.Error()) } gotMsg := byteBuf.String() if gotMsg != tc.output { t.Errorf("Expected stdout message to be: %v, Got: %v\n", tc.output, gotMsg) } byteBuf.Reset() }}
Save the Listing 1.9 code to a new file, run_cmd_test.go
, in the same directory as Listing 1.8.
Now, run all of the tests:
$ go test -v=== RUN TestParseArgs--- PASS: TestParseArgs (0.00s)=== RUN TestRunCmd--- PASS: TestRunCmd (0.00s)=== RUN TestValidateArgs--- PASS: TestValidateArgs (0.00s)PASSok github.com/practicalgo/code/chap1/flag-improvements 0.376s
Summary
We started off the chapter implementing a basic command-line interface by directly parsing the command-line arguments. You then saw how you can make use of the flag
package to define a standard command-line interface. Instead of implementing the parsing and validating the arguments ourselves, you learned to use the package's built-in support for user-specified arguments and data type validation. All throughout the chapter, you wrote well-encapsulated functions to make unit testing straightforward.
In the next chapter, you will continue your journey into the flag
package by learning to implement command-line applications with sub-commands, introducing robustness into your applications and more.