diff --git a/helper/resource/testing.go b/helper/resource/testing.go new file mode 100644 index 000000000..84a287e68 --- /dev/null +++ b/helper/resource/testing.go @@ -0,0 +1,205 @@ +package resource + +import ( + "fmt" + "io" + "io/ioutil" + "os" + "strings" + "testing" + + "github.com/hashicorp/terraform/config" + "github.com/hashicorp/terraform/terraform" +) + +const TestEnvVar = "TF_ACC" + +// TestCheckFunc is the callback type used with acceptance tests to check +// the state of a resource. The state passed in is the latest state known, +// or in the case of being after a destroy, it is the last known state when +// it was created. +type TestCheckFunc func(*terraform.State) error + +// TestCase is a single acceptance test case used to test the apply/destroy +// lifecycle of a resource in a specific configuration. +type TestCase struct { + // Provider is the ResourceProvider that will be under test. + Providers map[string]terraform.ResourceProvider + + // CheckDestroy is called after the resource is finally destroyed + // to allow the tester to test that the resource is truly gone. + CheckDestroy TestCheckFunc + + // Steps are the apply sequences done within the context of the + // same state. Each step can have its own check to verify correctness. + Steps []TestStep +} + +// TestStep is a single apply sequence of a test, done within the +// context of a state. +// +// Multiple TestSteps can be sequenced in a Test to allow testing +// potentially complex update logic. In general, simply create/destroy +// tests will only need one step. +type TestStep struct { + // Config a string of the configuration to give to Terraform. + Config string + + // Check is called after the Config is applied. Use this step to + // make your own API calls to check the status of things, and to + // inspect the format of the ResourceState itself. + // + // If an error is returned, the test will fail. In this case, a + // destroy plan will still be attempted. + // + // If this is nil, no check is done on this step. + Check TestCheckFunc + + // Destroy will create a destroy plan if set to true. + Destroy bool +} + +// Test performs an acceptance test on a resource. +// +// Tests are not run unless an environmental variable "TF_ACC" is +// set to some non-empty value. This is to avoid test cases surprising +// a user by creating real resources. +// +// Tests will fail unless the verbose flag (`go test -v`, or explicitly +// the "-test.v" flag) is set. Because some acceptance tests take quite +// long, we require the verbose flag so users are able to see progress +// output. +func Test(t TestT, c TestCase) { + // We only run acceptance tests if an env var is set because they're + // slow and generally require some outside configuration. + if os.Getenv(TestEnvVar) == "" { + t.Skip(fmt.Sprintf( + "Acceptance tests skipped unless env '%s' set", + TestEnvVar)) + return + } + + // We require verbose mode so that the user knows what is going on. + if !testTesting && !testing.Verbose() { + t.Fatal("Acceptance tests must be run with the -v flag on tests") + return + } + + // Build our context options that we can + ctxProviders := make(map[string]terraform.ResourceProviderFactory) + for k, p := range c.Providers { + ctxProviders[k] = terraform.ResourceProviderFactoryFixed(p) + } + opts := terraform.ContextOpts{Providers: ctxProviders} + + // A single state variable to track the lifecycle, starting with no state + var state *terraform.State + + // Go through each step and run it + for i, step := range c.Steps { + var err error + state, err = testStep(opts, state, step) + if err != nil { + t.Error(fmt.Sprintf( + "Step %d error: %s", i, err)) + break + } + } + + // If we have a state, then run the destroy + if state != nil { + destroyStep := TestStep{ + Config: c.Steps[len(c.Steps)-1].Config, + Check: c.CheckDestroy, + Destroy: true, + } + + state, err := testStep(opts, state, destroyStep) + if err != nil { + t.Error(fmt.Sprintf( + "Error destroying resource! WARNING: Dangling resources\n"+ + "may exist. The full state and error is shown below.\n\n"+ + "Error: %s\n\nState: %s", + err, + state)) + } + } +} + +func testStep( + opts terraform.ContextOpts, + state *terraform.State, + step TestStep) (*terraform.State, error) { + // Write the configuration + cfgF, err := ioutil.TempFile("", "tf-test") + if err != nil { + return state, fmt.Errorf( + "Error creating temporary file for config: %s", err) + } + cfgPath := cfgF.Name() + ".tf" + cfgF.Close() + os.Remove(cfgF.Name()) + + cfgF, err = os.Create(cfgPath) + if err != nil { + return state, fmt.Errorf( + "Error creating temporary file for config: %s", err) + } + defer os.Remove(cfgPath) + + _, err = io.Copy(cfgF, strings.NewReader(step.Config)) + cfgF.Close() + if err != nil { + return state, fmt.Errorf( + "Error creating temporary file for config: %s", err) + } + + // Parse the configuration + config, err := config.Load(cfgPath) + if err != nil { + return state, fmt.Errorf( + "Error parsing configuration: %s", err) + } + + // Build the context + opts.Config = config + ctx := terraform.NewContext(&opts) + if ws, es := ctx.Validate(); len(ws) > 0 || len(es) > 0 { + return state, fmt.Errorf( + "Configuration is invalid.\n\nWarnings: %#v\n\nErrors: %#v", + ws, es) + } + + // Plan! + if _, err := ctx.Plan(&terraform.PlanOpts{Destroy: step.Destroy}); err != nil { + return state, fmt.Errorf( + "Error planning: %s", err) + } + + // Apply! + state, err = ctx.Apply() + if err != nil { + return state, fmt.Errorf("Error applying: %s", err) + } + + // Check! Excitement! + if step.Check != nil { + if err = step.Check(state); err != nil { + err = fmt.Errorf("Check failed: %s", err) + } + } + + return state, err +} + +// TestT is the interface used to handle the test lifecycle of a test. +// +// Users should just use a *testing.T object, which implements this. +type TestT interface { + Error(args ...interface{}) + Fatal(args ...interface{}) + Skip(args ...interface{}) +} + +// This is set to true by unit tests to alter some behavior +var testTesting = false diff --git a/helper/resource/testing_test.go b/helper/resource/testing_test.go new file mode 100644 index 000000000..33fc2727d --- /dev/null +++ b/helper/resource/testing_test.go @@ -0,0 +1,163 @@ +package resource + +import ( + "os" + "testing" + + "github.com/hashicorp/terraform/terraform" +) + +func init() { + testTesting = true + + if err := os.Setenv(TestEnvVar, "1"); err != nil { + panic(err) + } +} + +func TestTest(t *testing.T) { + mp := testProvider() + mp.ApplyReturn = &terraform.ResourceState{ + ID: "foo", + } + + checkDestroy := false + checkStep := false + + checkDestroyFn := func(*terraform.State) error { + checkDestroy = true + return nil + } + + checkStepFn := func(s *terraform.State) error { + checkStep = true + + rs, ok := s.Resources["test_instance.foo"] + if !ok { + t.Error("test_instance.foo is not present") + return nil + } + if rs.ID != "foo" { + t.Errorf("bad check ID: %s", rs.ID) + } + + return nil + } + + mt := new(mockT) + Test(mt, TestCase{ + Providers: map[string]terraform.ResourceProvider{ + "test": mp, + }, + CheckDestroy: checkDestroyFn, + Steps: []TestStep{ + TestStep{ + Config: testConfigStr, + Check: checkStepFn, + }, + }, + }) + + if mt.failed() { + t.Fatalf("test failed: %s", mt.failMessage()) + } + if !checkStep { + t.Fatal("didn't call check for step") + } + if !checkDestroy { + t.Fatal("didn't call check for destroy") + } +} + +func TestTest_empty(t *testing.T) { + destroyCalled := false + checkDestroyFn := func(*terraform.State) error { + destroyCalled = true + return nil + } + + mt := new(mockT) + Test(mt, TestCase{ + CheckDestroy: checkDestroyFn, + }) + + if mt.failed() { + t.Fatal("test failed") + } + if destroyCalled { + t.Fatal("should not call check destroy if there is no steps") + } +} + +func TestTest_noEnv(t *testing.T) { + // Unset the variable + if err := os.Setenv(TestEnvVar, ""); err != nil { + t.Fatalf("err: %s", err) + } + + mt := new(mockT) + Test(mt, TestCase{}) + + if !mt.SkipCalled { + t.Fatal("skip not called") + } +} + +// mockT implements TestT for testing +type mockT struct { + ErrorCalled bool + ErrorArgs []interface{} + FatalCalled bool + FatalArgs []interface{} + SkipCalled bool + SkipArgs []interface{} + + f bool +} + +func (t *mockT) Error(args ...interface{}) { + t.ErrorCalled = true + t.ErrorArgs = args + t.f = true +} + +func (t *mockT) Fatal(args ...interface{}) { + t.FatalCalled = true + t.FatalArgs = args + t.f = true +} + +func (t *mockT) Skip(args ...interface{}) { + t.SkipCalled = true + t.SkipArgs = args + t.f = true +} + +func (t *mockT) failed() bool { + return t.f +} + +func (t *mockT) failMessage() string { + if t.FatalCalled { + return t.FatalArgs[0].(string) + } else if t.ErrorCalled { + return t.ErrorArgs[0].(string) + } else if t.SkipCalled { + return t.SkipArgs[0].(string) + } + + return "unknown" +} + +func testProvider() *terraform.MockResourceProvider { + mp := new(terraform.MockResourceProvider) + mp.ResourcesReturn = []terraform.ResourceType{ + terraform.ResourceType{Name: "test_instance"}, + } + + return mp +} + +const testConfigStr = ` +resource "test_instance" "foo" {} +`