diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 45ac921d..9a3261ea 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -40,6 +40,9 @@ jobs:
         uses: ./ci/image
         env:
           COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          CODER_URL: ${{ secrets.CODER_URL }}
+          CODER_EMAIL: ${{ secrets.CODER_EMAIL }}
+          CODER_PASSWORD: ${{ secrets.CODER_PASSWORD }}
         with:
           args: make -j test/coverage
   gendocs:
diff --git a/internal/cmd/cli_test.go b/internal/cmd/cli_test.go
new file mode 100644
index 00000000..d1c639dc
--- /dev/null
+++ b/internal/cmd/cli_test.go
@@ -0,0 +1,142 @@
+package cmd
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"net/url"
+	"os"
+	"strings"
+	"testing"
+
+	"cdr.dev/slog"
+	"cdr.dev/slog/sloggers/slogtest"
+	"cdr.dev/slog/sloggers/slogtest/assert"
+	"golang.org/x/xerrors"
+
+	"cdr.dev/coder-cli/coder-sdk"
+	"cdr.dev/coder-cli/internal/config"
+	"cdr.dev/coder-cli/pkg/clog"
+)
+
+func init() {
+	tmpDir, err := ioutil.TempDir("", "coder-cli-config-dir")
+	if err != nil {
+		panic(err)
+	}
+	config.SetRoot(tmpDir)
+
+	// TODO: might need to make this a command scoped option to make assertions against its output
+	clog.SetOutput(ioutil.Discard)
+
+	email := os.Getenv("CODER_EMAIL")
+	password := os.Getenv("CODER_PASSWORD")
+	rawURL := os.Getenv("CODER_URL")
+	if email == "" || password == "" || rawURL == "" {
+		panic("CODER_EMAIL, CODER_PASSWORD, and CODER_URL are required environment variables")
+	}
+	u, err := url.Parse(rawURL)
+	if err != nil {
+		panic("invalid CODER_URL: " + err.Error())
+	}
+	client, err := coder.NewClient(coder.ClientOptions{
+		BaseURL:  u,
+		Email:    email,
+		Password: password,
+	})
+	if err != nil {
+		panic("new client: " + err.Error())
+	}
+	if err := config.URL.Write(rawURL); err != nil {
+		panic("write config url: " + err.Error())
+	}
+	if err := config.Session.Write(client.Token()); err != nil {
+		panic("write config token: " + err.Error())
+	}
+}
+
+type result struct {
+	outBuffer *bytes.Buffer
+	errBuffer *bytes.Buffer
+	exitErr   error
+}
+
+func (r result) success(t *testing.T) {
+	t.Helper()
+	assert.Success(t, "execute command", r.exitErr)
+}
+
+//nolint
+func (r result) stdoutContains(t *testing.T, substring string) {
+	t.Helper()
+	if !strings.Contains(r.outBuffer.String(), substring) {
+		slogtest.Fatal(t, "stdout contains substring", slog.F("substring", substring), slog.F("stdout", r.outBuffer.String()))
+	}
+}
+
+//nolint
+func (r result) stdoutUnmarshals(t *testing.T, target interface{}) {
+	t.Helper()
+	err := json.Unmarshal(r.outBuffer.Bytes(), target)
+	assert.Success(t, "unmarshal json", err)
+}
+
+//nolint
+func (r result) stdoutEmpty(t *testing.T) {
+	t.Helper()
+	assert.Equal(t, "stdout empty", "", r.outBuffer.String())
+}
+
+//nolint
+func (r result) stderrEmpty(t *testing.T) {
+	t.Helper()
+	assert.Equal(t, "stderr empty", "", r.errBuffer.String())
+}
+
+//nolint
+func (r result) stderrContains(t *testing.T, substring string) {
+	t.Helper()
+	if !strings.Contains(r.errBuffer.String(), substring) {
+		slogtest.Fatal(t, "stderr contains substring", slog.F("substring", substring), slog.F("stderr", r.errBuffer.String()))
+	}
+}
+
+//nolint
+func (r result) clogError(t *testing.T) clog.CLIError {
+	t.Helper()
+	var cliErr clog.CLIError
+	if !xerrors.As(r.exitErr, &cliErr) {
+		slogtest.Fatal(t, "expected clog error, none found", slog.Error(r.exitErr), slog.F("type", fmt.Sprintf("%T", r.exitErr)))
+	}
+	slogtest.Debug(t, "clog error", slog.F("message", cliErr.String()))
+	return cliErr
+}
+
+func execute(t *testing.T, in io.Reader, args ...string) result {
+	cmd := Make()
+
+	var outStream bytes.Buffer
+	var errStream bytes.Buffer
+
+	cmd.SetArgs(args)
+
+	cmd.SetIn(in)
+	cmd.SetOut(&outStream)
+	cmd.SetErr(&errStream)
+
+	err := cmd.Execute()
+
+	slogtest.Debug(t, "execute command",
+		slog.F("out_buffer", outStream.String()),
+		slog.F("err_buffer", errStream.String()),
+		slog.F("args", args),
+		slog.F("execute_error", err),
+	)
+	return result{
+		outBuffer: &outStream,
+		errBuffer: &errStream,
+		exitErr:   err,
+	}
+}
diff --git a/internal/cmd/envs_test.go b/internal/cmd/envs_test.go
index 879a20a0..c7cc6451 100644
--- a/internal/cmd/envs_test.go
+++ b/internal/cmd/envs_test.go
@@ -1,76 +1,18 @@
 package cmd
 
 import (
-	"bytes"
-	"fmt"
-	"io"
-	"io/ioutil"
-	"strings"
 	"testing"
 
-	"cdr.dev/slog"
-	"cdr.dev/slog/sloggers/slogtest"
-	"cdr.dev/slog/sloggers/slogtest/assert"
-	"golang.org/x/xerrors"
-
-	"cdr.dev/coder-cli/internal/config"
-	"cdr.dev/coder-cli/pkg/clog"
+	"cdr.dev/coder-cli/coder-sdk"
 )
 
-func init() {
-	tmpDir, err := ioutil.TempDir("", "coder-cli-config-dir")
-	if err != nil {
-		panic(err)
-	}
-	config.SetRoot(tmpDir)
-}
-
-func TestEnvsCommand(t *testing.T) {
-	res := execute(t, []string{"envs", "ls"}, nil)
-	assert.Error(t, "execute without auth", res.ExitErr)
-
-	err := assertClogErr(t, res.ExitErr)
-	assert.True(t, "login hint in error", strings.Contains(err.String(), "did you run \"coder login"))
-}
-
-type result struct {
-	OutBuffer *bytes.Buffer
-	ErrBuffer *bytes.Buffer
-	ExitErr   error
-}
+func Test_envs_ls(t *testing.T) {
+	res := execute(t, nil, "envs", "ls")
+	res.success(t)
 
-func execute(t *testing.T, args []string, in io.Reader) result {
-	cmd := Make()
-
-	outStream := bytes.NewBuffer(nil)
-	errStream := bytes.NewBuffer(nil)
-
-	cmd.SetArgs(args)
-
-	cmd.SetIn(in)
-	cmd.SetOut(outStream)
-	cmd.SetErr(errStream)
-
-	err := cmd.Execute()
-
-	slogtest.Debug(t, "execute command",
-		slog.F("outBuffer", outStream.String()),
-		slog.F("errBuffer", errStream.String()),
-		slog.F("args", args),
-		slog.F("execute_error", err),
-	)
-	return result{
-		OutBuffer: outStream,
-		ErrBuffer: errStream,
-		ExitErr:   err,
-	}
-}
+	res = execute(t, nil, "envs", "ls", "--output=json")
+	res.success(t)
 
-func assertClogErr(t *testing.T, err error) clog.CLIError {
-	var cliErr clog.CLIError
-	if !xerrors.As(err, &cliErr) {
-		slogtest.Fatal(t, "expected clog error, none found", slog.Error(err), slog.F("type", fmt.Sprintf("%T", err)))
-	}
-	slogtest.Debug(t, "clog error", slog.F("message", cliErr.String()))
-	return cliErr
+	var envs []coder.Environment
+	res.stdoutUnmarshals(t, &envs)
 }
diff --git a/internal/cmd/providers_test.go b/internal/cmd/providers_test.go
new file mode 100644
index 00000000..14900759
--- /dev/null
+++ b/internal/cmd/providers_test.go
@@ -0,0 +1,10 @@
+package cmd
+
+import (
+	"testing"
+)
+
+func Test_providers_ls(t *testing.T) {
+	res := execute(t, nil, "providers", "ls")
+	res.success(t)
+}