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:
2026-08-09 12:17:26 +03:00
commit a6d8aa8b52
160 changed files with 24260 additions and 0 deletions

View 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")
}
}
```

View 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(),
},
},
},
},
```

View 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")),
},
},
},
```

View 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
```