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.
415 lines
13 KiB
Markdown
415 lines
13 KiB
Markdown
---
|
|
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")
|
|
}
|
|
}
|
|
```
|