tfexec: Initial test command support (#400)

Reference: https://github.com/hashicorp/terraform-exec/issues/398
Reference: https://github.com/hashicorp/terraform/pull/33454

Adds support for the `terraform test` command, which currently supports JSON machine-readable output and one flag for configuring the tests directory away from the command default of `tests`. The command will return a non-zero status if any of the tests fail, which returns an error back to callers of the `Test` function. If consumers need access to the pass/fail test results, the terraform-json Go module will need to be enhanced to support the test summary JSON, e.g.

```
{"@level":"info","@message":"Failure! 0 passed, 1 failed.","@module":"terraform.ui","@timestamp":"2023-07-25T10:03:42.980799-04:00","test_summary":{"status":"fail","passed":0,"failed":1,"errored":0,"skipped":0},"type":"test_summary"}
```

Output of new end-to-end testing:

```
$ TFEXEC_E2ETEST_VERSIONS=1.5.3,1.6.0-alpha20230719 go test -count=1 -run='TestTest' -v ./tfexec/internal/e2etest
...
--- PASS: TestTest (9.50s)
    --- SKIP: TestTest/test_command_passing-1.5.3 (4.06s)
    --- PASS: TestTest/test_command_passing-1.6.0-alpha20230719 (5.44s)
...
--- PASS: TestTestError (0.48s)
    --- SKIP: TestTestError/test_command_failing-1.5.3 (0.27s)
    --- PASS: TestTestError/test_command_failing-1.6.0-alpha20230719 (0.21s)
```
diff --git a/tfexec/internal/e2etest/test_test.go b/tfexec/internal/e2etest/test_test.go
new file mode 100644
index 0000000..f925e22
--- /dev/null
+++ b/tfexec/internal/e2etest/test_test.go
@@ -0,0 +1,56 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: MPL-2.0
+
+package e2etest
+
+import (
+	"context"
+	"io"
+	"regexp"
+	"testing"
+
+	"github.com/hashicorp/go-version"
+
+	"github.com/hashicorp/terraform-exec/tfexec"
+)
+
+var (
+	testMinVersion = version.Must(version.NewVersion("1.6.0"))
+)
+
+func TestTest(t *testing.T) {
+	runTest(t, "test_command_passing", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
+		// Use Core() to enable pre-release support
+		if tfv.Core().LessThan(testMinVersion) {
+			t.Skip("test command is not available in this Terraform version")
+		}
+
+		err := tf.Test(context.Background(), nil)
+
+		if err != nil {
+			t.Fatalf("error running test command: %s", err)
+		}
+	})
+}
+
+func TestTestError(t *testing.T) {
+	runTest(t, "test_command_failing", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
+		// Use Core() to enable pre-release support
+		if tfv.Core().LessThan(testMinVersion) {
+			t.Skip("test command is not available in this Terraform version")
+		}
+
+		err := tf.Test(context.Background(), io.Discard)
+
+		if err == nil {
+			t.Fatal("expected error, got none")
+		}
+
+		got := err.Error()
+		expected := regexp.MustCompile("exit status 1")
+
+		if !expected.MatchString(got) {
+			t.Fatalf("expected error matching '%s', got: %s", expected, got)
+		}
+	})
+}
diff --git a/tfexec/internal/e2etest/testdata/test_command_failing/main.tf b/tfexec/internal/e2etest/testdata/test_command_failing/main.tf
new file mode 100644
index 0000000..b4652a0
--- /dev/null
+++ b/tfexec/internal/e2etest/testdata/test_command_failing/main.tf
@@ -0,0 +1,11 @@
+variable "test" {
+  type = string
+}
+
+resource "terraform_data" "test" {
+  input = var.test
+}
+
+output "test" {
+  value = terraform_data.test.output
+}
diff --git a/tfexec/internal/e2etest/testdata/test_command_failing/tests/passthrough.tftest b/tfexec/internal/e2etest/testdata/test_command_failing/tests/passthrough.tftest
new file mode 100644
index 0000000..25d3026
--- /dev/null
+++ b/tfexec/internal/e2etest/testdata/test_command_failing/tests/passthrough.tftest
@@ -0,0 +1,12 @@
+variables {
+  test = "test value"
+}
+
+run "variable_output_passthrough" {
+  command = apply
+
+  assert {
+    condition     = output.test == "not test value" # intentionally incorrect
+    error_message = "variable was not passed through to output"
+  }
+}
diff --git a/tfexec/internal/e2etest/testdata/test_command_passing/main.tf b/tfexec/internal/e2etest/testdata/test_command_passing/main.tf
new file mode 100644
index 0000000..b4652a0
--- /dev/null
+++ b/tfexec/internal/e2etest/testdata/test_command_passing/main.tf
@@ -0,0 +1,11 @@
+variable "test" {
+  type = string
+}
+
+resource "terraform_data" "test" {
+  input = var.test
+}
+
+output "test" {
+  value = terraform_data.test.output
+}
diff --git a/tfexec/internal/e2etest/testdata/test_command_passing/tests/passthrough.tftest b/tfexec/internal/e2etest/testdata/test_command_passing/tests/passthrough.tftest
new file mode 100644
index 0000000..3ebaf8d
--- /dev/null
+++ b/tfexec/internal/e2etest/testdata/test_command_passing/tests/passthrough.tftest
@@ -0,0 +1,12 @@
+variables {
+  test = "test value"
+}
+
+run "variable_output_passthrough" {
+  command = apply
+
+  assert {
+    condition     = output.test == "test value"
+    error_message = "variable was not passed through to output"
+  }
+}
diff --git a/tfexec/internal/testutil/tfcache.go b/tfexec/internal/testutil/tfcache.go
index e2f87b2..ff9ceb0 100644
--- a/tfexec/internal/testutil/tfcache.go
+++ b/tfexec/internal/testutil/tfcache.go
@@ -22,6 +22,8 @@
 	Latest015   = "0.15.5"
 	Latest_v1   = "1.0.11"
 	Latest_v1_1 = "1.1.9"
+	Latest_v1_5 = "1.5.3"
+	Latest_v1_6 = "1.6.0-alpha20230719"
 )
 
 const appendUserAgent = "tfexec-testutil"
diff --git a/tfexec/options.go b/tfexec/options.go
index a9bade0..5cccde3 100644
--- a/tfexec/options.go
+++ b/tfexec/options.go
@@ -365,6 +365,15 @@
 	return &TargetOption{resource}
 }
 
+type TestsDirectoryOption struct {
+	testsDirectory string
+}
+
+// TestsDirectory represents the -tests-directory option (path to tests files)
+func TestsDirectory(testsDirectory string) *TestsDirectoryOption {
+	return &TestsDirectoryOption{testsDirectory}
+}
+
 type GraphTypeOption struct {
 	graphType string
 }
diff --git a/tfexec/test.go b/tfexec/test.go
new file mode 100644
index 0000000..5e0bb63
--- /dev/null
+++ b/tfexec/test.go
@@ -0,0 +1,66 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: MPL-2.0
+
+package tfexec
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"os/exec"
+)
+
+type testConfig struct {
+	testsDirectory string
+}
+
+var defaultTestOptions = testConfig{}
+
+type TestOption interface {
+	configureTest(*testConfig)
+}
+
+func (opt *TestsDirectoryOption) configureTest(conf *testConfig) {
+	conf.testsDirectory = opt.testsDirectory
+}
+
+// Test represents the terraform test -json subcommand.
+//
+// The given io.Writer, if specified, will receive
+// [machine-readable](https://developer.hashicorp.com/terraform/internals/machine-readable-ui)
+// JSON from Terraform including test results.
+func (tf *Terraform) Test(ctx context.Context, w io.Writer, opts ...TestOption) error {
+	err := tf.compatible(ctx, tf1_6_0, nil)
+
+	if err != nil {
+		return fmt.Errorf("terraform test was added in 1.6.0: %w", err)
+	}
+
+	tf.SetStdout(w)
+
+	testCmd := tf.testCmd(ctx)
+
+	err = tf.runTerraformCmd(ctx, testCmd)
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (tf *Terraform) testCmd(ctx context.Context, opts ...TestOption) *exec.Cmd {
+	c := defaultTestOptions
+
+	for _, o := range opts {
+		o.configureTest(&c)
+	}
+
+	args := []string{"test", "-json"}
+
+	if c.testsDirectory != "" {
+		args = append(args, "-tests-directory="+c.testsDirectory)
+	}
+
+	return tf.buildTerraformCmd(ctx, nil, args...)
+}
diff --git a/tfexec/test_test.go b/tfexec/test_test.go
new file mode 100644
index 0000000..aa2b450
--- /dev/null
+++ b/tfexec/test_test.go
@@ -0,0 +1,43 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: MPL-2.0
+
+package tfexec
+
+import (
+	"context"
+	"testing"
+
+	"github.com/hashicorp/terraform-exec/tfexec/internal/testutil"
+)
+
+func TestTestCmd(t *testing.T) {
+	td := t.TempDir()
+
+	tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1_6))
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// empty env, to avoid environ mismatch in testing
+	tf.SetEnv(map[string]string{})
+
+	t.Run("defaults", func(t *testing.T) {
+		testCmd := tf.testCmd(context.Background())
+
+		assertCmd(t, []string{
+			"test",
+			"-json",
+		}, nil, testCmd)
+	})
+
+	t.Run("override all defaults", func(t *testing.T) {
+		testCmd := tf.testCmd(context.Background(), TestsDirectory("test"))
+
+		assertCmd(t, []string{
+			"test",
+			"-json",
+			"-tests-directory=test",
+		}, nil, testCmd)
+	})
+}
diff --git a/tfexec/version.go b/tfexec/version.go
index ec4d522..6233212 100644
--- a/tfexec/version.go
+++ b/tfexec/version.go
@@ -31,6 +31,7 @@
 	tf0_15_3 = version.Must(version.NewVersion("0.15.3"))
 	tf1_1_0  = version.Must(version.NewVersion("1.1.0"))
 	tf1_4_0  = version.Must(version.NewVersion("1.4.0"))
+	tf1_6_0  = version.Must(version.NewVersion("1.6.0"))
 )
 
 // Version returns structured output from the terraform version command including both the Terraform CLI version