DEV Community

budment
budment

Posted on

What if API testing worked like Terraform? (An experiment in Go)

I’ve been learning Go recently and wanted to try a Terraform-like workflow for API testing: write scenarios as code, compile them into a static plan, then execute that plan.

So I built a small project. You define workflows in TypeScript, run budment plan to inspect the execution hierarchy and hooks without making network requests, then execute the compiled AST natively in Go.

“Scenario-as-code” isn’t new, but I’m interested in whether a plan-first approach can make test suites easier to inspect, audit, and maintain.

I’m still getting comfortable with Go, so I’d appreciate any feedback on the architecture, concurrency, or anything that looks questionable.

Repo: https://github.com/budment/budment
Docs: https://budment.com

Top comments (4)

Collapse
 
raknaos profile image
Raknaos •

Compiling to a static plan and executing a tree instead of replaying scripts is what makes Terraform tolerable, and applying it to API flows is a good use of the idea — being able to read the hierarchy and hooks before any network call is exactly what a lot of request collections can't do.

The question I'd have is the plan-then-apply half you don't get for free: what happens when the plan is stale. If step four depends on an id from step two, either you carry state forward, which turns the static plan into a template, or you re-plan, which means the thing you reviewed isn't quite the thing that ran. Do you persist plans as artifacts so CI can diff one run against the next, and how do you clean up resources a half-finished apply leaves behind? Those two are what made me give up on a similar design once.

Collapse
 
budment profile image
budment •

Fair points. Here is how it works under the hood:

1. Dynamic State: You're right, it does turn into a template. In the plan phase, ${get('id')} compiles into a {{id}} token and gets split into static and variable chunks. At runtime, Go just loops over those chunks and pulls the value from the worker's scope map into a strings.Builder (implemented here if you're curious). So there is no re-planning or JS execution needed to resolve variables.

2. Plan Artifacts: I don't persist plans right now because evaluating the TS scenario in-memory only takes milliseconds. That said, saving a plan artifact so CI can diff changes between runs makes a lot of sense, and I'll look into adding it.

3. Cleanup: Haven't solved this yet. A failed or interrupted run will leave orphaned data behind right now. Adding a guaranteed teardown step (even on cancellation) is something I need to add.

Thanks for pointing these out — really useful feedback.

Collapse
 
budment profile image
budment • • Edited

Hey @raknaos, if you have a minute, I'd love to get your thoughts on this!

Your feedback was really helpful. I’ve pushed some updates in PR #10 and clarified the plan/runtime boundary a bit more documented here.

1. URL Skeleton

The route structure stays static in the plan, while {{}} tokens are resolved dynamically at runtime:

http.get(`/api/users/${get('userId')}/orders?page=${get('page')}&limit=${get('limit')}`)
    .before(
        set("userId", random.integer(1, 100)), // Pure Go
        req => {                               // JS hook
            log(req.getTarget());             // /api/users/5/orders?page={{page}}&limit={{limit}}
            set("page", "1");
            set("limit", "20");
            log(req.getTarget());             // /api/users/5/orders?page=1&limit=20
        }
    )
Enter fullscreen mode Exit fullscreen mode

So budment plan --detail can show the complete skeleton before any network request:

└── [HTTP] GET /api/users/{{userId}}/orders?page={{page}}&limit={{limit}}
    └── [BEFORE]
        ├── [SET] userId = {{@random:int:1:100}}
        └── [SCRIPT] http_1_before
Enter fullscreen mode Exit fullscreen mode

At runtime, the placeholders are resolved as the scope changes.

2. Teardown

I also added a dedicated teardown phase to handle cleanup.

The idea was to get some of the benefits of a Terraform-like plan — predictable execution and lower RAM/GC overhead for routine tasks — while still keeping the DX flexible for dynamic logic. That's what led me to this hybrid approach.
I'm still exploring the trade-offs here. Does this approach make more sense, or do you see any obvious blind spots?

Some comments may only be visible to logged-in visitors. Sign in to view all comments.