Testing Guide
Abbyfile has three levels of testing: unit tests for individual packages, integration tests against built binaries, and MCP bridge tests for the protocol layer.
Unit Testing Tools
CLI Tools
CLI tool tests use real system commands. The Executor runs them as subprocesses:
func TestExecutor_Run_CLI(t *testing.T) {
exec := tools.NewExecutor(5*time.Second, nil)
def := tools.CLI("echo", "echo", "Echo text")
def.Args = []string{"hello"}
result, err := exec.Run(context.Background(), def, nil)
if err != nil {
t.Fatalf("Run() error: %v", err)
}
if result != "hello" {
t.Errorf("Run() = %q, want %q", result, "hello")
}
}
Test argument passing via the args input field:
func TestExecutor_Run_WithArgs(t *testing.T) {
exec := tools.NewExecutor(5*time.Second, nil)
def := tools.CLI("echo", "echo", "Echo text")
result, err := exec.Run(context.Background(), def, map[string]any{
"args": "hello world",
})
// result == "hello world"
}
Builtin Tools
Builtin tool handlers are plain functions. Test them directly:
func TestBuiltinTool(t *testing.T) {
exec := tools.NewExecutor(5*time.Second, nil)
def := tools.BuiltinTool("test", "A test tool", nil,
func(input map[string]any) (string, error) {
return "builtin result", nil
},
)
result, err := exec.Run(context.Background(), def, nil)
// result == "builtin result"
}
Sandboxed Builtins
The shipped builtins (read_file, run_command, …) are context-aware (HandlerCtx) and read their sandbox from the context. Build a sandbox rooted at a temp dir and call HandlerCtx directly:
func TestReadFile_OutsideDenied(t *testing.T) {
root, _ := filepath.EvalSymlinks(t.TempDir()) // resolve e.g. macOS /var -> /private/var
sb, err := sandbox.New(sandbox.Config{}, root) // zero Config = sandbox.Default(): allowed_dirs ["."] = root
if err != nil {
t.Fatal(err)
}
ctx := sandbox.NewContext(context.Background(), sb)
var readFile *tools.Definition
for _, d := range builtins.All() {
if d.Name == "read_file" {
readFile = d
}
}
_, err = readFile.HandlerCtx(ctx, map[string]any{"path": "/etc/hosts"})
// err: path "/etc/hosts" is outside the allowed directories (...)
}
Calling Handler instead runs under the default sandbox with no deadline. See pkg/builtins/sandbox_helpers_test.go and files_sandbox_test.go for the helpers and the full suite.
Timeout Testing
func TestExecutor_Run_Timeout(t *testing.T) {
exec := tools.NewExecutor(100*time.Millisecond, nil)
def := tools.CLI("sleep", "sleep", "Sleep")
def.Args = []string{"10"}
_, err := exec.Run(context.Background(), def, nil)
// err contains "timed out"
}
Input Validation
Test schema validation without running the tool:
func TestValidateInput(t *testing.T) {
def := &tools.Definition{
Name: "test_tool",
InputSchema: map[string]any{
"type": "object",
"required": []any{"key"},
"properties": map[string]any{
"key": map[string]any{"type": "string"},
},
},
}
// Missing required field
err := def.ValidateInput(map[string]any{})
// err: missing required field "key"
// Wrong type
err = def.ValidateInput(map[string]any{"key": 123.0})
// err: field "key": expected string, got number
}
See pkg/tools/validate_test.go for the full table-driven test suite.
Unit Testing Memory
Use t.TempDir() for isolated file stores:
func TestMemory(t *testing.T) {
dir := filepath.Join(t.TempDir(), "memory")
store, err := memory.NewFileStoreAt(dir, memory.Limits{})
if err != nil {
t.Fatal(err)
}
store.Write("greeting", "Hello, world!")
got, _ := store.Read("greeting")
// got == "Hello, world!"
}
Test limits enforcement:
func TestMemory_MaxKeys(t *testing.T) {
dir := filepath.Join(t.TempDir(), "memory")
store, _ := memory.NewFileStoreAt(dir, memory.Limits{MaxKeys: 2})
store.Write("a", "1")
store.Write("b", "2")
err := store.Write("c", "3")
// err: key count 3 would exceed limit of 2 keys
}
Test the Manager’s built-in tool handlers:
func TestManager_HandleWrite_Read(t *testing.T) {
store, _ := memory.NewFileStoreAt(t.TempDir(), memory.Limits{})
mgr := memory.NewManager(store)
memTools := mgr.Tools()
toolMap := make(map[string]*tools.Definition)
for _, tool := range memTools {
toolMap[tool.Name] = tool
}
// Call the write handler directly
result, err := toolMap["memory_write"].Handler(map[string]any{
"key": "test",
"value": "hello world",
})
// result: "Stored 11 bytes under key \"test\""
// Call the read handler directly
result, err = toolMap["memory_read"].Handler(map[string]any{"key": "test"})
// result: "hello world"
}
Unit Testing Prompts
Use t.Setenv("HOME", ...) to isolate override behavior:
func TestLoader_Override(t *testing.T) {
tmpHome := t.TempDir()
t.Setenv("HOME", tmpHome)
overrideDir := filepath.Join(tmpHome, ".abbyfile", "test-agent")
os.MkdirAll(overrideDir, 0o755)
os.WriteFile(
filepath.Join(overrideDir, "override.md"),
[]byte("Override prompt content"),
0o644,
)
loader := prompt.NewLoader("test-agent", testFS, "testdata/system.md")
got, _ := loader.Load()
// got == "Override prompt content"
}
MCP Bridge Testing
Use gomcp.NewInMemoryTransports() for in-process client/server testing:
func TestBridge(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.BuiltinTool("echo", "Echo input",
map[string]any{
"type": "object",
"properties": map[string]any{
"message": map[string]any{"type": "string"},
},
"required": []string{"message"},
},
func(input map[string]any) (string, error) {
msg, _ := input["message"].(string)
return "echo: " + msg, nil
},
))
bridge := mcp.NewBridge(mcp.BridgeConfig{
Name: "test-agent",
Version: "v0.1.0",
Registry: registry,
Executor: tools.NewExecutor(30*time.Second, nil),
Loader: loader,
})
serverTransport, clientTransport := gomcp.NewInMemoryTransports()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
go func() { bridge.ServeTransport(ctx, serverTransport) }()
client := gomcp.NewClient(&gomcp.Implementation{
Name: "test-client", Version: "v0.1.0",
}, nil)
session, _ := client.Connect(ctx, clientTransport, nil)
defer session.Close()
// List tools
listResult, _ := session.ListTools(ctx, nil)
// Verify tool count, names, annotations
// Call a tool
callResult, _ := session.CallTool(ctx, &gomcp.CallToolParams{
Name: "echo",
Arguments: map[string]any{"message": "hello"},
})
// Verify result content
}
Test memory resources, prompts, and error handling in the same pattern. See pkg/mcp/bridge_test.go for the full test suite.
Agents speak both protocol eras (MCP 2026-07-28 via server/discover, and initialize for 2025-11-25 / 2025-06-18 clients). To pin a client to one era, pass &gomcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"} to client.Connect and check session.InitializeResult().ProtocolVersion. pkg/mcp/conformance_test.go covers this (TestDualEra) and checks that tool, prompt and resource lists don’t change between connections (TestListsInvariantAcrossConnections); pkg/mcp/wire_test.go holds a golden tools/list wire test.
Integration Testing
Integration tests build the binary and exercise all subcommands. They live in internal/integration/ and use the //go:build integration tag.
# Run integration tests
make integration
# Equivalent to:
go test -tags integration -race -count=1 -timeout 120s ./internal/integration/
Integration tests are not included in the normal go test ./... run.
Test Setup
The TestMain function builds the abby CLI once, then uses it to build a test agent from a temp Abbyfile against the local module:
//go:build integration
func TestMain(m *testing.M) {
// Never touch a real runtime: force the file method (no `claude`/`gemini`
// CLI calls) and ignore the developer's own runtime config dirs.
os.Setenv("ABBY_CONFIG_METHOD", "file")
os.Unsetenv("CLAUDE_CONFIG_DIR")
os.Unsetenv("CODEX_HOME")
projectRoot := findProjectRoot()
tmp, _ := os.MkdirTemp("", "abbyfile-integration-*")
defer os.RemoveAll(tmp)
abbyBin = filepath.Join(tmp, "abby-cli")
cmd := exec.Command("go", "build", "-o", abbyBin, "./cmd/abby")
cmd.Dir = projectRoot
cmd.Run()
// ... write tmp/Abbyfile and tmp/agents/test-agent.md ...
buildDir := filepath.Join(tmp, "build")
cmd = exec.Command(abbyBin, "build", "-f", filepath.Join(tmp, "Abbyfile"),
"-o", buildDir, "--module-dir", projectRoot)
cmd.Dir = tmp
cmd.Run()
binaryPath = filepath.Join(buildDir, "test-agent")
os.Exit(m.Run())
}
Tests that run abby commands also give each child process its own HOME=<t.TempDir()>, so the registry and user-scope configs (~/.claude.json, ~/.codex/, ~/.gemini/) stay in the temp dir.
Important: Use cmd.Output(), not CombinedOutput()
Agent binaries log to stderr via slog. When parsing stdout output (like JSON from --describe), use cmd.Output() to get only stdout. CombinedOutput() mixes in log lines and corrupts the output.
func runAgentStdout(t *testing.T, args ...string) string {
cmd := exec.CommandContext(ctx, binaryPath, args...)
out, err := cmd.Output() // stdout only
// ...
}
MCP Integration with CommandTransport
func TestServeMCP(t *testing.T) {
cmd := exec.CommandContext(ctx, binaryPath, "serve-mcp")
client := gomcp.NewClient(&gomcp.Implementation{
Name: "integration-test", Version: "v0.1.0",
}, nil)
session, _ := client.Connect(ctx, &gomcp.CommandTransport{Command: cmd}, nil)
defer session.Close()
// List tools, call tools, verify responses
}
The make all Pipeline
make all
Runs four stages in order:
- fmtcheck – verify all files are
gofmt-formatted - vet –
go vet ./...for static analysis - test –
go test -race ./...(all unit tests) - build – build the abby CLI
Individual stages:
make fmt # auto-format all files
make fmtcheck # check formatting (CI-friendly, fails on unformatted)
make vet # static analysis
make test # unit tests with race detector
make build # build the abby CLI → build/abby
make integration # integration tests (builds binary first)
make clean # remove build/, .abbyfile/, .mcp.json, .codex/config.toml, .gemini/settings.json
make clean deletes those runtime config files in the repo root outright, including any entries you added by hand.
CI Setup Patterns
A typical CI workflow:
steps:
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Lint and test
run: make all
- name: Integration tests
run: make integration
Key considerations:
- Always use
-raceflag (already set in the Makefile) - Integration tests need a working Go toolchain (they
go buildthe binary) - Memory tests are isolated via
t.TempDir()– no filesystem cleanup needed
Plugin Testing
Plugin generation is tested at two levels:
Unit Tests (pkg/plugin/plugin_test.go)
Test the plugin.Generate() function with a fake binary and in-memory definitions:
func TestGenerate_DirectoryStructure(t *testing.T) {
tmp := t.TempDir()
binaryPath := filepath.Join(tmp, "my-agent")
os.WriteFile(binaryPath, []byte("#!/bin/sh\necho hello"), 0o755)
def := &definition.AgentDef{
Name: "my-agent",
Version: "1.0.0",
}
skills := []plugin.SkillFile{
{Name: "review-pr", Description: "Review a PR", Content: "Review content."},
}
outputDir := filepath.Join(tmp, "build")
os.MkdirAll(outputDir, 0o755)
err := plugin.Generate(def, skills, plugin.GenerateConfig{
OutputDir: outputDir,
BinaryPath: binaryPath,
})
// Verify: .claude-plugin/plugin.json, .mcp.json, binary, skills/review-pr/SKILL.md
}
Tests cover directory structure, plugin.json content, .mcp.json content, SKILL.md frontmatter + body, no-skills case, and binary permissions.
Integration Tests (internal/integration/plugin_test.go)
End-to-end test that builds a real agent with --plugin and verifies the full output:
func TestBuildPlugin(t *testing.T) {
// Create Abbyfile, agent .md with skills, skill .md files
// Run: abby build --plugin
// Verify: plugin directory structure, plugin.json, .mcp.json, SKILL.md content, binary executable
}
Run with make integration.
Distribution Testing
The distribution layer (install, uninstall, list, publish) has integration tests in internal/integration/distribution_test.go:
func TestList(t *testing.T) {
// Verifies list shows "No agents installed." with empty registry.
// Uses isolated HOME via t.TempDir().
}
func TestInstallLocalWithRegistry(t *testing.T) {
// Installs a binary from ./build/, verifies:
// - Binary copied to .abbyfile/bin/
// - Registry entry created with source="local" and correct version
// - abby list shows the agent
}
func TestUninstall(t *testing.T) {
// Installs, then uninstalls. Verifies:
// - Binary removed
// - Registry cleaned up
// - List shows empty again
}
func TestPublishDryRun(t *testing.T) {
// Runs publish --dry-run. Verifies:
// - Cross-compiled binaries created for all 4 targets
// - No GitHub Release created
}
Distribution tests use HOME override for registry isolation, run with ABBY_CONFIG_METHOD=file, and reuse the test agent binary built by TestMain. The same suite also covers:
TestInstallAllLocal(install --allfrom./build/)install_config_test.go–install --dry-run --runtime allwrites nothing, and install through a fakeclaudeCLI onPATHdoctor_test.go–abby doctorafter an install, including from an unrelated directorysandbox_test.go– the sandbox end to end through a built agentstdio_test.go– stdout carries only JSON-RPC in both protocol eras, even with sandbox warnings
Unit Testing the Registry
func TestSaveAndLoad(t *testing.T) {
path := filepath.Join(t.TempDir(), "registry.json")
r, _ := registry.Load(path)
r.Set(registry.Entry{
Name: "test", Source: "local", Version: "1.0.0",
Path: "/bin/test", Scope: "local",
})
r.Save()
r2, _ := registry.Load(path)
e, ok := r2.Get("test")
// ok == true, e.Version == "1.0.0"
}
Unit Testing the GitHub Client
Tests use httptest to mock the GitHub API:
func TestGetRelease(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(github.Release{
TagName: "myagent/v1.0.0",
Assets: []github.Asset,
})
}))
defer srv.Close()
c := &github.Client{HTTPClient: srv.Client(), BaseURL: srv.URL}
ref := github.ReleaseRef{Owner: "owner", Repo: "repo", Agent: "myagent", Version: "1.0.0"}
release, _ := c.GetRelease(context.Background(), ref)
// release.TagName == "myagent/v1.0.0"
}
Test Count
The project currently has 500+ unit tests across all packages:
pkg/agent -- agent creation, options, defaults, sandbox wiring
pkg/tools -- registry, executor, validation, shaping, spill, process groups
pkg/memory -- file store, limits, concurrency, manager tools
pkg/prompt -- loader, override, paths
pkg/builtins -- builtin tool implementations, sandbox confinement
pkg/sandbox -- path confinement, command allowlist, argv parsing
pkg/definition -- Abbyfile + agent .md parsing (skills, context_budget, sandbox)
pkg/builder -- code generation templates, build notes
pkg/config -- config.yaml loading and overrides
pkg/mcp -- bridge, tools, annotations, resources, prompts, protocol eras
pkg/plugin -- plugin directory generation
pkg/subagent -- sub-agent file emission
pkg/registry -- installed agents tracking, atomic save/load
pkg/runtimecfg -- runtime config writers (CLI + file), JSON/TOML edits, redaction
pkg/fsutil -- atomic writes, ownership, snapshots
pkg/github -- GitHub Releases client, checksums, version comparison, ref parsing
internal/cli -- root command, validate, config, flags
cmd/abby -- install, update, doctor, checksum, config-change planning
Plus integration tests that exercise the full binary end-to-end, including distribution commands (list, install, uninstall, doctor, publish –dry-run), and benchmarks under benchmarks/ (see make bench).