terraform/helper/resource/testing.go

276 lines
7.3 KiB
Go

package resource
import (
"fmt"
"io"
"io/ioutil"
"log"
"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.
//
// When the destroy plan is executed, the config from the last TestStep
// is used to plan it.
type TestCase struct {
// PreCheck, if non-nil, will be called before any test steps are
// executed. It will only be executed in the case that the steps
// would run, so it can be used for some validation before running
// acceptance tests, such as verifying that keys are setup.
PreCheck func()
// 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
}
// Run the PreCheck if we have it
if c.PreCheck != nil {
c.PreCheck()
}
// 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
log.Printf("[WARN] Test: Executing step %d", i)
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,
}
log.Printf("[WARN] Test: Executing destroy step")
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))
}
} else {
log.Printf("[WARN] Skipping destroy test since there is no 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
opts.State = state
ctx := terraform.NewContext(&opts)
if ws, es := ctx.Validate(); len(ws) > 0 || len(es) > 0 {
estrs := make([]string, len(es))
for i, e := range es {
estrs[i] = e.Error()
}
return state, fmt.Errorf(
"Configuration is invalid.\n\nWarnings: %#v\n\nErrors: %#v",
ws, estrs)
}
// Refresh!
state, err = ctx.Refresh()
if err != nil {
return state, fmt.Errorf(
"Error refreshing: %s", err)
}
// Plan!
if p, err := ctx.Plan(&terraform.PlanOpts{Destroy: step.Destroy}); err != nil {
return state, fmt.Errorf(
"Error planning: %s", err)
} else {
log.Printf("[WARN] Test: Step plan: %s", p)
}
// 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
}
// ComposeTestCheckFunc lets you compose multiple TestCheckFuncs into
// a single TestCheckFunc.
//
// As a user testing their provider, this lets you decompose your checks
// into smaller pieces more easily.
func ComposeTestCheckFunc(fs ...TestCheckFunc) TestCheckFunc {
return func(s *terraform.State) error {
for _, f := range fs {
if err := f(s); err != nil {
return err
}
}
return nil
}
}
func TestCheckResourceAttr(name, key, value string) TestCheckFunc {
return func(s *terraform.State) error {
rs, ok := s.Resources[name]
if !ok {
return fmt.Errorf("Not found: %s", name)
}
if rs.Attributes[key] != value {
return fmt.Errorf(
"%s: Attribute '%s' expected %#v, got %#v",
name,
key,
value,
rs.Attributes[key])
}
return nil
}
}
// 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