helper/resource: Acceptance test framework

This commit is contained in:
Mitchell Hashimoto 2014-07-10 10:20:21 -07:00
parent 3a79a1ca1a
commit e0fbd48afd
2 changed files with 368 additions and 0 deletions

205
helper/resource/testing.go Normal file
View File

@ -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

View File

@ -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" {}
`