Skip to content

proposal: public a2atest testing package #310

Description

@hairglasses

Proposal: Public a2atest package for SDK consumers

Motivation

The a2a-go SDK has excellent internal test infrastructure in internal/testutil/
and internal/testutil/testexecutor/. However, these packages are not accessible
to SDK consumers, which means everyone building A2A agents in Go must create
their own test mocks, in-process servers, and assertion helpers from scratch.

This is the single most common pain point reported by Go SDK adopters. The
Python SDK provides a2a.testing with mock servers and test clients. The JS SDK
has similar utilities. The Go SDK is the only major A2A SDK without public test
helpers.

Current internal test infrastructure

The SDK already has well-designed test utilities that cover the core testing needs:

Internal package Contents Consumers
internal/testutil TestTaskStore, TestPushConfigStore, TestPushSender, TestEventQueue, TestQueueManager, TestWorkQueue, test logger a2asrv handler tests, internal/taskexec tests
internal/testutil/testexecutor TestAgentExecutor with control channels, event generators, functional constructors e2e tests, handler tests

These utilities follow a consistent pattern: they embed the real implementation
and allow individual method overrides via function fields. This is a clean,
composable design that SDK consumers would benefit from directly.

Proposed public API

A new top-level a2atest package (following the convention of a2aclient,
a2asrv, a2aext):

package a2atest

// --- In-process test server ---

// Server is an in-process A2A server for integration testing.
// It starts an httptest.Server with JSON-RPC and REST handlers wired up.
type Server struct { /* ... */ }

// NewServer creates an in-process A2A test server with the given executor
// and options. The server is automatically cleaned up when the test finishes.
func NewServer(t testing.TB, executor a2asrv.AgentExecutor, opts ...ServerOption) *Server

// URL returns the base URL of the test server.
func (s *Server) URL() string

// Card returns the AgentCard for the test server.
func (s *Server) Card() *a2a.AgentCard

// Client returns a pre-configured client connected to the test server.
func (s *Server) Client(t testing.TB) *a2aclient.Client

// Handler returns the underlying RequestHandler for direct handler testing.
func (s *Server) Handler() a2asrv.RequestHandler

// ServerOption configures the test server.
type ServerOption func(*serverConfig)

// WithTaskStore configures the test server with a custom task store.
func WithTaskStore(store taskstore.Store) ServerOption

// WithCapabilities sets the agent capabilities on the test server's AgentCard.
func WithCapabilities(caps a2a.AgentCapabilities) ServerOption

// WithSkills sets the skills on the test server's AgentCard.
func WithSkills(skills []a2a.AgentSkill) ServerOption

// WithCallInterceptors adds call interceptors to the test server.
func WithCallInterceptors(interceptors ...a2asrv.CallInterceptor) ServerOption

// --- Mock executor ---

// Executor is a configurable mock AgentExecutor for testing.
// It embeds the internal TestAgentExecutor functionality.
type Executor struct { /* ... */ }

// NewExecutor creates a mock executor. Without configuration, it returns
// an empty event sequence.
func NewExecutor() *Executor

// FromFunc creates an Executor from a function.
func FromFunc(fn func(ctx context.Context, ec *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error]) *Executor

// FromEvents creates an Executor that emits a fixed sequence of events.
func FromEvents(fn func(execCtx *a2asrv.ExecutorContext) []a2a.Event) *Executor

// Emitted returns all events emitted by the executor (thread-safe).
func (e *Executor) Emitted() []a2a.Event

// WithControlChannels returns an Executor with channels for controlling
// execution timing in race condition tests.
func WithControlChannels() (*Executor, *ControlChannels)

// ControlChannels provides channels for controlling executor behavior.
type ControlChannels struct {
    ReqCtx         <-chan *a2asrv.ExecutorContext
    ExecEvent      chan<- a2a.Event
    CancelCalled   <-chan struct{}
    ContinueCancel chan<- struct{}
}

// --- Mock task store ---

// TaskStore is a mock task store that delegates to InMemory by default.
// Individual operations can be overridden.
type TaskStore struct { /* ... */ }

// NewTaskStore creates a mock task store backed by in-memory storage.
func NewTaskStore() *TaskStore

// SetSaveError overrides Create and Update to return the given error.
func (s *TaskStore) SetSaveError(err error) *TaskStore

// SetGetOverride overrides Get to return the given task and error.
func (s *TaskStore) SetGetOverride(task *taskstore.StoredTask, err error) *TaskStore

// WithTasks seeds the store with the given tasks.
func (s *TaskStore) WithTasks(t testing.TB, tasks ...*a2a.Task) *TaskStore

// --- Test logger ---

// NewLogger returns an slog.Logger that directs output to t.Log().
// Output is only printed for failed tests or when running with -v.
func NewLogger(t testing.TB) *slog.Logger

// SetDefaultLogger calls slog.SetDefault with a test logger and restores
// the original logger on test cleanup.
func SetDefaultLogger(t testing.TB)

Design principles

  1. Export what already works: The internal test infrastructure is battle-tested.
    The public API should be a thin wrapper, not a rewrite.
  2. Zero configuration for common cases: a2atest.NewServer(t, executor) should
    work with no options for the 80% case.
  3. Composable for advanced cases: Options for custom stores, interceptors,
    and capabilities cover the remaining 20%.
  4. Test cleanup via t.Cleanup(): All resources are automatically cleaned up
    when the test finishes. No manual defer server.Close() needed.
  5. Follow the SDK's own patterns: Use t.Helper(), cmp.Diff(),
    t.Parallel(), and the same error formatting conventions documented in AGENTS.md.

Relationship to existing internal packages

The a2atest package would import from internal/testutil initially via
package-level delegation, then gradually replace the internal package as the
public API stabilizes. The internal package can eventually become a thin re-export
or be removed entirely once all internal tests migrate to the public API.

Alternatively, if the maintainers prefer, the internal utilities can be moved
directly to the public package in a single refactor. I am happy to go either
direction.

Implementation plan

I am happy to implement this. Proposed approach:

Phase 1 (1 PR): Create a2atest/ package with:

  • Server (in-process server with JSON-RPC handler)
  • Executor (wrapping testexecutor.TestAgentExecutor)
  • TaskStore (wrapping testutil.TestTaskStore)
  • NewLogger and SetDefaultLogger
  • Tests and doc.go with package documentation
  • Example test showing the full pattern

Phase 2 (follow-up PR): Add:

  • REST transport support in test server
  • Assertion helpers (AssertTaskState, AssertArtifacts, AssertEvents)
  • Push notification test infrastructure (PushConfigStore, PushSender)
  • Migration of select internal tests to use the public API

Phase 3 (optional, based on feedback): Add:

  • gRPC transport support in test server
  • Test fixtures (pre-built AgentCards, common event sequences)
  • Benchmarking helpers

Backward compatibility

This is a purely additive change. No existing APIs are modified. The new package
can be imported independently:

import "github.com/a2aproject/a2a-go/v2/a2atest"

Context

I have an open PR (#309) fixing a race condition in task execution cleanup, which gave me familiarity with the internal test infrastructure and motivated this proposal.

Questions for maintainers

  1. Does this align with your vision for the SDK's public API surface?
  2. Do you prefer the public package to delegate to internal/testutil or to
    directly move the internal code?
  3. Are there additional test utilities you've wished existed when writing the
    SDK's own tests?
  4. Any naming preferences? (a2atest vs testutil vs testing)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions