Skip to main content
Unlisted page
This page is unlisted. Search engines will not index it, and only users having a direct link can access it.

Testing Guide

Comprehensive testing strategies for SixDegree integrations and molecules.

Overview

SixDegree supports multiple testing approaches:

  1. Unit Tests - Test individual functions and components
  2. Integration Tests - Test molecule discovery and API integration
  3. End-to-End Tests - Test complete workflows including chat
  4. Mock Testing - Use Doppelganger to simulate external APIs

Unit Testing

Testing Molecule Logic

package main

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap/zaptest"
)

func TestEntityConversion(t *testing.T) {
mol := &MyMolecule{}

// Test data
resource := &Resource{
ID: "123",
Name: "test-resource",
URL: "https://example.com",
}

// Convert to entity
entity := mol.convertToEntity(resource)

// Assertions
assert.Equal(t, "resource_123", entity.UID)
assert.Equal(t, "Resource", entity.Kind)
assert.Equal(t, "test-resource", entity.Name)
assert.Contains(t, entity.Properties, "url")
}

func TestConfigurationValidation(t *testing.T) {
mol := &MyMolecule{}
logger := zaptest.NewLogger(t)

tests := []struct {
name string
config map[string]interface{}
wantErr bool
}{
{
name: "valid config",
config: map[string]interface{}{
"api_key": "valid-key",
"base_url": "https://api.example.com",
},
wantErr: false,
},
{
name: "missing api_key",
config: map[string]interface{}{
"base_url": "https://api.example.com",
},
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := &molecule.MoleculeConfig{
Discovery: &molecule.CapabilitySettings{
Settings: tt.config,
},
}

err := mol.Initialize(context.Background(), config, logger)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}

Testing Relationship Logic

func TestRelationshipCreation(t *testing.T) {
mol := &MyMolecule{}

repo := &Repository{ID: "repo-1", Name: "api-service"}
service := &Service{ID: "svc-1", RepoID: "repo-1"}

entities := mol.createEntitiesWithRelations(repo, service)

// Find repository entity
var repoEntity *molecule.Entity
for i := range entities {
if entities[i].Kind == "Repository" {
repoEntity = &entities[i]
break
}
}

require.NotNil(t, repoEntity)

// Check relationships
assert.Len(t, repoEntity.Relations, 1)
assert.Equal(t, "DEPLOYS_TO", repoEntity.Relations[0].Type)
assert.Equal(t, "service_svc-1", repoEntity.Relations[0].Target)
}

Testing MCP Tools

func TestMCPToolInvocation(t *testing.T) {
mol := &MyMolecule{}
mol.client = NewMockClient()

input := json.RawMessage(`{
"query": "test search",
"limit": 10
}`)

result, err := mol.handleSearch(context.Background(), input)
require.NoError(t, err)

resultMap, ok := result.(map[string]interface{})
require.True(t, ok)

assert.Contains(t, resultMap, "results")
assert.Contains(t, resultMap, "count")
}

func TestMCPToolValidation(t *testing.T) {
mol := &MyMolecule{}

// Invalid input - missing required field
invalidInput := json.RawMessage(`{
"limit": 10
}`)

_, err := mol.handleSearch(context.Background(), invalidInput)
assert.Error(t, err)
assert.Contains(t, err.Error(), "query")
}

Integration Testing

Testing with Mock HTTP Server

import (
"net/http"
"net/http/httptest"
)

func TestDiscoveryWithMockServer(t *testing.T) {
// Create mock server
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify request
assert.Equal(t, "/api/resources", r.URL.Path)
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))

// Mock response
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]Resource{
{ID: "1", Name: "Resource 1"},
{ID: "2", Name: "Resource 2"},
})
}))
defer mockServer.Close()

// Configure molecule with mock server
mol := &MyMolecule{}
config := &molecule.MoleculeConfig{
Namespace: "test",
Discovery: &molecule.CapabilitySettings{
Settings: map[string]interface{}{
"api_key": "test-token",
"base_url": mockServer.URL,
},
},
}

logger := zaptest.NewLogger(t)
err := mol.Initialize(context.Background(), config, logger)
require.NoError(t, err)

// Run discovery
result, err := mol.Discover(context.Background())
require.NoError(t, err)

// Verify results
assert.Len(t, result.Entities, 2)
assert.Equal(t, "resource_1", result.Entities[0].UID)
assert.Equal(t, "Resource 1", result.Entities[0].Name)
}

Testing Pagination

func TestDiscoveryWithPagination(t *testing.T) {
pageCount := 0
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
page := r.URL.Query().Get("page")
pageNum, _ := strconv.Atoi(page)

if pageNum == 1 {
// First page
json.NewEncoder(w).Encode(PagedResponse{
Data: []Resource{{ID: "1"}, {ID: "2"}},
Page: 1,
HasMore: true,
})
} else {
// Second page
json.NewEncoder(w).Encode(PagedResponse{
Data: []Resource{{ID: "3"}},
Page: 2,
HasMore: false,
})
}
pageCount++
}))
defer mockServer.Close()

// Test pagination logic
mol := setupMolecule(mockServer.URL)
result, err := mol.Discover(context.Background())

require.NoError(t, err)
assert.Equal(t, 2, pageCount) // Verify two requests made
assert.Len(t, result.Entities, 3) // Total entities from both pages
}

Testing Error Handling

func TestDiscoveryErrorHandling(t *testing.T) {
tests := []struct {
name string
statusCode int
response string
wantErr string
}{
{
name: "unauthorized",
statusCode: 401,
response: `{"error": "unauthorized"}`,
wantErr: "unauthorized",
},
{
name: "rate limit",
statusCode: 429,
response: `{"error": "rate limit exceeded"}`,
wantErr: "rate limit",
},
{
name: "server error",
statusCode: 500,
response: `{"error": "internal server error"}`,
wantErr: "internal server error",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tt.statusCode)
w.Write([]byte(tt.response))
}))
defer mockServer.Close()

mol := setupMolecule(mockServer.URL)
_, err := mol.Discover(context.Background())

require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
})
}
}

Testing with Doppelganger

Doppelganger is a mock API server that simulates external service APIs for testing.

Starting Doppelganger

cd doppelganger
go run main.go

# Server starts on http://localhost:9090

Configuring Test Scenarios

Create test fixtures in doppelganger/fixtures/:

# fixtures/github.yaml
name: GitHub API Mock
base_path: /github

scenarios:
- name: List repositories
request:
method: GET
path: /orgs/test-org/repos
response:
status: 200
headers:
Content-Type: application/json
body: |
[
{
"id": 1,
"name": "repo-1",
"full_name": "test-org/repo-1",
"private": false
},
{
"id": 2,
"name": "repo-2",
"full_name": "test-org/repo-2",
"private": true
}
]

- name: Get repository
request:
method: GET
path: /repos/test-org/repo-1
response:
status: 200
body: |
{
"id": 1,
"name": "repo-1",
"full_name": "test-org/repo-1"
}

- name: Unauthorized
request:
method: GET
path: /orgs/unauthorized/repos
response:
status: 401
body: |
{
"message": "Bad credentials"
}

Using Doppelganger in Tests

func TestWithDoppelganger(t *testing.T) {
// Doppelganger should be running on localhost:9090

mol := &GitHubMolecule{}
config := &molecule.MoleculeConfig{
Discovery: &molecule.CapabilitySettings{
Settings: map[string]interface{}{
"token": "mock-token",
"api_url": "http://localhost:9090/github",
"selectors": []interface{}{
map[string]interface{}{
"organization": "test-org",
},
},
},
},
}

logger := zaptest.NewLogger(t)
err := mol.Initialize(context.Background(), config, logger)
require.NoError(t, err)

result, err := mol.Discover(context.Background())
require.NoError(t, err)

assert.Len(t, result.Entities, 2)
assert.Equal(t, "repo_1", result.Entities[0].UID)
}

End-to-End Testing

Testing Discovery Pipeline

func TestFullDiscoveryPipeline(t *testing.T) {
// Setup
db := setupTestDatabase(t)
defer cleanupDatabase(t, db)

platform := startTestPlatform(t, db)
defer platform.Stop()

mol := startTestMolecule(t)
defer mol.Stop()

// Create environment
env := createTestEnvironment(t, platform)

// Configure molecule
config := createMoleculeConfig(t, mol.URL())
platform.ConfigureMolecule(t, env.ID, config)

// Run discovery
runID := platform.RunDiscovery(t, env.ID, "test-molecule")

// Wait for completion
status := waitForDiscovery(t, platform, runID, 30*time.Second)
assert.Equal(t, "completed", status.Status)

// Verify entities were created
entities := platform.ListEntities(t, env.ID)
assert.NotEmpty(t, entities)

// Verify relationships
for _, entity := range entities {
if len(entity.Relations) > 0 {
relations := platform.GetEntityRelations(t, env.ID, entity.UID)
assert.NotEmpty(t, relations)
}
}
}

Testing Chat Integration

func TestChatWithMCP(t *testing.T) {
platform := startTestPlatform(t, setupTestDatabase(t))
defer platform.Stop()

// Create environment with entities
env := createTestEnvironment(t, platform)
seedTestEntities(t, platform, env.ID)

// Configure MCP molecule
mol := startTestMolecule(t)
platform.ConfigureMCP(t, env.ID, mol.URL())

// Create chat session
chat := platform.CreateChat(t, env.ID)

// Send message that triggers MCP tool
response := platform.SendChatMessage(t, chat.ID, "Search for resources matching 'api'")

// Verify tool was called
assert.Contains(t, response.ToolCalls, "search_resources")

// Verify response contains results
assert.Contains(t, response.Content, "api")
}

Performance Testing

Benchmarking Discovery

func BenchmarkDiscovery(b *testing.B) {
mol := setupMolecule(b)

b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := mol.Discover(context.Background())
if err != nil {
b.Fatal(err)
}
}
}

func BenchmarkEntityConversion(b *testing.B) {
mol := &MyMolecule{}
resources := generateTestResources(1000)

b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, r := range resources {
_ = mol.convertToEntity(r)
}
}
}

Load Testing MCP Tools

func TestMCPToolConcurrency(t *testing.T) {
mol := setupMolecule(t)

numGoroutines := 10
numRequests := 100

var wg sync.WaitGroup
errChan := make(chan error, numGoroutines*numRequests)

for g := 0; g < numGoroutines; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for r := 0; r < numRequests; r++ {
input := json.RawMessage(`{"query": "test"}`)
_, err := mol.handleSearch(context.Background(), input)
if err != nil {
errChan <- err
}
}
}()
}

wg.Wait()
close(errChan)

// Check for errors
var errors []error
for err := range errChan {
errors = append(errors, err)
}

assert.Empty(t, errors, "Expected no errors during concurrent execution")
}

Testing Agents and Triggers

Agents and triggers run on the platform's durable task queue, so testing them is closer to integration testing than unit testing. The patterns below cover the three layers you typically test.

Agent Definition Validation

Test that a saved agent's scope, autonomy, and blast-radius round-trip correctly:

func TestAgentDefinitionRoundTrip(t *testing.T) {
def := task.TaskDefinition{
Name: "daily-ci-failures",
Goal: "Summarize failing CI runs in the last 24h",
Autonomy: task.AutonomyObserve,
}
def.ScopeJSON = mustJSON(&task.AgentScope{
Molecules: []string{"molecules.sixdegree.ai/[email protected]"},
EntityTypes: []string{"entities.sixdegree.ai/v1/GithubRepository"},
})

scope, err := def.DecodeScope()
require.NoError(t, err)
assert.Equal(t, []string{"molecules.sixdegree.ai/[email protected]"}, scope.Molecules)
}

Tier-Based Tool Disclosure

Autonomy tiers gate which tools are visible to the agent. Assert the contract directly:

func TestObserveTierDisclosesReadOnly(t *testing.T) {
allowed := task.AutonomyObserve.AllowedRiskLevels()
assert.True(t, allowed["read"])
assert.False(t, allowed["write"])
assert.False(t, allowed["destructive"])
}

This kind of test catches regressions where a new tier inadvertently widens what a write-restricted agent can see.

Trigger Filter Matching

For event triggers, validate that filter expressions match the events you expect:

func TestEventTriggerFilter(t *testing.T) {
trigger := task.Trigger{
EventType: "github.issue.opened",
FilterJSON: mustJSON(map[string]any{"payload.labels": "critical"}),
}
event := task.Event{
Type: "github.issue.opened",
Payload: map[string]any{"payload": map[string]any{"labels": "critical"}},
}

assert.True(t, dispatcher.Matches(trigger, event))
}

Approval Gating

For higher-autonomy agents, an integration test should verify that the approval queue actually intercepts write-class tool calls:

  1. Create a TaskDefinition at suggest autonomy.
  2. Enqueue a task and run it through a fake model that calls one write tool and one read tool.
  3. Assert the read tool executed and the write tool produced a pending Approval row instead.

Tests at this layer require a Postgres-backed task.Store. Use the pgstore integration test helpers in pkg/agent/task/pgstore/.

Blast-Radius Enforcement

Drive a fake model into a tool-call loop and verify the executor bails when it hits the configured ceiling. Each limit has its own assertion:

LimitTest pattern
MaxToolCallsLoop a no-op tool; assert run ends at the limit.
MaxTokensUse a usage-emitting fake model; assert run ends when accumulated tokens exceed cap.
MaxCostUSDSame as tokens, but with a priced model.
MaxWallClockUse a slow fake tool; assert cancellation within a small tolerance of the limit.

These tests are worth having for any custom executor you build. Limits that fail open are how unbounded-cost incidents start.

Don't Mock the Database

Agent task state, approval rows, and the trigger fire ledger all interact through Postgres constraints (especially the trigger_kind_xor check, the FK from agent_tasks.task_definition_id, and the atomic ClaimScheduledFire). Mocked stores let bugs slip through; the integration test helpers in pkg/agent/task/pgstore/*_integration_test.go spin up a real Postgres in seconds.

Testing Best Practices

1. Use Table-Driven Tests

func TestInputValidation(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
errMsg string
}{
{
name: "valid input",
input: `{"name": "test"}`,
wantErr: false,
},
{
name: "missing required field",
input: `{}`,
wantErr: true,
errMsg: "name is required",
},
{
name: "invalid json",
input: `{invalid}`,
wantErr: true,
errMsg: "invalid json",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateInput(tt.input)
if tt.wantErr {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errMsg)
} else {
assert.NoError(t, err)
}
})
}
}

2. Use Test Fixtures

// testdata/fixtures.go
package testdata

var ValidRepository = &Repository{
ID: "123",
Name: "test-repo",
URL: "https://github.com/org/test-repo",
}

var RepositoryWithRelations = &Repository{
ID: "123",
Name: "api-service",
Dependencies: []string{"lib-1", "lib-2"},
}

func LoadFixture(t *testing.T, filename string) []byte {
data, err := os.ReadFile(filepath.Join("testdata", filename))
require.NoError(t, err)
return data
}

3. Clean Up Resources

func TestWithCleanup(t *testing.T) {
server := httptest.NewServer(handler)
t.Cleanup(server.Close) // Automatic cleanup

tempDir := t.TempDir() // Automatically removed after test

// Test code...
}

4. Use Subtests for Organization

func TestMolecule(t *testing.T) {
mol := setupMolecule(t)

t.Run("Discovery", func(t *testing.T) {
t.Run("ValidConfig", func(t *testing.T) {
// Test valid configuration
})

t.Run("InvalidConfig", func(t *testing.T) {
// Test error handling
})
})

t.Run("MCPTools", func(t *testing.T) {
t.Run("SearchTool", func(t *testing.T) {
// Test search tool
})

t.Run("CreateTool", func(t *testing.T) {
// Test create tool
})
})
}

CI/CD Integration

GitHub Actions Workflow

name: Test

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest

services:
# Database service configuration
# Contact maintainers for test database setup

steps:
- uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'

- name: Cache Go modules
uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}

- name: Install dependencies
run: go mod download

- name: Run tests
env:
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
run: go test -v -race -coverprofile=coverage.out ./...

- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: ./coverage.out

Additional Resources