Documentation
¶
Overview ¶
Package runtime provides an execution environment for executing changesets in tests.
The runtime package is the core testing infrastructure that manages state accumulation across multiple task executions, ensuring that each task operates on a fresh environment that reflects the cumulative state changes from previous executions. This enables comprehensive integration testing of deployment workflows where multiple changesets or operations need to be executed in sequence.
Core Concepts ¶
The runtime operates on three main concepts:
- Runtime: The main orchestrator that manages task execution and state accumulation
- Executable: An interface that represents any task that can be executed by the runtime
- State: Internal state that accumulates changes from task executions
Thread Safety ¶
The runtime is thread-safe and ensures sequential execution of tasks through mutex protection. This guarantees that state consistency is maintained even when multiple goroutines attempt to execute tasks concurrently.
State Management ¶
Each task execution follows this process:
- Execute the task against the current environment
- Task is responsible for updating the runtime state
- Generate a new environment incorporating the updated state
- Proceed to the next task
The state accumulates:
- Address book entries from changeset deployments
- DataStore updates including contract addresses and metadata
- Changeset outputs for debugging and verification
Basic Usage ¶
Here's a simple example of using the runtime to execute a changeset:
import ( "testing" "github.com/stretchr/testify/require" testenv "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/runtime" ) func TestMyDeployment(t *testing.T) { // Test environment with a simulated EVM blockchain loader := testenv.NewLoader() env, err := loader.Load(t, testenv.WithEVMSimulatedN(t, 1)) require.NoError(t, err) // Create runtime instance runtime := NewFromEnvironment(*env) // Execute a changeset task := ChangesetTask(myChangeset, MyChangesetConfig{ Parameter1: "value1", Parameter2: 42, }) err := runtime.Exec(task) require.NoError(t, err) // Verify deployment results addrs, err := runtime.State().DataStore.Addresses().Fetch() require.NoError(t, err) assert.Len(t, addrs, 1) }
Sequential Changeset Execution ¶
The runtime handles executing multiple changesets in sequence, where later changesets depend on the results of earlier ones. The environment provided to the later changesets will include the data of the previous changesets execution.
func TestMultiStepDeployment(t *testing.T) { // Load test environment with multiple EVM chains loader := testenv.NewLoader() env, err := loader.Load(t, testenv.WithEVMSimulatedN(t, 1)) require.NoError(t, err) runtime := NewFromEnvironment(*env) // Define the first changeset coreTask := ChangesetTask(coreChangeset, CoreConfig{}) // Define the dependent second changeset // The environment provided to the dependent changeset will include the data of the previous changesets execution dependentTask := ChangesetTask(dependentChangeset, DependentConfig{}) // Execute in sequence - each task sees the results of previous tasks err := runtime.Exec(coreTask, dependentTask) require.NoError(t, err) // Verify final state contains all deployed contracts addrs, err := runtime.State().DataStore.Addresses().Fetch() require.NoError(t, err) assert.Len(t, addrs, 1) }
Environment Loading Options ¶
The engine/test/environment package provides various blockchain loading options:
// EVM simulated blockchains (fast, in-memory) env, err := loader.Load(t, testenv.WithEVMSimulatedN(t, 2)) // Specific chain selectors env, err := loader.Load(t, testenv.WithEVMSimulated(t, []uint64{ chainsel.ETHEREUM_TESTNET_SEPOLIA.Selector, chainsel.POLYGON_TESTNET_MUMBAI.Selector, })) // Multiple blockchain types env, err := loader.Load(t, testenv.WithEVMSimulatedN(t, 1), testenv.WithSolanaContainerN(t, 1, "/path/to/programs", programIDs), testenv.WithTonContainerN(t, 1), ) // Custom EVM configuration cfg := onchain.EVMSimLoaderConfig{ ChainID: 1337, BlockTime: time.Second, } env, err := loader.Load(t, testenv.WithEVMSimulatedWithConfigN(t, 1, cfg))
Error Handling ¶
Task execution is atomic - if any task fails, the runtime state remains unchanged and subsequent tasks are not executed:
func TestErrorHandling(t *testing.T) { // Load test environment loader := testenv.NewLoader() env, err := loader.Load(t, testenv.WithEVMSimulatedN(t, 1)) require.NoError(t, err) runtime := NewFromEnvironment(*env) successTask := ChangesetTask(workingChangeset, WorkingConfig{}) failingTask := ChangesetTask(brokenChangeset, BrokenConfig{}) shouldNotRunTask := ChangesetTask(afterFailureChangeset, AfterFailureConfig{}) err := runtime.Exec(successTask, failingTask, shouldNotRunTask) require.Error(t, err) assert.Contains(t, err.Error(), "expected failure message") // Only the successful task's output should be in state outputs := runtime.State().Outputs assert.Contains(t, outputs, "working-changeset-output") assert.NotContains(t, outputs, "broken-changeset-output") assert.NotContains(t, outputs, "after-failure-output") }
State Inspection ¶
The runtime provides access to accumulated state for verification and debugging:
func TestStateInspection(t *testing.T) { // Load test environment loader := testenv.NewLoader() env, err := loader.Load(t, testenv.WithEVMSimulatedN(t, 1)) require.NoError(t, err) runtime := NewFromEnvironment(*env) // Execute some tasks err := runtime.Exec(task1, task2, task3) require.NoError(t, err) // Inspect final state runtimeState := runtime.State() // Check deployed addresses addrs, err := runtimeState.DataStore.Addresses().Fetch() require.NoError(t, err) // Check address book entries (legacy) addressBook := runtimeState.AddressBook // Check task execution outputs taskOutputs := runtimeState.Outputs assert.Contains(t, taskOutputs, "task1-id") assert.Contains(t, taskOutputs, "task2-id") assert.Contains(t, taskOutputs, "task3-id") // Access current environment (reflects all changes) currentEnv := runtime.Environment() finalAddrs, err := currentEnv.DataStore.Addresses().Fetch() require.NoError(t, err) assert.Equal(t, addrs, finalAddrs) }
Integration with Testing Framework ¶
The runtime integrates seamlessly with Go's testing framework and popular assertion libraries like testify:
func TestIntegrationExample(t *testing.T) { t.Parallel() // Runtime is thread-safe // Subtests can each have their own runtime instance t.Run("deployment_scenario_1", func(t *testing.T) { loader := testenv.NewLoader() env, err := loader.Load(t, testenv.WithEVMSimulatedN(t, 1)) require.NoError(t, err) runtime := NewFromEnvironment(*env) err = runtime.Exec(scenario1Tasks...) require.NoError(t, err) // scenario1-specific assertions }) t.Run("deployment_scenario_2", func(t *testing.T) { loader := testenv.NewLoader() env, err := loader.Load(t, testenv.WithEVMSimulatedN(t, 2)) require.NoError(t, err) runtime := NewFromEnvironment(*env) err = runtime.Exec(scenario2Tasks...) require.NoError(t, err) // scenario2-specific assertions }) }
Best Practices ¶
- Create a new runtime instance for each test to ensure isolation.
- Be wary when using containerized chains as they take a long time to start containers. It is better to write a longer test with a single containerized chain than to write a shorter test with multiple containerized chains.
- Verify both intermediate and final state in multi-step tests
- Leverage the runtime's error handling for negative test cases
Common Patterns ¶
## Setup Environment
func setupTestEnvironment(t *testing.T) *fdeployment.Environment { t.Helper() loader := testenv.NewLoader() env, err := loader.Load(t, testenv.WithEVMSimulatedN(t, 1)) require.NoError(t, err) return env }
## Task Factory Pattern
func DeployTokenTask(config TokenConfig) Executable { return ChangesetTask(tokenChangeset, config) }
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func ChangesetTask ¶
func ChangesetTask[C any](changeset fdeployment.ChangeSetV2[C], config C) changesetTask[C]
ChangesetTask creates a new executable task that runs a changeset. It generates a unique ID for the task and returns a task that can be executed by the runtime.
Types ¶
type Executable ¶
type Executable interface { // ID returns a unique identifier for this executable task. ID() string // Run executes the task against the provided environment and updates the state. // The environment represents the current deployment state, and the state parameter // should be updated with any changes produced by the execution. Run(e fdeployment.Environment, state *State) error }
Executable represents a task that can be executed by the runtime.
Executables are used to manipulate the runtime state and environment. The most common implementation is to run a changeset, however this could be extended to perform offchain operations in the future.
type Runtime ¶
type Runtime struct {
// contains filtered or unexported fields
}
Runtime provides an execution environment for running tasks in tests.
It manages the state accumulation across multiple task executions and ensures that each task operates on a fresh environment that reflects the cumulative state changes from previous executions.
The runtime is thread-safe and ensures sequential execution of task to maintain state consistency. Each task execution updates the internal state and regenerates the environment for subsequent executions.
func NewFromEnvironment ¶
func NewFromEnvironment(e fdeployment.Environment) *Runtime
NewFromEnvironment creates a new Runtime instance initialized with the given environment.
Initial state is seeded from the provided environment's existing addresses and datastore. A fresh environment is immediately generated and cached for the first execution.
Returns a configured Runtime ready for task execution, or an error if initialization fails.
func (*Runtime) Environment ¶
func (r *Runtime) Environment() fdeployment.Environment
Environment returns the current environment of the runtime.
func (*Runtime) Exec ¶
func (r *Runtime) Exec(executables ...Executable) error
Exec executes a sequence of tasks in order, ensuring each operates on a fresh environment that reflects the cumulative state changes from previous executions.
The execution process for each task: 1. Execute the task against the current environment (The task is responsible for updating state) 2. Generate a new environment incorporating the updated state 3. Proceed to the next task
Execution is thread-safe and atomic - if any task fails, the runtime state remains unchanged and the error is returned immediately. All tasks must succeed for the execution to be considered successful.
Returns an error if any task execution fails
type State ¶
type State struct { AddressBook fdeployment.AddressBook // Legacy address book (deprecated) DataStore fdatastore.DataStore // Datastore containing address references and metadata Outputs map[string]fdeployment.ChangesetOutput // Changeset outputs keyed by changeset ID // contains filtered or unexported fields }
State represents the mutable State of a test runtime environment. It tracks the accumulated results of changeset executions including address book updates, datastore changes, and changeset outputs. All State modifications are thread-safe through the use of a mutex.
The State is updated after each changeset execution to reflect the changes made by that changeset, allowing subsequent changesets to build upon previous results.
func (*State) MergeChangesetOutput ¶
func (s *State) MergeChangesetOutput(id string, out fdeployment.ChangesetOutput) error
MergeChangesetOutput updates the state with the results of a changeset execution. This method is thread-safe and updates all relevant state components based on the changeset output.
The update process includes: 1. Merging any address book changes from the changeset output 2. Merging any datastore changes from the changeset output 3. Storing the complete changeset output for future reference
Returns an error if the address book or datastore merge operations fail.