Testing Guide
Comprehensive testing strategies for SixDegree integrations and molecules.
Overview
SixDegree supports multiple testing approaches:
- Unit Tests - Test individual functions and components
- Integration Tests - Test molecule discovery and API integration
- End-to-End Tests - Test complete workflows including chat
- 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{
EntityTypes: []string{"entities.sixdegree.ai/v1/GithubRepository"},
})
scope, err := def.DecodeScope()
require.NoError(t, err)
}
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:
- Create a
TaskDefinitionatsuggestautonomy. - Enqueue a task and run it through a fake model that calls one write tool and one read tool.
- Assert the read tool executed and the write tool produced a pending
Approvalrow 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:
| Limit | Test pattern |
|---|---|
MaxToolCalls | Loop a no-op tool; assert run ends at the limit. |
MaxTokens | Use a usage-emitting fake model; assert run ends when accumulated tokens exceed cap. |
MaxCostUSD | Same as tokens, but with a priced model. |
MaxWallClock | Use 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
- Development Setup - Set up local environment
- Building Molecules - Molecule development guide
- Go SDK - SDK documentation