Skip to content

🧪 Testability Design

The 586 test cases in Composer Skills (referred to as 450+ externally in the README) all pass on a machine with no PHP, no Composer, and no network, thanks to the Mock mechanism that runs through both the base layer and the SDK layer.

Test Scale Overview

PackageNumber of func Test
pkg/composer422
pkg/installer78
pkg/client22
pkg/composerutils20
pkg/detector17
pkg/repository16
pkg/domain11
Total586

🔌 Mock Mechanism 1: SetupMockOutput in pkg/composer

Defined in pkg/composer/composer.go and pkg/composer/test_utils.go.

Core idea: a package-level variable testMode marks the test state; in test state, validation of the real composer binary is skipped. Meanwhile, a global map[string]MockOutput injects fake output keyed by "command string".

go
// composer.go
var testMode = false

// Enable test mode and set mock output for a specific command
func SetupMockOutput(command string, output string, err error) {
    // ... writes to the mockOutputs map ...
    testMode = true
}
go
// Usage in tests
func TestAudit(t *testing.T) {
    composer.SetupMockOutput("composer audit --format=json",
        `{"found":2,"advisories":[]}`, nil)
    defer composer.ClearMockOutputsAdvanced()

    comp, _ := composer.New(composer.DefaultOptions())
    result, _ := comp.AuditWithJSON()
    assert.Equal(t, 2, result.Found)
}

Concurrency safety is guaranteed by a sync.RWMutex (mockMutex in test_utils.go).

🧩 Mock Mechanism 2: MockCommandExecutor in pkg/composerutils/mock

For scenarios that need to replace the command executor itself, defined in pkg/composerutils/mock/mock.go.

go
// CommandExecutor interface — a replaceable command executor
type CommandExecutor interface {
    Execute(name string, args ...string) ([]byte, error)
}

// Default implementation: actually executes system commands
type DefaultCommandExecutor struct{}

// Mock implementation: returns preset results
type MockCommandExecutor struct {
    CommandResults map[string]struct{ Output []byte; Err error }
}

The 78 tests in pkg/installer inject a MockCommandExecutor to verify the "detected missing → triggers install" branch logic without actually downloading PHP.

go
exec := mock.NewMockCommandExecutor()
exec.SetCommandResult("composer", []string{"--version"},
    []byte("Composer version 2.7.0"), nil)

⚙️ The Role of testMode

Around line 252 of composer.go: when testMode == false (non-test state), New() forcibly validates that the local composer binary exists; in test state this check is skipped so that tests do not depend on a real environment. SetupMockOutput / ClearMockOutputsAdvanced automatically set testMode to true.

🗂️ Test Organization

  • Co-located: every foo.go has a neighboring foo_test.go; 61 test files are spread across 7 packages.
  • Integration tests: pkg/composer/integration_test.go chains multiple Mocks into a full workflow (detect → install → execute → audit).
  • Shared helpers: functions like createTestComposerLock in pkg/composer/test_utils.go are reused across multiple test files, avoiding duplicate fixture boilerplate.

📝 How to Write Tests for Your Own Code

  1. CLI wrapper methods (pkg/composer): call composer.SetupMockOutput("composer <subcommand> <args>", <output>, <error>) to inject fake output, defer cleanup, then assert on the structured return value.
  2. Install/detect logic (pkg/installer, pkg/detector): build an executor with mock.NewMockCommandExecutor(), preset returns via SetCommandResult, then run the business logic.
  3. HTTP client (pkg/client): stand up a fake Packagist server with the standard httptest.NewServer and point the client's base URL at it.
  4. Domain model (pkg/domain): pure structs — just construct instances and assert directly.

Running Tests

bash
make test           # all tests
make test-race      # race detection
make test-coverage  # coverage report

Note

Mock output is globally shared; always defer ClearMockOutputsAdvanced() between tests, otherwise it will pollute other cases.

Released under the MIT License