Skip to main content

Local tests

github.com/graphene-ci/pipeline/pkg/pipelinetest attaches a Graphene contract simulator to go.temporal.io/sdk/testsuite.TestWorkflowEnvironment. Run the real pipeline function with typed inputs, fixtures for external state, and assertions on both its result and its resources. No Graphene server, Docker daemon, Kubernetes cluster, cloud account or host shell is needed.

Install​

Add the SDK and the adapters your pipeline uses to its Go module:

go get github.com/graphene-ci/pipeline@v0.2.0
go get github.com/graphene-ci/library/docker@v0.2.0
go get github.com/graphene-ci/library/k8s@v0.2.0

The adapters are packages inside their library modules; they have no separate module versions. Tests work with published dependencies without a local workspace.

A complete test​

package report

import (
"testing"
"time"

"github.com/graphene-ci/pipeline/pkg/artifact"
"github.com/graphene-ci/pipeline/pkg/pipeline"
"github.com/graphene-ci/pipeline/pkg/pipelinetest"
"github.com/stretchr/testify/require"
"go.temporal.io/sdk/testsuite"
)

func run(ctx pipeline.Context, text string) (pipeline.ArtifactState, error) {
report := pipeline.NewArtifact(ctx, "report", artifact.FromBytes([]byte(text)))
state := report.Ready(ctx)
pipeline.ToStand(ctx, report, pipeline.KeepFor(time.Hour))
return state, nil
}

func TestReport(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
world := pipelinetest.Install(t, env)
wf := pipelinetest.Workflow(world, "report", run)

env.ExecuteWorkflow(wf, "benchmark passed")
require.NoError(t, env.GetWorkflowError())
var result pipeline.ArtifactState
require.NoError(t, env.GetWorkflowResult(&result))
content, found := world.Blob(result.Blob)
require.True(t, found)
require.Equal(t, "benchmark passed", string(content))
world.AssertOwner(t, "artifact/report", "stand/report")
world.AssertNoLeaks(t)

world.Advance(time.Hour)
record, found := world.Resource("artifact/report")
require.True(t, found)
require.Equal(t, "deleted", record.Phase)
}

The wrapper and cleanup interceptor are shared with pipeline.Main. Parameter validation, Ready failures, cancellation and cleanup therefore use the same pipeline code. Create a new environment and world for every test; independent worlds support t.Parallel().

Activities and agents​

Configure fixtures, install library adapters, then call pipelinetest.Workflow. Preparation discovers activity signatures through the normal recording pass. After preparation, set mocks and execute the workflow.

world.ConnectAfter("bare-1", time.Second)
world.File("bare-1", "/var/log/perf/report.tgz", []byte("fixture content"))
world.SeedArtifact("baseline-report", []byte("baseline"))

wf := pipelinetest.Workflow(world, "perf-nightly", run)
world.OnAgentActivity("bare-1", "run-work", mock.Anything, params.Work).
Return("benchmark passed", nil).
Once()

env.ExecuteWorkflow(wf, params)
env.AssertExpectations(t)

OnAgentActivity uses Temporal's mock argument convention, including the activity context as the first argument. It distinguishes the same activity on different agents. Ordinary env.OnActivity also works for user activities. An activity without a replacement fails with UnmockedActivity; its discovered production body is never called. Explicitly registering a replacement on env is an opt-in to executing that replacement.

ConnectAfter only connects an identity. The pipeline must still declare its resource. SeedAgent instead supplies an existing foreign record for attach and selection. Disconnect, PublishCapability and FailResource can change fixtures from env.RegisterDelayedCallback callbacks. Labels and capability needs are evaluated against the world's current state; creating agents are not selected.

User activity retries remain Temporal retries. AtMostOnce keeps one attempt and classifies a timeout as pipeline.ErrUnknown, which the pipeline can handle. env.CancelWorkflow, signals and delayed callbacks keep their normal testsuite behavior. Use world.SetDataConverter when configuring a custom payload converter so both the environment and simulated results use it.

Docker and Kubernetes adapters​

Install adapters before preparing the pipeline:

dockertest.Install(world, "28.5.2")
objects := k8stest.Install(world)

Imports are github.com/graphene-ci/library/docker/dockertest and github.com/graphene-ci/library/k8s/k8stest.

A Kubernetes object gets its observed fields from objects.Set(ref, live) or, timed, objects.After(delay, ref, live). A test that does not know which objects a spec will declare answers them all at once with objects.SetDefault(func(kind, name string, manifest map[string]any) any): the function sees the declared manifest and returns the observed fields (nil — nothing observed yet); a Set for a specific object still wins.

The Docker adapter models containers, volumes and networks, preserves their native specs and flows, and returns deterministic fixture identifiers. Its docker.install handler publishes a Docker capability. Override an installation for one agent with OnAgentActivity to test a failure; a mocked failure does not publish the capability.

The Kubernetes adapter accepts native Go objects or maps. The key is the exact Graphene reference (k8s.<group>.<version>.<Kind>/<id>; namespaced ids are <namespace>.<name>). Supply observed fields with objects.Set(ref, live) or objects.After(delay, ref, live) and check the returned error. Omitted fields are taken from the declared manifest. The adapter executes the kind's real WithReady predicate with its polling interval and timeout. A pending or missing fixture does not become ready automatically. Crossplane reference resolution and provider-generated ids must be represented by fixtures.

Assertions and lifetime​

SurfaceWhat it checks or returns
Resource(ref)Detached record including owner, phase, spec, state, agent, flows and From — the run that handed it to its current owner
Resources()Every record the world knows, deleted ones included, ordered by ref
OnDeclare(fn)A hook called with each record as it is declared — connect the agent, set the object's observed state, count what a spec produced — without knowing the names in advance
Calls()Every dispatched activity with its outcome: name, queue, serialized arguments, virtual times, result or error, and a causal sequence number
Events()Declaration, readiness, transfer and deletion order
Outcome("run/test-<pipeline>")Cleanup outcome: success, failure or canceled
AssertOwner(t, ref, owner)The resource is live under the expected owner
AssertNoLeaks(t)No orphan, cycle or survivor under a completed run
Advance(duration)Advance modeled stand TTL after the root workflow completes; during the run the TTL runs on its own in virtual time

ToStand moves the root of the declared dependency tree, matching the server's stand transfer rule. A point transfer through Children only reparents that child. Cleanup deletes resources still owned by the run. Explicitly transferred survivors remain even if later pipeline code fails. Foreign fixtures are never adopted or deleted by cleanup. Deleted records remain inspectable. Blob content is removed when no live artifact in the world references it.

A stand's TTL runs in virtual time: a holding given away with KeepFor expires while the run still goes, the way it does on a real stand, so a run that sleeps past its own KeepFor sees the resource deleted. Advance is cumulative and only available after workflow completion; it covers what the run did not wait out and advances the model, not a real stand workflow.

A run that fails keeps what it collected: the workflow error is a Temporal ApplicationError of type pipeline.FailureType whose details carry the partial result and whose cause is the pipeline's own error — errors.Is and errors.As see through it, app.Details(&partial) reads the result.

Reading activity outcomes from Calls​

The simulator answers most activities itself — Handle, the library adapters, server.* — and it stands last in the interceptor chain, so no other interceptor or activity listener sees those activities end. Calls is where the outcome is read, for the simulator's own handlers and for mocks alike:

FieldMeaning
Name, TaskQueue, Args, Timethe dispatch: what, where, with which serialized arguments, at which virtual time
Seqorder of the dispatch
Donevirtual time the future settled; zero while the call is pending — a run that ended first leaves it so
DoneSeqorder of the completion, in the same counter as Seq
Resultthe JSON payload on success; empty for an activity that returns only an error
Errthe failure message, "" on success
Canceledthe future ended by cancellation (Err is set too)

Virtual time stands still while a workflow task runs, so many events carry one Time. Seq and DoneSeq share a counter precisely for that: a.DoneSeq < b.Seq means a ended before b was sent, b.Seq < a.DoneSeq means they were in flight together.

for _, call := range world.Calls() {
if call.Name == "docker.job" && call.Err != "" {
t.Logf("%s failed after %s: %s", call.Name, call.Done.Sub(call.Time), call.Err)
}
}

A dispatch is one Call whatever its retries: attempts happen under the future, and the recorded outcome is the final one. Use Temporal mock expectations to assert individual attempts.

Full example and development checkouts​

examples/full/main_test.go exercises the unchanged run function with two agents, typed Crossplane status, Docker, artifacts, flows and stand transfers. It covers success, work failure, cancellation while waiting for the VM, VM creation failure, failed Docker installation, and a missing foreign artifact. The last case deliberately retains resources already handed to the stand before the failure.

To test the released example with its pinned dependencies:

git clone --branch full/v0.1.0 https://github.com/graphene-ci/examples.git
cd examples/full
GOWORK=off go test -race ./...
GOWORK=off go run . plan

For development across unpublished SDK and library changes, clone pipeline, library and examples as sibling directories, then run:

cd examples
make configure
make test
make lint
cd full
go run . plan
go test -race ./...

configure creates an ignored local go.work pointing to those checkouts and installs pinned tools into the repository's bin/. This setup leaves the versions in go.mod unchanged. The library repository has equivalent configure, test and lint targets covering all its modules.

Verification boundary​

The simulator runs pipeline code and models service contracts. It does not run the Graphene control plane, agent processes, Kubernetes controllers, Docker containers, stand workflows or a Temporal service. Simulated service activities use workflow coroutines and virtual timers; their transport retries and heartbeats are not reproduced. User activities use normal Temporal testsuite execution and mocks.

Network flows are declarations, not connectivity checks. Telemetry delivery, secret resolution, cron/webhook admission, cross-pipeline managed runs, ContinueAsNew, visibility consistency and worker crash recovery need separate tests against the relevant components. There is no automatic fallback to live infrastructure for unsupported activities. Adapter authors can use Handle, Handle1, Declare, Ready and OnPrepare to add explicit contract simulations.

The library tests also execute the actual Docker entity init/finalize (including interrupted creation) and Kubernetes apply/heal/finalize workflows with fake external operations. These check lifecycle implementation separately from the pipeline model; they are not a full installation test.