internal/moduletest: Experimental module testing helpers
As part of ongoing research into Terraform testing we'd like to use an experimental feature to validate our current understanding that expressing tests as part of the Terraform language, as opposed to in some other language run alongside, is a good and viable way to write practical module integration tests. This initial experimental incarnation of that idea is implemented as a provider, just because that's an easier extension point for research purposes than a first-class language feature would be. Whether this would ultimately emerge as a provider similar to this or as custom language constructs will be a matter for future research, if this first experiment confirms that tests written in the Terraform language are the best direction to take. The previous incarnation of this experiment was an externally-developed provider apparentlymart/testing, listed on the Terraform Registry. That helped with showing that there are some useful tests that we can write in the Terraform language, but integrating such a provider into Terraform will allow us to make use of it in the also-experimental "terraform test" command, which will follow in subsequent commits, to see how this might fit into a development workflow.
This commit is contained in:
parent
56b756cfd9
commit
8330f8e991
1
go.mod
1
go.mod
|
@ -114,6 +114,7 @@ require (
|
|||
github.com/xiang90/probing v0.0.0-20160813154853-07dd2e8dfe18 // indirect
|
||||
github.com/xlab/treeprint v0.0.0-20161029104018-1d6e34225557
|
||||
github.com/zclconf/go-cty v1.7.1
|
||||
github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b
|
||||
github.com/zclconf/go-cty-yaml v1.0.2
|
||||
go.uber.org/atomic v1.3.2 // indirect
|
||||
go.uber.org/multierr v1.1.0 // indirect
|
||||
|
|
2
go.sum
2
go.sum
|
@ -617,6 +617,8 @@ github.com/zclconf/go-cty v1.1.0/go.mod h1:xnAOWiHeOqg2nWS62VtQ7pbOu17FtxJNW8RLE
|
|||
github.com/zclconf/go-cty v1.2.0/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8=
|
||||
github.com/zclconf/go-cty v1.7.1 h1:AvsC01GMhMLFL8CgEYdHGM+yLnnDOwhPAYcgTkeF0Gw=
|
||||
github.com/zclconf/go-cty v1.7.1/go.mod h1:VDR4+I79ubFBGm1uJac1226K5yANQFHeauxPBoP54+o=
|
||||
github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b h1:FosyBZYxY34Wul7O/MSKey3txpPYyCqVO5ZyceuQJEI=
|
||||
github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8=
|
||||
github.com/zclconf/go-cty-yaml v1.0.2 h1:dNyg4QLTrv2IfJpm7Wtxi55ed5gLGOlPrZ6kMd51hY0=
|
||||
github.com/zclconf/go-cty-yaml v1.0.2/go.mod h1:IP3Ylp0wQpYm50IHK8OZWKMu6sPJIUgKa8XhiVHura0=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
package moduletest
|
||||
|
||||
import (
|
||||
"github.com/hashicorp/terraform/tfdiags"
|
||||
)
|
||||
|
||||
// Assertion is the description of a single test assertion, whether
|
||||
// successful or unsuccessful.
|
||||
type Assertion struct {
|
||||
Outcome Status
|
||||
|
||||
// Description is a user-provided, human-readable description of what
|
||||
// this assertion represents.
|
||||
Description string
|
||||
|
||||
// Message is typically relevant only for TestFailed or TestError
|
||||
// assertions, giving a human-readable description of the problem,
|
||||
// formatted in the way our format package expects to receive paragraphs
|
||||
// for terminal word wrapping.
|
||||
Message string
|
||||
|
||||
// Diagnostics includes diagnostics specific to the current test assertion,
|
||||
// if available.
|
||||
Diagnostics tfdiags.Diagnostics
|
||||
}
|
||||
|
||||
// Component represents a component being tested, each of which can have
|
||||
// several associated test assertions.
|
||||
type Component struct {
|
||||
Assertions map[string]*Assertion
|
||||
}
|
||||
|
||||
// Status is an enumeration of possible outcomes of a test assertion.
|
||||
type Status rune
|
||||
|
||||
const (
|
||||
// Pending indicates that the test was registered (during planning)
|
||||
// but didn't register an outcome during apply, perhaps due to being
|
||||
// blocked by some other upstream failure.
|
||||
Pending Status = '?'
|
||||
|
||||
// Passed indicates that the test condition succeeded.
|
||||
Passed Status = 'P'
|
||||
|
||||
// Failed indicates that the test condition was valid but did not
|
||||
// succeed.
|
||||
Failed Status = 'F'
|
||||
|
||||
// Error indicates that the test condition was invalid or that the
|
||||
// test report failed in some other way.
|
||||
Error Status = 'E'
|
||||
)
|
|
@ -0,0 +1,8 @@
|
|||
// Package moduletest contains the support code for some experimental features
|
||||
// we're using to evaluate strategies for having an opinionated approach to
|
||||
// testing of Terraform modules.
|
||||
//
|
||||
// At the moment nothing in this module is considered stable, so any features
|
||||
// that are usable by end-users ought to emit experiment warnings saying that
|
||||
// everything is subject to change even in patch releases.
|
||||
package moduletest
|
|
@ -0,0 +1,523 @@
|
|||
package moduletest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
"github.com/zclconf/go-cty/cty/gocty"
|
||||
ctyjson "github.com/zclconf/go-cty/cty/json"
|
||||
|
||||
"github.com/hashicorp/hcl/v2/hclsyntax"
|
||||
"github.com/hashicorp/terraform/configs/configschema"
|
||||
"github.com/hashicorp/terraform/providers"
|
||||
"github.com/hashicorp/terraform/repl"
|
||||
"github.com/hashicorp/terraform/tfdiags"
|
||||
)
|
||||
|
||||
// Provider is an implementation of providers.Interface which we're
|
||||
// using as a likely-only-temporary vehicle for research on an opinionated
|
||||
// module testing workflow in Terraform.
|
||||
//
|
||||
// We expose this to configuration as "terraform.io/builtin/test", but
|
||||
// any attempt to configure it will emit a warning that it is experimental
|
||||
// and likely to change or be removed entirely in future Terraform CLI
|
||||
// releases.
|
||||
//
|
||||
// The testing provider exists to gather up test results during a Terraform
|
||||
// apply operation. Its "test_results" managed resource type doesn't have any
|
||||
// user-visible effect on its own, but when used in conjunction with the
|
||||
// "terraform test" experimental command it is the intermediary that holds
|
||||
// the test results while the test runs, so that the test command can then
|
||||
// report them.
|
||||
//
|
||||
// For correct behavior of the assertion tracking, the "terraform test"
|
||||
// command must be sure to use the same instance of Provider for both the
|
||||
// plan and apply steps, so that the assertions that were planned can still
|
||||
// be tracked during apply. For other commands that don't explicitly support
|
||||
// test assertions, the provider will still succeed but the assertions data
|
||||
// may not be complete if the apply step fails.
|
||||
type Provider struct {
|
||||
// components tracks all of the "component" names that have been
|
||||
// used in test assertions resources so far. Each resource must have
|
||||
// a unique component name.
|
||||
components map[string]*Component
|
||||
|
||||
// Must lock mutex in order to interact with the components map, because
|
||||
// test assertions can potentially run concurrently.
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
var _ providers.Interface = (*Provider)(nil)
|
||||
|
||||
// NewProvider returns a new instance of the test provider.
|
||||
func NewProvider() *Provider {
|
||||
return &Provider{
|
||||
components: make(map[string]*Component),
|
||||
}
|
||||
}
|
||||
|
||||
// TestResults returns the current record of test results tracked inside the
|
||||
// provider.
|
||||
//
|
||||
// The result is a direct reference to the internal state of the provider,
|
||||
// so the caller mustn't modify it nor store it across calls to provider
|
||||
// operations.
|
||||
func (p *Provider) TestResults() map[string]*Component {
|
||||
return p.components
|
||||
}
|
||||
|
||||
// GetSchema returns the complete schema for the provider.
|
||||
func (p *Provider) GetSchema() providers.GetSchemaResponse {
|
||||
return providers.GetSchemaResponse{
|
||||
ResourceTypes: map[string]providers.Schema{
|
||||
"test_assertions": testAssertionsSchema,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// PrepareProviderConfig is used to tweak the configuration values.
|
||||
func (p *Provider) PrepareProviderConfig(req providers.PrepareProviderConfigRequest) providers.PrepareProviderConfigResponse {
|
||||
// This provider has no configurable settings.
|
||||
var res providers.PrepareProviderConfigResponse
|
||||
res.PreparedConfig = req.Config
|
||||
return res
|
||||
}
|
||||
|
||||
// Configure configures and initializes the provider.
|
||||
func (p *Provider) Configure(providers.ConfigureRequest) providers.ConfigureResponse {
|
||||
// This provider has no configurable settings, but we use the configure
|
||||
// request as an opportunity to generate a warning about it being
|
||||
// experimental.
|
||||
var res providers.ConfigureResponse
|
||||
res.Diagnostics = res.Diagnostics.Append(tfdiags.AttributeValue(
|
||||
tfdiags.Warning,
|
||||
"The test provider is experimental",
|
||||
"The Terraform team is using the test provider (terraform.io/builtin/test) as part of ongoing research about declarative testing of Terraform modules.\n\nThe availability and behavior of this provider is expected to change significantly even in patch releases, so we recommend using this provider only in test configurations and constraining your test configurations to an exact Terraform version.",
|
||||
nil,
|
||||
))
|
||||
return res
|
||||
}
|
||||
|
||||
// ValidateResourceTypeConfig is used to validate configuration values for a resource.
|
||||
func (p *Provider) ValidateResourceTypeConfig(req providers.ValidateResourceTypeConfigRequest) providers.ValidateResourceTypeConfigResponse {
|
||||
var res providers.ValidateResourceTypeConfigResponse
|
||||
if req.TypeName != "test_assertions" { // we only have one resource type
|
||||
res.Diagnostics = res.Diagnostics.Append(fmt.Errorf("unsupported resource type %s", req.TypeName))
|
||||
return res
|
||||
}
|
||||
|
||||
config := req.Config
|
||||
if !config.GetAttr("component").IsKnown() {
|
||||
res.Diagnostics = res.Diagnostics.Append(tfdiags.AttributeValue(
|
||||
tfdiags.Error,
|
||||
"Invalid component expression",
|
||||
"The component name must be a static value given in the configuration, and may not be derived from a resource type attribute that will only be known during the apply step.",
|
||||
cty.GetAttrPath("component"),
|
||||
))
|
||||
}
|
||||
if !hclsyntax.ValidIdentifier(config.GetAttr("component").AsString()) {
|
||||
res.Diagnostics = res.Diagnostics.Append(tfdiags.AttributeValue(
|
||||
tfdiags.Error,
|
||||
"Invalid component name",
|
||||
"The component name must be a valid identifier, starting with a letter followed by zero or more letters, digits, and underscores.",
|
||||
cty.GetAttrPath("component"),
|
||||
))
|
||||
}
|
||||
for it := config.GetAttr("equal").ElementIterator(); it.Next(); {
|
||||
k, obj := it.Element()
|
||||
if !hclsyntax.ValidIdentifier(k.AsString()) {
|
||||
res.Diagnostics = res.Diagnostics.Append(tfdiags.AttributeValue(
|
||||
tfdiags.Error,
|
||||
"Invalid assertion name",
|
||||
"An assertion name must be a valid identifier, starting with a letter followed by zero or more letters, digits, and underscores.",
|
||||
cty.GetAttrPath("equal").Index(k),
|
||||
))
|
||||
}
|
||||
if !obj.GetAttr("description").IsKnown() {
|
||||
res.Diagnostics = res.Diagnostics.Append(tfdiags.AttributeValue(
|
||||
tfdiags.Error,
|
||||
"Invalid description expression",
|
||||
"The description must be a static value given in the configuration, and may not be derived from a resource type attribute that will only be known during the apply step.",
|
||||
cty.GetAttrPath("equal").Index(k).GetAttr("description"),
|
||||
))
|
||||
}
|
||||
}
|
||||
for it := config.GetAttr("check").ElementIterator(); it.Next(); {
|
||||
k, obj := it.Element()
|
||||
if !hclsyntax.ValidIdentifier(k.AsString()) {
|
||||
res.Diagnostics = res.Diagnostics.Append(tfdiags.AttributeValue(
|
||||
tfdiags.Error,
|
||||
"Invalid assertion name",
|
||||
"An assertion name must be a valid identifier, starting with a letter followed by zero or more letters, digits, and underscores.",
|
||||
cty.GetAttrPath("check").Index(k),
|
||||
))
|
||||
}
|
||||
if !obj.GetAttr("description").IsKnown() {
|
||||
res.Diagnostics = res.Diagnostics.Append(tfdiags.AttributeValue(
|
||||
tfdiags.Error,
|
||||
"Invalid description expression",
|
||||
"The description must be a static value given in the configuration, and may not be derived from a resource type attribute that will only be known during the apply step.",
|
||||
cty.GetAttrPath("equal").Index(k).GetAttr("description"),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// ReadResource refreshes a resource and returns its current state.
|
||||
func (p *Provider) ReadResource(req providers.ReadResourceRequest) providers.ReadResourceResponse {
|
||||
var res providers.ReadResourceResponse
|
||||
if req.TypeName != "test_assertions" { // we only have one resource type
|
||||
res.Diagnostics = res.Diagnostics.Append(fmt.Errorf("unsupported resource type %s", req.TypeName))
|
||||
return res
|
||||
}
|
||||
// Test assertions are not a real remote object, so there isn't actually
|
||||
// anything to refresh here.
|
||||
res.NewState = req.PriorState
|
||||
return res
|
||||
}
|
||||
|
||||
// UpgradeResourceState is called to allow the provider to adapt the raw value
|
||||
// stored in the state in case the schema has changed since it was originally
|
||||
// written.
|
||||
func (p *Provider) UpgradeResourceState(req providers.UpgradeResourceStateRequest) providers.UpgradeResourceStateResponse {
|
||||
var res providers.UpgradeResourceStateResponse
|
||||
if req.TypeName != "test_assertions" { // we only have one resource type
|
||||
res.Diagnostics = res.Diagnostics.Append(fmt.Errorf("unsupported resource type %s", req.TypeName))
|
||||
return res
|
||||
}
|
||||
|
||||
// We assume here that there can never be a flatmap version of this
|
||||
// resource type's data, because this provider was never included in a
|
||||
// version of Terraform that used flatmap and this provider's schema
|
||||
// contains attributes that are not flatmap-compatible anyway.
|
||||
if len(req.RawStateFlatmap) != 0 {
|
||||
res.Diagnostics = res.Diagnostics.Append(fmt.Errorf("can't upgrade a flatmap state for %q", req.TypeName))
|
||||
return res
|
||||
}
|
||||
if req.Version != 0 {
|
||||
res.Diagnostics = res.Diagnostics.Append(fmt.Errorf("the state for this %s was created by a newer version of the provider", req.TypeName))
|
||||
return res
|
||||
}
|
||||
|
||||
v, err := ctyjson.Unmarshal(req.RawStateJSON, testAssertionsSchema.Block.ImpliedType())
|
||||
if err != nil {
|
||||
res.Diagnostics = res.Diagnostics.Append(fmt.Errorf("failed to decode state for %s: %s", req.TypeName, err))
|
||||
return res
|
||||
}
|
||||
|
||||
res.UpgradedState = v
|
||||
return res
|
||||
}
|
||||
|
||||
// PlanResourceChange takes the current state and proposed state of a
|
||||
// resource, and returns the planned final state.
|
||||
func (p *Provider) PlanResourceChange(req providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse {
|
||||
var res providers.PlanResourceChangeResponse
|
||||
if req.TypeName != "test_assertions" { // we only have one resource type
|
||||
res.Diagnostics = res.Diagnostics.Append(fmt.Errorf("unsupported resource type %s", req.TypeName))
|
||||
return res
|
||||
}
|
||||
|
||||
// During planning, our job is to gather up all of the planned test
|
||||
// assertions marked as pending, which will then allow us to include
|
||||
// all of them in test results even if there's a failure during apply
|
||||
// that prevents the full completion of the graph walk.
|
||||
//
|
||||
// In a sense our plan phase is similar to the compile step for a
|
||||
// test program written in another language. Planning itself can fail,
|
||||
// which means we won't be able to form a complete test plan at all,
|
||||
// but if we succeed in planning then subsequent problems can be treated
|
||||
// as test failures at "runtime", while still keeping a full manifest
|
||||
// of all of the tests that ought to have run if the apply had run to
|
||||
// completion.
|
||||
|
||||
proposed := req.ProposedNewState
|
||||
res.PlannedState = proposed
|
||||
componentName := proposed.GetAttr("component").AsString() // proven known during validate
|
||||
p.mutex.Lock()
|
||||
defer p.mutex.Unlock()
|
||||
if _, exists := p.components[componentName]; exists {
|
||||
res.Diagnostics = res.Diagnostics.Append(tfdiags.AttributeValue(
|
||||
tfdiags.Error,
|
||||
"Duplicate test component",
|
||||
fmt.Sprintf("Another test_assertions resource already declared assertions for the component name %q.", componentName),
|
||||
cty.GetAttrPath("component"),
|
||||
))
|
||||
return res
|
||||
}
|
||||
|
||||
component := Component{
|
||||
Assertions: make(map[string]*Assertion),
|
||||
}
|
||||
|
||||
for it := proposed.GetAttr("equal").ElementIterator(); it.Next(); {
|
||||
k, obj := it.Element()
|
||||
name := k.AsString()
|
||||
if _, exists := component.Assertions[name]; exists {
|
||||
// We can't actually get here in practice because so far we've
|
||||
// only been pulling keys from one map, and so any duplicates
|
||||
// would've been caught during config decoding, but this is here
|
||||
// just to make these two blocks symmetrical to avoid mishaps in
|
||||
// future refactoring/reorganization.
|
||||
res.Diagnostics = res.Diagnostics.Append(tfdiags.AttributeValue(
|
||||
tfdiags.Error,
|
||||
"Duplicate test assertion",
|
||||
fmt.Sprintf("Another assertion block in this resource already declared an assertion named %q.", name),
|
||||
cty.GetAttrPath("equal").Index(k),
|
||||
))
|
||||
continue
|
||||
}
|
||||
|
||||
var desc string
|
||||
descVal := obj.GetAttr("description")
|
||||
if descVal.IsNull() {
|
||||
descVal = cty.StringVal("")
|
||||
}
|
||||
err := gocty.FromCtyValue(descVal, &desc)
|
||||
if err != nil {
|
||||
// We shouldn't get here because we've already validated everything
|
||||
// that would make FromCtyValue fail above and during validate.
|
||||
res.Diagnostics = res.Diagnostics.Append(err)
|
||||
}
|
||||
|
||||
component.Assertions[name] = &Assertion{
|
||||
Outcome: Pending,
|
||||
Description: desc,
|
||||
}
|
||||
}
|
||||
|
||||
for it := proposed.GetAttr("check").ElementIterator(); it.Next(); {
|
||||
k, obj := it.Element()
|
||||
name := k.AsString()
|
||||
if _, exists := component.Assertions[name]; exists {
|
||||
res.Diagnostics = res.Diagnostics.Append(tfdiags.AttributeValue(
|
||||
tfdiags.Error,
|
||||
"Duplicate test assertion",
|
||||
fmt.Sprintf("Another assertion block in this resource already declared an assertion named %q.", name),
|
||||
cty.GetAttrPath("check").Index(k),
|
||||
))
|
||||
continue
|
||||
}
|
||||
|
||||
var desc string
|
||||
descVal := obj.GetAttr("description")
|
||||
if descVal.IsNull() {
|
||||
descVal = cty.StringVal("")
|
||||
}
|
||||
err := gocty.FromCtyValue(descVal, &desc)
|
||||
if err != nil {
|
||||
// We shouldn't get here because we've already validated everything
|
||||
// that would make FromCtyValue fail above and during validate.
|
||||
res.Diagnostics = res.Diagnostics.Append(err)
|
||||
}
|
||||
|
||||
component.Assertions[name] = &Assertion{
|
||||
Outcome: Pending,
|
||||
Description: desc,
|
||||
}
|
||||
}
|
||||
|
||||
p.components[componentName] = &component
|
||||
return res
|
||||
}
|
||||
|
||||
// ApplyResourceChange takes the planned state for a resource, which may
|
||||
// yet contain unknown computed values, and applies the changes returning
|
||||
// the final state.
|
||||
func (p *Provider) ApplyResourceChange(req providers.ApplyResourceChangeRequest) providers.ApplyResourceChangeResponse {
|
||||
var res providers.ApplyResourceChangeResponse
|
||||
if req.TypeName != "test_assertions" { // we only have one resource type
|
||||
res.Diagnostics = res.Diagnostics.Append(fmt.Errorf("unsupported resource type %s", req.TypeName))
|
||||
return res
|
||||
}
|
||||
|
||||
// During apply we actually check the assertions and record the results.
|
||||
// An assertion failure isn't reflected as an error from the apply call
|
||||
// because if possible we'd like to continue exercising other objects
|
||||
// downstream in case that allows us to gather more information to report.
|
||||
// (If something downstream returns an error then that could prevent us
|
||||
// from completing other assertions, though.)
|
||||
|
||||
planned := req.PlannedState
|
||||
res.NewState = planned
|
||||
componentName := planned.GetAttr("component").AsString() // proven known during validate
|
||||
|
||||
p.mutex.Lock()
|
||||
defer p.mutex.Unlock()
|
||||
component := p.components[componentName]
|
||||
if component == nil {
|
||||
// We might get here when using this provider outside of the
|
||||
// "terraform test" command, where there won't be any mechanism to
|
||||
// preserve the test provider instance between the plan and apply
|
||||
// phases. In that case, we assume that nobody will come looking to
|
||||
// collect the results anyway, and so we can just silently skip
|
||||
// checking.
|
||||
return res
|
||||
}
|
||||
|
||||
for it := planned.GetAttr("equal").ElementIterator(); it.Next(); {
|
||||
k, obj := it.Element()
|
||||
name := k.AsString()
|
||||
var desc string
|
||||
if plan, exists := component.Assertions[name]; exists {
|
||||
desc = plan.Description
|
||||
}
|
||||
assert := &Assertion{
|
||||
Outcome: Pending,
|
||||
Description: desc,
|
||||
}
|
||||
|
||||
gotVal := obj.GetAttr("got")
|
||||
wantVal := obj.GetAttr("want")
|
||||
switch {
|
||||
case wantVal.RawEquals(gotVal):
|
||||
assert.Outcome = Passed
|
||||
gotStr := repl.FormatValue(gotVal, 4)
|
||||
assert.Message = fmt.Sprintf("correct value\n got: %s\n", gotStr)
|
||||
default:
|
||||
assert.Outcome = Failed
|
||||
gotStr := repl.FormatValue(gotVal, 4)
|
||||
wantStr := repl.FormatValue(wantVal, 4)
|
||||
assert.Message = fmt.Sprintf("wrong value\n got: %s\n want: %s\n", gotStr, wantStr)
|
||||
}
|
||||
|
||||
component.Assertions[name] = assert
|
||||
}
|
||||
|
||||
for it := planned.GetAttr("check").ElementIterator(); it.Next(); {
|
||||
k, obj := it.Element()
|
||||
name := k.AsString()
|
||||
var desc string
|
||||
if plan, exists := component.Assertions[name]; exists {
|
||||
desc = plan.Description
|
||||
}
|
||||
assert := &Assertion{
|
||||
Outcome: Pending,
|
||||
Description: desc,
|
||||
}
|
||||
|
||||
condVal := obj.GetAttr("condition")
|
||||
switch {
|
||||
case condVal.IsNull():
|
||||
res.Diagnostics = res.Diagnostics.Append(tfdiags.AttributeValue(
|
||||
tfdiags.Error,
|
||||
"Invalid check condition",
|
||||
"The condition value must be a boolean expression, not null.",
|
||||
cty.GetAttrPath("check").Index(k).GetAttr("condition"),
|
||||
))
|
||||
continue
|
||||
case condVal.True():
|
||||
assert.Outcome = Passed
|
||||
assert.Message = "condition passed"
|
||||
default:
|
||||
assert.Outcome = Failed
|
||||
// For "check" we can't really return a decent error message
|
||||
// because we've lost all of the context by the time we get here.
|
||||
// "equal" will be better for most tests for that reason, and also
|
||||
// this is one reason why in the long run it would be better for
|
||||
// test assertions to be a first-class language feature rather than
|
||||
// just a provider-based concept.
|
||||
assert.Message = "condition failed"
|
||||
}
|
||||
|
||||
component.Assertions[name] = assert
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// ImportResourceState requests that the given resource be imported.
|
||||
func (p *Provider) ImportResourceState(req providers.ImportResourceStateRequest) providers.ImportResourceStateResponse {
|
||||
var res providers.ImportResourceStateResponse
|
||||
res.Diagnostics = res.Diagnostics.Append(fmt.Errorf("%s is not importable", req.TypeName))
|
||||
return res
|
||||
}
|
||||
|
||||
// ValidateDataSourceConfig is used to to validate the resource configuration values.
|
||||
func (p *Provider) ValidateDataSourceConfig(req providers.ValidateDataSourceConfigRequest) providers.ValidateDataSourceConfigResponse {
|
||||
// This provider has no data resouce types at all.
|
||||
var res providers.ValidateDataSourceConfigResponse
|
||||
res.Diagnostics = res.Diagnostics.Append(fmt.Errorf("unsupported data source %s", req.TypeName))
|
||||
return res
|
||||
}
|
||||
|
||||
// ReadDataSource returns the data source's current state.
|
||||
func (p *Provider) ReadDataSource(req providers.ReadDataSourceRequest) providers.ReadDataSourceResponse {
|
||||
// This provider has no data resouce types at all.
|
||||
var res providers.ReadDataSourceResponse
|
||||
res.Diagnostics = res.Diagnostics.Append(fmt.Errorf("unsupported data source %s", req.TypeName))
|
||||
return res
|
||||
}
|
||||
|
||||
// Stop is called when the provider should halt any in-flight actions.
|
||||
func (p *Provider) Stop() error {
|
||||
// This provider doesn't do anything that can be cancelled.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close is a noop for this provider, since it's run in-process.
|
||||
func (p *Provider) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var testAssertionsSchema = providers.Schema{
|
||||
Block: &configschema.Block{
|
||||
Attributes: map[string]*configschema.Attribute{
|
||||
"component": {
|
||||
Type: cty.String,
|
||||
Description: "The name of the component being tested. This is just for namespacing assertions in a result report.",
|
||||
DescriptionKind: configschema.StringPlain,
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
BlockTypes: map[string]*configschema.NestedBlock{
|
||||
"equal": {
|
||||
Nesting: configschema.NestingMap,
|
||||
Block: configschema.Block{
|
||||
Attributes: map[string]*configschema.Attribute{
|
||||
"description": {
|
||||
Type: cty.String,
|
||||
Description: "An optional human-readable description of what's being tested by this assertion.",
|
||||
DescriptionKind: configschema.StringPlain,
|
||||
Required: true,
|
||||
},
|
||||
"got": {
|
||||
Type: cty.DynamicPseudoType,
|
||||
Description: "The actual result value generated by the relevant component.",
|
||||
DescriptionKind: configschema.StringPlain,
|
||||
Required: true,
|
||||
},
|
||||
"want": {
|
||||
Type: cty.DynamicPseudoType,
|
||||
Description: "The value that the component is expected to have generated.",
|
||||
DescriptionKind: configschema.StringPlain,
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"check": {
|
||||
Nesting: configschema.NestingMap,
|
||||
Block: configschema.Block{
|
||||
Attributes: map[string]*configschema.Attribute{
|
||||
"description": {
|
||||
Type: cty.String,
|
||||
Description: "An optional (but strongly recommended) human-readable description of what's being tested by this assertion.",
|
||||
DescriptionKind: configschema.StringPlain,
|
||||
Required: true,
|
||||
},
|
||||
"condition": {
|
||||
Type: cty.Bool,
|
||||
Description: "An expression that must be true in order for the test to pass.",
|
||||
DescriptionKind: configschema.StringPlain,
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
|
@ -0,0 +1,155 @@
|
|||
package moduletest
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/hashicorp/terraform/providers"
|
||||
"github.com/zclconf/go-cty-debug/ctydebug"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
)
|
||||
|
||||
func TestProvider(t *testing.T) {
|
||||
|
||||
assertionConfig := cty.ObjectVal(map[string]cty.Value{
|
||||
"component": cty.StringVal("spline_reticulator"),
|
||||
"equal": cty.MapVal(map[string]cty.Value{
|
||||
"match": cty.ObjectVal(map[string]cty.Value{
|
||||
"description": cty.StringVal("this should match"),
|
||||
"got": cty.StringVal("a"),
|
||||
"want": cty.StringVal("a"),
|
||||
}),
|
||||
"unmatch": cty.ObjectVal(map[string]cty.Value{
|
||||
"description": cty.StringVal("this should not match"),
|
||||
"got": cty.StringVal("a"),
|
||||
"want": cty.StringVal("b"),
|
||||
}),
|
||||
}),
|
||||
"check": cty.MapVal(map[string]cty.Value{
|
||||
"pass": cty.ObjectVal(map[string]cty.Value{
|
||||
"description": cty.StringVal("this should pass"),
|
||||
"condition": cty.True,
|
||||
}),
|
||||
"fail": cty.ObjectVal(map[string]cty.Value{
|
||||
"description": cty.StringVal("this should fail"),
|
||||
"condition": cty.False,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
// The provider code expects to receive an object that was decoded from
|
||||
// HCL using the schema, so to make sure we're testing a more realistic
|
||||
// situation here we'll require the config to conform to the schema. If
|
||||
// this fails, it's a bug in the configuration definition above rather
|
||||
// than in the provider itself.
|
||||
for _, err := range assertionConfig.Type().TestConformance(testAssertionsSchema.Block.ImpliedType()) {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
p := NewProvider()
|
||||
|
||||
configureResp := p.Configure(providers.ConfigureRequest{
|
||||
Config: cty.EmptyObjectVal,
|
||||
})
|
||||
if got, want := len(configureResp.Diagnostics), 1; got != want {
|
||||
t.Fatalf("got %d Configure diagnostics, but want %d", got, want)
|
||||
}
|
||||
if got, want := configureResp.Diagnostics[0].Description().Summary, "The test provider is experimental"; got != want {
|
||||
t.Fatalf("wrong diagnostic message\ngot: %s\nwant: %s", got, want)
|
||||
}
|
||||
|
||||
validateResp := p.ValidateResourceTypeConfig(providers.ValidateResourceTypeConfigRequest{
|
||||
TypeName: "test_assertions",
|
||||
Config: assertionConfig,
|
||||
})
|
||||
if got, want := len(validateResp.Diagnostics), 0; got != want {
|
||||
t.Fatalf("got %d ValidateResourceTypeConfig diagnostics, but want %d", got, want)
|
||||
}
|
||||
|
||||
planResp := p.PlanResourceChange(providers.PlanResourceChangeRequest{
|
||||
TypeName: "test_assertions",
|
||||
Config: assertionConfig,
|
||||
PriorState: cty.NullVal(assertionConfig.Type()),
|
||||
ProposedNewState: assertionConfig,
|
||||
})
|
||||
if got, want := len(planResp.Diagnostics), 0; got != want {
|
||||
t.Fatalf("got %d PlanResourceChange diagnostics, but want %d", got, want)
|
||||
}
|
||||
planned := planResp.PlannedState
|
||||
if got, want := planned, assertionConfig; !want.RawEquals(got) {
|
||||
t.Fatalf("wrong planned new value\n%s", ctydebug.DiffValues(want, got))
|
||||
}
|
||||
|
||||
gotComponents := p.TestResults()
|
||||
wantComponents := map[string]*Component{
|
||||
"spline_reticulator": {
|
||||
Assertions: map[string]*Assertion{
|
||||
"pass": {
|
||||
Outcome: Pending,
|
||||
Description: "this should pass",
|
||||
},
|
||||
"fail": {
|
||||
Outcome: Pending,
|
||||
Description: "this should fail",
|
||||
},
|
||||
"match": {
|
||||
Outcome: Pending,
|
||||
Description: "this should match",
|
||||
},
|
||||
"unmatch": {
|
||||
Outcome: Pending,
|
||||
Description: "this should not match",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if diff := cmp.Diff(wantComponents, gotComponents); diff != "" {
|
||||
t.Fatalf("wrong test results after planning\n%s", diff)
|
||||
}
|
||||
|
||||
applyResp := p.ApplyResourceChange(providers.ApplyResourceChangeRequest{
|
||||
TypeName: "test_assertions",
|
||||
Config: assertionConfig,
|
||||
PriorState: cty.NullVal(assertionConfig.Type()),
|
||||
PlannedState: planned,
|
||||
})
|
||||
if got, want := len(applyResp.Diagnostics), 0; got != want {
|
||||
t.Fatalf("got %d ApplyResourceChange diagnostics, but want %d", got, want)
|
||||
}
|
||||
final := applyResp.NewState
|
||||
if got, want := final, assertionConfig; !want.RawEquals(got) {
|
||||
t.Fatalf("wrong new value\n%s", ctydebug.DiffValues(want, got))
|
||||
}
|
||||
|
||||
gotComponents = p.TestResults()
|
||||
wantComponents = map[string]*Component{
|
||||
"spline_reticulator": {
|
||||
Assertions: map[string]*Assertion{
|
||||
"pass": {
|
||||
Outcome: Passed,
|
||||
Description: "this should pass",
|
||||
Message: "condition passed",
|
||||
},
|
||||
"fail": {
|
||||
Outcome: Failed,
|
||||
Description: "this should fail",
|
||||
Message: "condition failed",
|
||||
},
|
||||
"match": {
|
||||
Outcome: Passed,
|
||||
Description: "this should match",
|
||||
Message: "correct value\n got: \"a\"\n",
|
||||
},
|
||||
"unmatch": {
|
||||
Outcome: Failed,
|
||||
Description: "this should not match",
|
||||
Message: "wrong value\n got: \"a\"\n want: \"b\"\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if diff := cmp.Diff(wantComponents, gotComponents); diff != "" {
|
||||
t.Fatalf("wrong test results after applying\n%s", diff)
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue