A Terraform provider for Dokploy
Plugin-framework provider covering projects, environments, applications, Compose stacks, managed databases, domains, mounts, ports, redirects, basic auth, registries, SSH keys, certificates and backup destinations, over Dokploy's tRPC-over-REST API. The shim package exposes the provider to other Go modules, which is how pulumi-dokploy bridges it.
This commit is contained in:
414
.agents/skills/provider-test-patterns/SKILL.md
Normal file
414
.agents/skills/provider-test-patterns/SKILL.md
Normal file
@@ -0,0 +1,414 @@
|
||||
---
|
||||
name: provider-test-patterns
|
||||
description: >-
|
||||
Terraform provider acceptance test patterns using terraform-plugin-testing
|
||||
with the Plugin Framework. Covers test structure, TestCase/TestStep fields,
|
||||
ConfigStateChecks with custom statecheck.StateCheck implementations,
|
||||
plan checks, CompareValue for cross-step assertions, config helpers,
|
||||
import testing with ImportStateKind, sweepers, and scenario patterns
|
||||
(basic, update, disappears, validation, regression), and ephemeral resource
|
||||
testing with the echoprovider package. Use when writing, reviewing, or
|
||||
debugging provider acceptance tests, including questions about statecheck,
|
||||
plancheck, TestCheckFunc, CheckDestroy, ExpectError, import state
|
||||
verification, ephemeral resources, or how to structure test files.
|
||||
metadata:
|
||||
copyright: Copyright IBM Corp. 2026
|
||||
version: "0.0.1"
|
||||
---
|
||||
|
||||
# Provider Acceptance Test Patterns
|
||||
|
||||
Patterns for writing acceptance tests using
|
||||
[terraform-plugin-testing](https://github.com/hashicorp/terraform-plugin-testing)
|
||||
with the [Plugin Framework](https://github.com/hashicorp/terraform-plugin-framework).
|
||||
|
||||
Source: [HashiCorp Testing Patterns](https://developer.hashicorp.com/terraform/plugin/testing/testing-patterns)
|
||||
|
||||
**References** (load when needed):
|
||||
- `references/checks.md` — statecheck, plancheck, knownvalue types, tfjsonpath, comparers
|
||||
- `references/sweepers.md` — sweeper setup, TestMain, dependencies
|
||||
- `references/ephemeral.md` — ephemeral resource testing, echoprovider, multi-step patterns
|
||||
|
||||
---
|
||||
|
||||
## Test Lifecycle
|
||||
|
||||
The framework runs each TestStep through: **plan → apply → refresh → final
|
||||
plan**. If the final plan shows a diff, the test fails (unless
|
||||
`ExpectNonEmptyPlan` is set). After all steps, destroy runs followed by
|
||||
`CheckDestroy`. This means every test automatically verifies that
|
||||
configurations apply cleanly and produce no drift — no assertions needed for
|
||||
that.
|
||||
|
||||
---
|
||||
|
||||
## Test Function Structure
|
||||
|
||||
```go
|
||||
func TestAccExample_basic(t *testing.T) {
|
||||
var widget example.Widget
|
||||
rName := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum)
|
||||
resourceName := "example_widget.test"
|
||||
|
||||
resource.ParallelTest(t, resource.TestCase{
|
||||
PreCheck: func() { testAccPreCheck(t) },
|
||||
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
|
||||
CheckDestroy: testAccCheckExampleDestroy,
|
||||
Steps: []resource.TestStep{
|
||||
{
|
||||
Config: testAccExampleConfig_basic(rName),
|
||||
ConfigStateChecks: []statecheck.StateCheck{
|
||||
stateCheckExampleExists(resourceName, &widget),
|
||||
statecheck.ExpectKnownValue(resourceName,
|
||||
tfjsonpath.New("name"), knownvalue.StringExact(rName)),
|
||||
statecheck.ExpectKnownValue(resourceName,
|
||||
tfjsonpath.New("id"), knownvalue.NotNull()),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Use `resource.ParallelTest` by default. Use `resource.Test` only when tests
|
||||
share state or cannot run concurrently.
|
||||
|
||||
---
|
||||
|
||||
## Provider Factory
|
||||
|
||||
```go
|
||||
// provider_test.go — Plugin Framework with Protocol 6 (use Protocol5 variant if needed)
|
||||
var testAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){
|
||||
"example": providerserver.NewProtocol6WithError(New("test")()),
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TestCase Fields
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `PreCheck` | `func()` — verify prerequisites (env vars, API access) |
|
||||
| `ProtoV6ProviderFactories` | Plugin Framework provider factories |
|
||||
| `CheckDestroy` | `TestCheckFunc` — verify resources destroyed after all steps |
|
||||
| `Steps` | `[]TestStep` — sequential test operations |
|
||||
| `TerraformVersionChecks` | `[]tfversion.TerraformVersionCheck` — gate by CLI version |
|
||||
|
||||
---
|
||||
|
||||
## TestStep Fields
|
||||
|
||||
### Config Mode
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `Config` | Inline HCL string to apply |
|
||||
| `ConfigStateChecks` | `[]statecheck.StateCheck` — modern assertions (preferred) |
|
||||
| `ConfigPlanChecks` | `resource.ConfigPlanChecks{PreApply: []plancheck.PlanCheck{...}}` |
|
||||
| `ExpectError` | `*regexp.Regexp` — expect failure matching pattern |
|
||||
| `ExpectNonEmptyPlan` | `bool` — expect non-empty plan after apply |
|
||||
| `PlanOnly` | `bool` — plan without applying |
|
||||
| `Destroy` | `bool` — run destroy step |
|
||||
| `PreConfig` | `func()` — setup before step |
|
||||
|
||||
### Import Mode
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `ImportState` | `true` to enable import mode |
|
||||
| `ImportStateVerify` | Verify imported state matches prior state |
|
||||
| `ImportStateVerifyIgnore` | `[]string` — attributes to skip during verify |
|
||||
| `ImportStateKind` | `resource.ImportBlockWithID` — import block generation |
|
||||
| `ResourceName` | Resource address to import |
|
||||
| `ImportStateId` | Override the ID used for import |
|
||||
|
||||
---
|
||||
|
||||
## Check Functions
|
||||
|
||||
### Modern: ConfigStateChecks (preferred)
|
||||
|
||||
Type-safe with aggregated error reporting. Compose built-in checks with custom
|
||||
`statecheck.StateCheck` implementations. See `references/checks.md` for full
|
||||
knownvalue types, tfjsonpath navigation, and comparers.
|
||||
|
||||
```go
|
||||
ConfigStateChecks: []statecheck.StateCheck{
|
||||
stateCheckExampleExists(resourceName, &widget),
|
||||
statecheck.ExpectKnownValue(resourceName,
|
||||
tfjsonpath.New("name"), knownvalue.StringExact("my-widget")),
|
||||
statecheck.ExpectKnownValue(resourceName,
|
||||
tfjsonpath.New("enabled"), knownvalue.Bool(true)),
|
||||
statecheck.ExpectKnownValue(resourceName,
|
||||
tfjsonpath.New("id"), knownvalue.NotNull()),
|
||||
statecheck.ExpectSensitiveValue(resourceName,
|
||||
tfjsonpath.New("api_key")),
|
||||
},
|
||||
```
|
||||
|
||||
Do not mix `Check` (legacy) and `ConfigStateChecks` in the same step.
|
||||
|
||||
### Legacy: Check (for CheckDestroy and migration)
|
||||
|
||||
`CheckDestroy` on `TestCase` requires `TestCheckFunc`. The `Check` field on
|
||||
`TestStep` also accepts `TestCheckFunc` but prefer `ConfigStateChecks` for new
|
||||
tests.
|
||||
|
||||
```go
|
||||
Check: resource.ComposeAggregateTestCheckFunc(
|
||||
resource.TestCheckResourceAttr(name, "key", "expected"),
|
||||
resource.TestCheckResourceAttrSet(name, "id"),
|
||||
resource.TestCheckNoResourceAttr(name, "removed"),
|
||||
resource.TestMatchResourceAttr(name, "url", regexp.MustCompile(`^https://`)),
|
||||
resource.TestCheckResourceAttrPair(res1, "ref_id", res2, "id"),
|
||||
),
|
||||
```
|
||||
|
||||
`ComposeAggregateTestCheckFunc` reports all errors; `ComposeTestCheckFunc`
|
||||
fails fast on the first.
|
||||
|
||||
---
|
||||
|
||||
## Config Helpers
|
||||
|
||||
Use numbered format verbs — `%[1]q` for quoted strings, `%[1]s` for raw:
|
||||
|
||||
```go
|
||||
func testAccExampleConfig_basic(rName string) string {
|
||||
return fmt.Sprintf(`
|
||||
resource "example_widget" "test" {
|
||||
name = %[1]q
|
||||
}
|
||||
`, rName)
|
||||
}
|
||||
|
||||
func testAccExampleConfig_full(rName, description string) string {
|
||||
return fmt.Sprintf(`
|
||||
resource "example_widget" "test" {
|
||||
name = %[1]q
|
||||
description = %[2]q
|
||||
enabled = true
|
||||
}
|
||||
`, rName, description)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scenario Patterns
|
||||
|
||||
### Basic + Update (combine in one test — updates are supersets of basic)
|
||||
|
||||
```go
|
||||
Steps: []resource.TestStep{
|
||||
{
|
||||
Config: testAccExampleConfig_basic(rName),
|
||||
ConfigStateChecks: []statecheck.StateCheck{
|
||||
stateCheckExampleExists(resourceName, &widget),
|
||||
statecheck.ExpectKnownValue(resourceName,
|
||||
tfjsonpath.New("name"), knownvalue.StringExact(rName)),
|
||||
},
|
||||
},
|
||||
{
|
||||
Config: testAccExampleConfig_full(rName, "updated"),
|
||||
ConfigStateChecks: []statecheck.StateCheck{
|
||||
stateCheckExampleExists(resourceName, &widget),
|
||||
statecheck.ExpectKnownValue(resourceName,
|
||||
tfjsonpath.New("description"), knownvalue.StringExact("updated")),
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
### Import
|
||||
|
||||
After a config step, verify import produces identical state. Use
|
||||
`ImportStateKind` for import block generation:
|
||||
|
||||
```go
|
||||
{
|
||||
ResourceName: resourceName,
|
||||
ImportState: true,
|
||||
ImportStateVerify: true,
|
||||
ImportStateKind: resource.ImportBlockWithID,
|
||||
},
|
||||
```
|
||||
|
||||
### Disappears (resource deleted externally)
|
||||
|
||||
```go
|
||||
{
|
||||
Config: testAccExampleConfig_basic(rName),
|
||||
ConfigStateChecks: []statecheck.StateCheck{
|
||||
stateCheckExampleExists(resourceName, &widget),
|
||||
stateCheckExampleDisappears(resourceName),
|
||||
},
|
||||
ExpectNonEmptyPlan: true,
|
||||
},
|
||||
```
|
||||
|
||||
### Validation (expect error)
|
||||
|
||||
```go
|
||||
{
|
||||
Config: testAccExampleConfig_invalidName(""),
|
||||
ExpectError: regexp.MustCompile(`name must not be empty`),
|
||||
},
|
||||
```
|
||||
|
||||
### Regression (two-commit workflow)
|
||||
|
||||
A proper bug fix uses at least two commits: first commit the regression test
|
||||
(which fails, confirming the bug), then commit the fix (test passes). This
|
||||
lets reviewers independently verify the test reproduces the issue by checking
|
||||
out the first commit, then advancing to the fix.
|
||||
|
||||
Name and document regression tests to identify the issue they fix. Include a
|
||||
link to the original bug report when possible.
|
||||
|
||||
```go
|
||||
// TestAccExample_regressionGH1234 verifies fix for https://github.com/org/repo/issues/1234
|
||||
func TestAccExample_regressionGH1234(t *testing.T) {
|
||||
rName := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum)
|
||||
resourceName := "example_widget.test"
|
||||
|
||||
resource.ParallelTest(t, resource.TestCase{
|
||||
PreCheck: func() { testAccPreCheck(t) },
|
||||
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
|
||||
CheckDestroy: testAccCheckExampleDestroy,
|
||||
Steps: []resource.TestStep{
|
||||
{
|
||||
// Reproduce the issue: this config triggered the bug
|
||||
Config: testAccExampleConfig_regressionGH1234(rName),
|
||||
ConfigStateChecks: []statecheck.StateCheck{
|
||||
stateCheckExampleExists(resourceName, nil),
|
||||
statecheck.ExpectKnownValue(resourceName,
|
||||
tfjsonpath.New("computed_field"), knownvalue.NotNull()),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Helper Functions
|
||||
|
||||
### Custom StateCheck: Exists
|
||||
|
||||
Implement `statecheck.StateCheck` for API existence verification. Separate the
|
||||
exists check into its own function for reuse across steps — the source
|
||||
recommends this as a design principle:
|
||||
|
||||
```go
|
||||
type exampleExistsCheck struct {
|
||||
resourceAddress string
|
||||
widget *example.Widget
|
||||
}
|
||||
|
||||
func (e exampleExistsCheck) CheckState(ctx context.Context, req statecheck.CheckStateRequest, resp *statecheck.CheckStateResponse) {
|
||||
r, err := stateResourceAtAddress(req.State, e.resourceAddress)
|
||||
if err != nil {
|
||||
resp.Error = err
|
||||
return
|
||||
}
|
||||
|
||||
id, ok := r.AttributeValues["id"].(string)
|
||||
if !ok {
|
||||
resp.Error = fmt.Errorf("no id found for %s", e.resourceAddress)
|
||||
return
|
||||
}
|
||||
|
||||
conn := testAccAPIClient()
|
||||
widget, err := conn.GetWidget(id)
|
||||
if err != nil {
|
||||
resp.Error = fmt.Errorf("%s not found via API: %w", e.resourceAddress, err)
|
||||
return
|
||||
}
|
||||
|
||||
if e.widget != nil {
|
||||
*e.widget = *widget
|
||||
}
|
||||
}
|
||||
|
||||
func stateCheckExampleExists(name string, widget *example.Widget) statecheck.StateCheck {
|
||||
return exampleExistsCheck{resourceAddress: name, widget: widget}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom StateCheck: Disappears
|
||||
|
||||
Delete a resource via API to simulate external deletion:
|
||||
|
||||
```go
|
||||
type exampleDisappearsCheck struct {
|
||||
resourceAddress string
|
||||
}
|
||||
|
||||
func (e exampleDisappearsCheck) CheckState(ctx context.Context, req statecheck.CheckStateRequest, resp *statecheck.CheckStateResponse) {
|
||||
r, err := stateResourceAtAddress(req.State, e.resourceAddress)
|
||||
if err != nil {
|
||||
resp.Error = err
|
||||
return
|
||||
}
|
||||
|
||||
id := r.AttributeValues["id"].(string)
|
||||
conn := testAccAPIClient()
|
||||
resp.Error = conn.DeleteWidget(id)
|
||||
}
|
||||
|
||||
func stateCheckExampleDisappears(name string) statecheck.StateCheck {
|
||||
return exampleDisappearsCheck{resourceAddress: name}
|
||||
}
|
||||
```
|
||||
|
||||
### State Resource Lookup (shared utility)
|
||||
|
||||
```go
|
||||
func stateResourceAtAddress(state *tfjson.State, address string) (*tfjson.StateResource, error) {
|
||||
if state == nil || state.Values == nil || state.Values.RootModule == nil {
|
||||
return nil, fmt.Errorf("no state available")
|
||||
}
|
||||
for _, r := range state.Values.RootModule.Resources {
|
||||
if r.Address == address {
|
||||
return r, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("not found in state: %s", address)
|
||||
}
|
||||
```
|
||||
|
||||
### Destroy Check (TestCheckFunc — required by CheckDestroy)
|
||||
|
||||
```go
|
||||
func testAccCheckExampleDestroy(s *terraform.State) error {
|
||||
conn := testAccAPIClient()
|
||||
for _, rs := range s.RootModule().Resources {
|
||||
if rs.Type != "example_widget" {
|
||||
continue
|
||||
}
|
||||
_, err := conn.GetWidget(rs.Primary.ID)
|
||||
if err == nil {
|
||||
return fmt.Errorf("widget %s still exists", rs.Primary.ID)
|
||||
}
|
||||
if !isNotFoundError(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### PreCheck
|
||||
|
||||
```go
|
||||
func testAccPreCheck(t *testing.T) {
|
||||
t.Helper()
|
||||
if os.Getenv("EXAMPLE_API_KEY") == "" {
|
||||
t.Fatal("EXAMPLE_API_KEY must be set for acceptance tests")
|
||||
}
|
||||
}
|
||||
```
|
||||
231
.agents/skills/provider-test-patterns/references/checks.md
Normal file
231
.agents/skills/provider-test-patterns/references/checks.md
Normal file
@@ -0,0 +1,231 @@
|
||||
# State Checks and Plan Checks Reference
|
||||
|
||||
Detailed reference for `statecheck` and `plancheck` packages from
|
||||
`terraform-plugin-testing`. Read this when writing assertions for test steps.
|
||||
|
||||
Source: [State Checks](https://developer.hashicorp.com/terraform/plugin/testing/acceptance-tests/state-checks/resource),
|
||||
[Plan Checks](https://developer.hashicorp.com/terraform/plugin/testing/acceptance-tests/plan-checks)
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [State Checks](#state-checks)
|
||||
2. [Known Value Types](#known-value-types)
|
||||
3. [tfjsonpath Navigation](#tfjsonpath-navigation)
|
||||
4. [Value Comparers](#value-comparers)
|
||||
5. [Plan Checks](#plan-checks)
|
||||
|
||||
---
|
||||
|
||||
## State Checks
|
||||
|
||||
Use via `ConfigStateChecks` field on `TestStep`. All assertion errors are
|
||||
aggregated and reported together.
|
||||
|
||||
### ExpectKnownValue
|
||||
|
||||
Assert an attribute has a specific type and value:
|
||||
|
||||
```go
|
||||
statecheck.ExpectKnownValue("example_widget.test",
|
||||
tfjsonpath.New("name"),
|
||||
knownvalue.StringExact("my-widget"))
|
||||
```
|
||||
|
||||
### ExpectSensitiveValue
|
||||
|
||||
Assert an attribute is marked sensitive (requires Terraform 1.4.6+):
|
||||
|
||||
```go
|
||||
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
|
||||
tfversion.SkipBelow(tfversion.Version1_4_6),
|
||||
},
|
||||
// ...
|
||||
statecheck.ExpectSensitiveValue("example_widget.test",
|
||||
tfjsonpath.New("api_key"))
|
||||
```
|
||||
|
||||
### CompareValue
|
||||
|
||||
Compare the same attribute across sequential test steps:
|
||||
|
||||
```go
|
||||
compareValuesSame := statecheck.CompareValue(compare.ValuesSame())
|
||||
|
||||
Steps: []resource.TestStep{
|
||||
{
|
||||
Config: testAccConfig_v1(rName),
|
||||
ConfigStateChecks: []statecheck.StateCheck{
|
||||
compareValuesSame.AddStateValue("example_widget.test",
|
||||
tfjsonpath.New("id")),
|
||||
},
|
||||
},
|
||||
{
|
||||
Config: testAccConfig_v2(rName),
|
||||
ConfigStateChecks: []statecheck.StateCheck{
|
||||
compareValuesSame.AddStateValue("example_widget.test",
|
||||
tfjsonpath.New("id")),
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
### CompareValuePairs
|
||||
|
||||
Compare attributes between two resources:
|
||||
|
||||
```go
|
||||
statecheck.CompareValuePairs(
|
||||
"example_widget.test", tfjsonpath.New("vpc_id"),
|
||||
"example_vpc.test", tfjsonpath.New("id"),
|
||||
compare.ValuesSame())
|
||||
```
|
||||
|
||||
### CompareValueCollection
|
||||
|
||||
Check if a value exists in a collection attribute:
|
||||
|
||||
```go
|
||||
statecheck.CompareValueCollection(
|
||||
"example_widget.test", tfjsonpath.New("tags"),
|
||||
"example_widget.test", tfjsonpath.New("name"),
|
||||
compare.ValuesSame())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Value Types
|
||||
|
||||
Use with `ExpectKnownValue` to assert attribute values:
|
||||
|
||||
| Type | Example |
|
||||
|------|---------|
|
||||
| `knownvalue.StringExact("value")` | Exact string match |
|
||||
| `knownvalue.StringRegexp(regexp.MustCompile(`^arn:`))` | Regex match |
|
||||
| `knownvalue.Bool(true)` | Boolean value |
|
||||
| `knownvalue.Int64Exact(42)` | Exact int64 |
|
||||
| `knownvalue.Float64Exact(3.14)` | Exact float64 |
|
||||
| `knownvalue.NotNull()` | Value is set (not null) |
|
||||
| `knownvalue.Null()` | Value is null |
|
||||
| `knownvalue.ListExact([]knownvalue.Check{...})` | Exact list match |
|
||||
| `knownvalue.ListPartial(map[int]knownvalue.Check{0: ...})` | Partial list match |
|
||||
| `knownvalue.ListSizeExact(3)` | List has N elements |
|
||||
| `knownvalue.SetExact([]knownvalue.Check{...})` | Exact set match |
|
||||
| `knownvalue.SetPartial([]knownvalue.Check{...})` | Set contains items |
|
||||
| `knownvalue.SetSizeExact(2)` | Set has N elements |
|
||||
| `knownvalue.MapExact(map[string]knownvalue.Check{...})` | Exact map match |
|
||||
| `knownvalue.MapPartial(map[string]knownvalue.Check{...})` | Map contains keys |
|
||||
| `knownvalue.MapSizeExact(1)` | Map has N keys |
|
||||
| `knownvalue.ObjectExact(map[string]knownvalue.Check{...})` | Exact object match |
|
||||
| `knownvalue.ObjectPartial(map[string]knownvalue.Check{...})` | Object has attributes |
|
||||
| `knownvalue.Float32Exact(1.5)` | Exact float32 |
|
||||
| `knownvalue.Int32Exact(42)` | Exact int32 |
|
||||
| `knownvalue.NumberExact(big.NewFloat(42))` | Exact number (`*big.Float`) |
|
||||
| `knownvalue.TupleExact([]knownvalue.Check{...})` | Exact tuple match |
|
||||
| `knownvalue.TuplePartial(map[int]knownvalue.Check{0: ...})` | Partial tuple match |
|
||||
| `knownvalue.TupleSizeExact(3)` | Tuple has N elements |
|
||||
|
||||
### Nested Value Example
|
||||
|
||||
```go
|
||||
statecheck.ExpectKnownValue("example_widget.test",
|
||||
tfjsonpath.New("settings"),
|
||||
knownvalue.ObjectExact(map[string]knownvalue.Check{
|
||||
"mode": knownvalue.StringExact("production"),
|
||||
"enabled": knownvalue.Bool(true),
|
||||
}))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## tfjsonpath Navigation
|
||||
|
||||
Navigate nested attributes in state:
|
||||
|
||||
```go
|
||||
tfjsonpath.New("attribute") // top-level attribute
|
||||
tfjsonpath.New("block").AtMapKey("key") // nested map/object key
|
||||
tfjsonpath.New("list_attr").AtSliceIndex(0) // list element by index
|
||||
tfjsonpath.New("block").AtMapKey("nested").AtMapKey("deep") // deep nesting
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Value Comparers
|
||||
|
||||
Use with `CompareValue`, `CompareValuePairs`, `CompareValueCollection`:
|
||||
|
||||
| Comparer | Purpose |
|
||||
|----------|---------|
|
||||
| `compare.ValuesSame()` | Values are identical |
|
||||
| `compare.ValuesDiffer()` | Values are different |
|
||||
|
||||
---
|
||||
|
||||
## Plan Checks
|
||||
|
||||
Use via `ConfigPlanChecks` or `RefreshPlanChecks` on `TestStep`. Plan checks
|
||||
inspect the plan file at specific phases.
|
||||
|
||||
### ConfigPlanChecks Phases
|
||||
|
||||
```go
|
||||
ConfigPlanChecks: resource.ConfigPlanChecks{
|
||||
PreApply: []plancheck.PlanCheck{...}, // after plan, before apply
|
||||
PostApplyPreRefresh: []plancheck.PlanCheck{...}, // after apply, before refresh
|
||||
PostApplyPostRefresh: []plancheck.PlanCheck{...}, // after refresh
|
||||
},
|
||||
```
|
||||
|
||||
### Built-in Plan Checks
|
||||
|
||||
```go
|
||||
// Expect no changes in plan
|
||||
plancheck.ExpectEmptyPlan()
|
||||
|
||||
// Expect changes in plan
|
||||
plancheck.ExpectNonEmptyPlan()
|
||||
|
||||
// Expect specific resource action
|
||||
plancheck.ExpectResourceAction("example_widget.test", plancheck.ResourceActionCreate)
|
||||
plancheck.ExpectResourceAction("example_widget.test", plancheck.ResourceActionUpdate)
|
||||
plancheck.ExpectResourceAction("example_widget.test", plancheck.ResourceActionDestroy)
|
||||
plancheck.ExpectResourceAction("example_widget.test", plancheck.ResourceActionNoop)
|
||||
|
||||
// Expect known plan value
|
||||
plancheck.ExpectKnownValue("example_widget.test",
|
||||
tfjsonpath.New("name"),
|
||||
knownvalue.StringExact("my-widget"))
|
||||
|
||||
// Expect unknown (computed) value in plan
|
||||
plancheck.ExpectUnknownValue("example_widget.test",
|
||||
tfjsonpath.New("computed_field"))
|
||||
|
||||
// Expect sensitive value in plan
|
||||
plancheck.ExpectSensitiveValue("example_widget.test",
|
||||
tfjsonpath.New("api_key"))
|
||||
```
|
||||
|
||||
### No-Op After Update Example
|
||||
|
||||
Verify that updating a config back to original values produces no diff:
|
||||
|
||||
```go
|
||||
Steps: []resource.TestStep{
|
||||
{
|
||||
Config: testAccConfig_basic(rName),
|
||||
},
|
||||
{
|
||||
Config: testAccConfig_updated(rName),
|
||||
},
|
||||
{
|
||||
Config: testAccConfig_basic(rName),
|
||||
ConfigPlanChecks: resource.ConfigPlanChecks{
|
||||
PreApply: []plancheck.PlanCheck{
|
||||
plancheck.ExpectEmptyPlan(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
208
.agents/skills/provider-test-patterns/references/ephemeral.md
Normal file
208
.agents/skills/provider-test-patterns/references/ephemeral.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# Ephemeral Resource Testing Reference
|
||||
|
||||
Testing patterns for ephemeral resources using `terraform-plugin-testing`.
|
||||
Ephemeral resources reference external data without persisting it to plan or
|
||||
state artifacts, which means standard plan checks and state checks cannot
|
||||
directly assert on ephemeral resource data.
|
||||
|
||||
Source: [Ephemeral Resource Acceptance Tests](https://developer.hashicorp.com/terraform/plugin/testing/acceptance-tests/ephemeral-resources)
|
||||
|
||||
**Requires Terraform >= 1.10.0** — gate all ephemeral tests with
|
||||
`tfversion.SkipBelow(tfversion.Version1_10_0)`.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Testing Approaches](#testing-approaches)
|
||||
2. [Direct Integration Testing](#direct-integration-testing)
|
||||
3. [Echo Provider Pattern](#echo-provider-pattern)
|
||||
4. [Multi-Step Testing](#multi-step-testing)
|
||||
|
||||
---
|
||||
|
||||
## Testing Approaches
|
||||
|
||||
Two strategies for testing ephemeral resources:
|
||||
|
||||
| Approach | When to use |
|
||||
|----------|-------------|
|
||||
| **Direct integration** | Verify the ephemeral resource successfully provides data to a dependent resource or provider |
|
||||
| **Echo provider** | Assert on specific attribute values using `ConfigStateChecks` via the `echoprovider` package |
|
||||
|
||||
---
|
||||
|
||||
## Direct Integration Testing
|
||||
|
||||
Test that an ephemeral resource successfully provides data to a dependent
|
||||
resource. No direct assertions on ephemeral data — the test passes if the
|
||||
dependent resource applies cleanly.
|
||||
|
||||
```go
|
||||
func TestExampleCloudSecret_DnsKerberos(t *testing.T) {
|
||||
resource.UnitTest(t, resource.TestCase{
|
||||
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
|
||||
tfversion.SkipBelow(tfversion.Version1_10_0),
|
||||
},
|
||||
ExternalProviders: map[string]resource.ExternalProvider{
|
||||
"dns": {
|
||||
Source: "hashicorp/dns",
|
||||
},
|
||||
},
|
||||
ProtoV5ProviderFactories: map[string]func() (tfprotov5.ProviderServer, error){
|
||||
"examplecloud": providerserver.NewProtocol5WithError(New()),
|
||||
},
|
||||
Steps: []resource.TestStep{
|
||||
{
|
||||
Config: `
|
||||
ephemeral "examplecloud_secret" "krb" {
|
||||
name = "example_kerberos_user"
|
||||
}
|
||||
|
||||
provider "dns" {
|
||||
update {
|
||||
server = "ns.example.com"
|
||||
gssapi {
|
||||
realm = ephemeral.examplecloud_secret.krb.secret_data.realm
|
||||
username = ephemeral.examplecloud_secret.krb.secret_data.username
|
||||
password = ephemeral.examplecloud_secret.krb.secret_data.password
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "dns_a_record_set" "record_set" {
|
||||
zone = "example.com."
|
||||
addresses = ["192.168.0.1", "192.168.0.2", "192.168.0.3"]
|
||||
}
|
||||
`,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Echo Provider Pattern
|
||||
|
||||
The `echoprovider` package (Protocol V6) captures ephemeral data into a
|
||||
managed resource's state, making it assertable with standard
|
||||
`ConfigStateChecks`.
|
||||
|
||||
### Setup
|
||||
|
||||
Register both your provider and the echo provider:
|
||||
|
||||
```go
|
||||
import (
|
||||
"github.com/hashicorp/terraform-plugin-testing/echoprovider"
|
||||
)
|
||||
|
||||
func TestExampleCloudSecret(t *testing.T) {
|
||||
resource.UnitTest(t, resource.TestCase{
|
||||
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
|
||||
tfversion.SkipBelow(tfversion.Version1_10_0),
|
||||
},
|
||||
ProtoV5ProviderFactories: map[string]func() (tfprotov5.ProviderServer, error){
|
||||
"examplecloud": providerserver.NewProtocol5WithError(New()),
|
||||
},
|
||||
ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){
|
||||
"echo": echoprovider.NewProviderServer(),
|
||||
},
|
||||
Steps: []resource.TestStep{
|
||||
// test configurations
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Config Pattern
|
||||
|
||||
Pass ephemeral data to the echo provider's `data` attribute, then assert on
|
||||
the `echo` managed resource:
|
||||
|
||||
```terraform
|
||||
ephemeral "examplecloud_secret" "krb" {
|
||||
name = "example_kerberos_user"
|
||||
}
|
||||
|
||||
provider "echo" {
|
||||
data = ephemeral.examplecloud_secret.krb.secret_data
|
||||
}
|
||||
|
||||
resource "echo" "test_krb" {}
|
||||
```
|
||||
|
||||
### State Assertions
|
||||
|
||||
Assert on the echo resource's `data` attribute using standard state checks:
|
||||
|
||||
```go
|
||||
Steps: []resource.TestStep{
|
||||
{
|
||||
Config: `...`,
|
||||
ConfigStateChecks: []statecheck.StateCheck{
|
||||
statecheck.ExpectKnownValue("echo.test_krb",
|
||||
tfjsonpath.New("data").AtMapKey("realm"),
|
||||
knownvalue.StringExact("EXAMPLE.COM")),
|
||||
statecheck.ExpectKnownValue("echo.test_krb",
|
||||
tfjsonpath.New("data").AtMapKey("username"),
|
||||
knownvalue.StringExact("john-doe")),
|
||||
statecheck.ExpectKnownValue("echo.test_krb",
|
||||
tfjsonpath.New("data").AtMapKey("password"),
|
||||
knownvalue.StringRegexp(regexp.MustCompile(`^.{12}$`))),
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Multi-Step Testing
|
||||
|
||||
The echo resource has special behavior to accommodate ephemeral data
|
||||
variability:
|
||||
|
||||
- During planning for new resources, the `data` attribute is marked unknown
|
||||
- Existing echo resources preserve prior state regardless of config changes
|
||||
- Refresh operations always return prior state
|
||||
|
||||
Because of this, **create new echo resource instances for each test step**
|
||||
rather than reusing the same one:
|
||||
|
||||
```go
|
||||
Steps: []resource.TestStep{
|
||||
{
|
||||
Config: `
|
||||
ephemeral "examplecloud_secret" "krb" {
|
||||
name = "user_one"
|
||||
}
|
||||
provider "echo" {
|
||||
data = ephemeral.examplecloud_secret.krb
|
||||
}
|
||||
resource "echo" "test_krb_one" {}
|
||||
`,
|
||||
ConfigStateChecks: []statecheck.StateCheck{
|
||||
statecheck.ExpectKnownValue("echo.test_krb_one",
|
||||
tfjsonpath.New("data").AtMapKey("name"),
|
||||
knownvalue.StringExact("user_one")),
|
||||
},
|
||||
},
|
||||
{
|
||||
Config: `
|
||||
ephemeral "examplecloud_secret" "krb" {
|
||||
name = "user_two"
|
||||
}
|
||||
provider "echo" {
|
||||
data = ephemeral.examplecloud_secret.krb
|
||||
}
|
||||
resource "echo" "test_krb_two" {}
|
||||
`,
|
||||
ConfigStateChecks: []statecheck.StateCheck{
|
||||
statecheck.ExpectKnownValue("echo.test_krb_two",
|
||||
tfjsonpath.New("data").AtMapKey("name"),
|
||||
knownvalue.StringExact("user_two")),
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
101
.agents/skills/provider-test-patterns/references/sweepers.md
Normal file
101
.agents/skills/provider-test-patterns/references/sweepers.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Test Sweepers Reference
|
||||
|
||||
Sweepers clean up infrastructure resources that leak during acceptance tests —
|
||||
when test infrastructure fails to be destroyed due to API errors or test
|
||||
failures.
|
||||
|
||||
Source: [Sweepers](https://developer.hashicorp.com/terraform/plugin/testing/acceptance-tests/sweepers)
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
### TestMain (required)
|
||||
|
||||
Add to a dedicated file (e.g., `sweep_test.go`):
|
||||
|
||||
```go
|
||||
func TestMain(m *testing.M) {
|
||||
resource.TestMain(m)
|
||||
}
|
||||
```
|
||||
|
||||
This parses the `-sweep` flag and invokes registered sweepers.
|
||||
|
||||
### Register a Sweeper
|
||||
|
||||
Register in the test file for the resource being swept, using `init()`:
|
||||
|
||||
```go
|
||||
func init() {
|
||||
resource.AddTestSweepers("example_widget", &resource.Sweeper{
|
||||
Name: "example_widget",
|
||||
F: sweepWidgets,
|
||||
})
|
||||
}
|
||||
|
||||
func sweepWidgets(region string) error {
|
||||
client, err := sharedClientForRegion(region)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting client: %w", err)
|
||||
}
|
||||
|
||||
conn := client.(*Client)
|
||||
widgets, err := conn.ListWidgets()
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing widgets: %w", err)
|
||||
}
|
||||
|
||||
for _, w := range widgets {
|
||||
if !strings.HasPrefix(w.Name, "test-acc") {
|
||||
continue
|
||||
}
|
||||
if err := conn.DeleteWidget(w.ID); err != nil {
|
||||
log.Printf("[WARN] Failed to delete widget %s: %s", w.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
Use a consistent test name prefix (e.g., `"test-acc"`) to identify
|
||||
test-created resources.
|
||||
|
||||
### Dependencies
|
||||
|
||||
When resources have ordering requirements (e.g., child resources must be
|
||||
deleted before parents), the **parent** sweeper declares children as
|
||||
dependencies so they run first:
|
||||
|
||||
```go
|
||||
resource.AddTestSweepers("example_widget", &resource.Sweeper{
|
||||
Name: "example_widget",
|
||||
Dependencies: []string{"example_widget_child"},
|
||||
F: sweepWidgets,
|
||||
})
|
||||
```
|
||||
|
||||
Dependencies run **before** the sweeper that declares them. In this example,
|
||||
`example_widget_child` is swept first, then `example_widget`.
|
||||
|
||||
### Shared Client
|
||||
|
||||
Create a helper to build an API client for the sweep region:
|
||||
|
||||
```go
|
||||
func sharedClientForRegion(region string) (any, error) {
|
||||
// Build and return a configured API client
|
||||
return NewClient(region)
|
||||
}
|
||||
```
|
||||
|
||||
## Running Sweepers
|
||||
|
||||
```bash
|
||||
# Run all sweepers for a region
|
||||
TF_ACC=1 go test ./internal/service/example -sweep=us-east-1 -v
|
||||
|
||||
# Makefile target (common convention)
|
||||
make sweep
|
||||
```
|
||||
Reference in New Issue
Block a user