learning-go

Learn Go by building a REST API and a Command Line Interface (CLI)

CLI building blocks - IO streams & testing helpers

The commands to implement will in most of the cases write output to the console. Therefore, to be able to appropriately unit test the commands, we need to remove the dependency from os.Stdout so we can write the output to a buffer and inspect that buffer to check for the appropriate behavior.

Other well known CLIs (like the GitHub CLI) use this approach.

Basically consists in having a struct with the input, output and error readers/writers interfaces.

So, to do that, let's create a package called iostreams. As this is an internal logic we will put it in the internal folders.

mkdir -p internal/iostreams
touch internal/iostreams/iostreams.go
touch internal/iostreams/iostreams_test.go

The contents of the iostreams.go is:

package iostreams

import (
	"fmt"
	"io"
)

// IOStreams represents the structures needed for input/output in commands
// Currently only supports output.
type IOStreams struct {
	Out io.Writer
}

// PrintOutput knows how to print using an IOStreams struct
func (iostreams *IOStreams) Fprint(v interface{}) (n int, err error) {
	return fmt.Fprint(iostreams.Out, v)
}

Basically we have a structure with an io.Writer interface and a method to print a value to the output.

Let's unit test this. The contents of the iostreams_test.go are:

package iostreams

import (
	"bytes"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestPrintOutput(t *testing.T) {
	// arrange
	buffer := &bytes.Buffer{}
	iostreams := IOStreams{Out: buffer}
	s := "my test string"

	// act
	iostreams.Fprint(s)

	// assert
	assert.Equal(t, s, buffer.String())
}

Additional helpers for testing

All commands will need a fake API and a fake OAuth2 token generation API.

We can create two functions for that.

Let's put them in the internal/testhelpers folder.

mkdir -p internal/testhelpers
touch internal/testhelpers/helpers.go

The helpers are:

package testhelpers

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"

	"github.com/renato0307/learning-go-cli/internal/auth"
)

// NewAuthTestServer create an httptest.Server to test commands requiring
// API authentication
func NewAuthTestServer() *httptest.Server {
	srv := httptest.NewServer(
		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			// to test the code sends an authorization token
			if r.Header.Get("Authorization") == "" {
				w.WriteHeader(http.StatusBadRequest)
				w.Write([]byte("Unauthorized"))
				return
			}
			w.WriteHeader(http.StatusOK)
			body, _ := json.Marshal(auth.AccessToken{})
			w.Write(body)
		}))

	return srv
}

// NewAPITestServer create an httptest.Server to test commands requiring
// to call the API
func NewAPITestServer(body string, expectedQueryParams []string, httpStatus int) *httptest.Server {
	srv := httptest.NewServer(
		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			for _, qp := range expectedQueryParams {
				if r.URL.Query().Get(qp) == "" {
					w.WriteHeader(http.StatusBadRequest)
					w.Write([]byte(fmt.Sprintf("parameter %s expected", qp)))
					return
				}
			}
			w.WriteHeader(httpStatus)
			w.Write([]byte(body))
		}))

	return srv
}

Next

The next section is Add the configure command to the CLI.