test: add cmdtest package (#15251)

## Description

This PR introduces the `cmdtest` package, offering a lightweight wrapper around cobra commands to simplify testing CLI utilities.

I backfilled tests for the `version` command, which was an example of a very simple test setup; and for the `export` command, which was more involved due to the server and client context requirements.

I did notice that there are some existing tests for some utilities, but the `cmdtest` package follows a simple pattern that has been easy to use successfully in [the relayer](https://github.com/cosmos/relayer/blob/main/internal/relayertest/system.go) and in other projects outside the Cosmos ecosystem.

While filling in these tests, I started removing uses of `cmd.Print`, as that is the root cause of issues like #8498, #7964, #15167, and possibly others. Internal to cobra, the print family of methods write to `cmd.OutOrStderr()` -- meaning that if the authors call `cmd.SetOutput()` before executing the command, the output will be written to stdout as expected; otherwise it will go to stderr. I don't understand why that would be the default behavior, but it is probably too late to change from cobra's side.

Instead of `cmd.Print`, we prefer to `fmt.Fprint(cmd.OutOrStdout())` or `fmt.Fprint(cmd.ErrOrStderr())` as appropriate, giving an unambiguous destination for output. And the new tests collect those outputs in plain `bytes.Buffer` values so that we can assert their content appropriately.

In the longer term, I would like to deprecate and eventually remove the `testutil` package's `ApplyMockIO` method and its `BufferWriter` and `BufferReader` types, as they are unnecessary indirection when a simpler solution exists. But that can wait until `cmdtest` has propagated through the codebase more.

---

### Author Checklist

*All items are required. Please add a note to the item if the item is not applicable and
please add links to any relevant follow up issues.*

I have...

- [x] included the correct [type prefix](https://github.com/commitizen/conventional-commit-types/blob/v3.0.0/index.json) in the PR title
- [x] targeted the correct branch (see [PR Targeting](https://github.com/cosmos/cosmos-sdk/blob/main/CONTRIBUTING.md#pr-targeting))
- [ ] ~~provided a link to the relevant issue or specification~~
- [x] reviewed "Files changed" and left comments if necessary
- [ ] confirmed all CI checks have passed

### Reviewers Checklist

*All items are required. Please add a note if the item is not applicable and please add
your handle next to the items reviewed if you only reviewed selected items.*

I have...

- [ ] confirmed the correct [type prefix](https://github.com/commitizen/conventional-commit-types/blob/v3.0.0/index.json) in the PR title
- [ ] confirmed all author checklist items have been addressed
- [ ] confirmed that this PR does not change production code
This commit is contained in:
Mark Rushakoff
2023-03-06 19:40:24 +00:00
committed by GitHub
parent f151bf627a
commit e7097b1468
6 changed files with 602 additions and 13 deletions
+4 -2
View File
@@ -2,6 +2,7 @@ package version
import (
"encoding/json"
"fmt"
"strings"
"github.com/cometbft/cometbft/libs/cli"
@@ -16,11 +17,12 @@ func NewVersionCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "version",
Short: "Print the application binary version information",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
verInfo := NewInfo()
if long, _ := cmd.Flags().GetBool(flagLong); !long {
cmd.Println(verInfo.Version)
fmt.Fprintln(cmd.OutOrStdout(), verInfo.Version)
return nil
}
@@ -42,7 +44,7 @@ func NewVersionCommand() *cobra.Command {
return err
}
cmd.Println(string(bz))
fmt.Fprintln(cmd.OutOrStdout(), string(bz))
return nil
},
}
+99
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"runtime"
"strings"
"testing"
"github.com/cometbft/cometbft/libs/cli"
@@ -11,6 +12,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/cosmos/cosmos-sdk/testutil"
"github.com/cosmos/cosmos-sdk/testutil/cmdtest"
"github.com/cosmos/cosmos-sdk/version"
)
@@ -40,6 +42,103 @@ go version go1.14 linux/amd64`
require.Equal(t, want, info.String())
}
func TestCLI(t *testing.T) {
setVersionPackageVars(t)
sys := cmdtest.NewSystem()
sys.AddCommands(version.NewVersionCommand())
t.Run("no flags", func(t *testing.T) {
res := sys.MustRun(t, "version")
// Only prints the version, with a newline, to stdout.
require.Equal(t, testVersion+"\n", res.Stdout.String())
require.Empty(t, res.Stderr.String())
})
t.Run("--long flag", func(t *testing.T) {
res := sys.MustRun(t, "version", "--long")
out := res.Stdout.String()
lines := strings.Split(out, "\n")
require.Contains(t, lines, "name: testchain-app")
require.Contains(t, lines, "server_name: testchaind")
require.Contains(t, lines, `version: "3.14"`)
require.Contains(t, lines, "commit: abc123")
require.Contains(t, lines, "build_tags: mybuildtag")
require.Empty(t, res.Stderr.String())
})
t.Run("--output=json flag", func(t *testing.T) {
res := sys.MustRun(t, "version", "--output=json")
var info version.Info
require.NoError(t, json.Unmarshal(res.Stdout.Bytes(), &info))
// Assert against a couple fields that are difficult to predict in test
// without copying and pasting code.
require.NotEmpty(t, info.GoVersion)
// The SDK version appears to not be set during this test, so we'll ignore it here.
// Now clear out the non-empty fields, so we can compare against a fixed value.
info.GoVersion = ""
want := version.Info{
Name: testName,
AppName: testAppName,
Version: testVersion,
GitCommit: testCommit,
BuildTags: testBuildTags,
}
require.Equal(t, want, info)
require.Empty(t, res.Stderr.String())
})
t.Run("positional args rejected", func(t *testing.T) {
res := sys.Run("version", "foo")
require.Error(t, res.Err)
})
}
const (
testName = "testchain-app"
testAppName = "testchaind"
testVersion = "3.14"
testCommit = "abc123"
testBuildTags = "mybuildtag"
)
// setVersionPackageVars temporarily overrides the package variables in the version package
// so that we can assert meaningful output.
func setVersionPackageVars(t *testing.T) {
t.Helper()
var (
origName = version.Name
origAppName = version.AppName
origVersion = version.Version
origCommit = version.Commit
origBuildTags = version.BuildTags
)
t.Cleanup(func() {
version.Name = origName
version.AppName = origAppName
version.Version = origVersion
version.Commit = origCommit
version.BuildTags = origBuildTags
})
version.Name = testName
version.AppName = testAppName
version.Version = testVersion
version.Commit = testCommit
version.BuildTags = testBuildTags
}
func Test_runVersionCmd(t *testing.T) {
cmd := version.NewVersionCommand()
_, mockOut := testutil.ApplyMockIO(cmd)