Contributing
Development Setup
Prerequisites
- Go 1.24+ (use mise for version management)
- Git
- tmux (optional; some integration tests skip without it)
- gh CLI (for the GitHub PR provider) or glab CLI (for the GitLab MR provider)
Clone and build
# Install Go via mise (recommended)
mise install
git clone https://github.com/jewell-lgtm/monkeypuzzle.git
cd monkeypuzzle
go build -o mp .
Run tests
go test ./... # Unit tests only
go test -tags=integration ./... # All tests including integration
go test ./internal/core/piece/... -v # Specific package, verbose
Lint
go vet ./...
Testing Philosophy: Outside-In
This is the most important section of this document.
Monkeypuzzle uses outside-in testing (also called London-school TDD). The core principle:
Every new feature starts with an integration test that proves the happy path works. Unit tests fill in edge cases and completeness.
Why Outside-In?
- Integration tests catch real bugs - unit tests with mocks can pass while the system is broken
- Happy path first - proves the feature works end-to-end before worrying about edge cases
- Unit tests are for completeness - edge cases, error handling, boundary conditions
- Faster feedback on architecture - integration tests reveal integration problems early
The Testing Pyramid (Inverted)
Traditional wisdom says “many unit tests, few integration tests.” We flip this for starting new work:
START HERE
│
▼
┌─────────────────────────────────┐
│ Integration Test (Happy Path) │ ← Write this FIRST
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Unit Tests (Edge Cases) │ ← Fill these in after
└─────────────────────────────────┘
Workflow for New Features
- Write one integration test that exercises the happy path
- Make it pass with the simplest implementation
- Add unit tests for edge cases, error conditions, boundary conditions
- Refactor with confidence (tests catch regressions)
Example: Adding a New Command
Step 1: Integration test first (apps/mp/cli_integration_test.go)
//go:build integration
func TestCLI_NewCommand_HappyPath(t *testing.T) {
env := setupTestEnv(t)
defer env.cleanup()
env.initProject("test")
// Exercise the REAL binary, REAL filesystem
stdout, _, err := env.run("newcommand", "--flag", "value")
if err != nil {
t.Fatalf("command failed: %v", err)
}
// Verify real output
var result map[string]any
json.Unmarshal([]byte(stdout), &result)
if result["expected_field"] != "expected_value" {
t.Error("happy path broken")
}
}
Step 2: Make it pass - implement the feature
Step 3: Unit tests for edge cases (internal/core/newcmd/handler_test.go)
func TestHandler_InvalidInput(t *testing.T) {
deps := core.Deps{
FS: adapters.NewMemoryFS(), // Mock filesystem
Output: adapters.NewBufferOutput(), // Mock output
Exec: adapters.NewMockExec(), // Mock commands
}
handler := newcmd.NewHandler(deps)
err := handler.Run(newcmd.Input{Name: ""})
if err == nil {
t.Error("expected error for empty name")
}
}
func TestHandler_FileAlreadyExists(t *testing.T) {
fs := adapters.NewMemoryFS()
fs.WriteFile("/existing", []byte("content"), 0644)
deps := core.Deps{FS: fs, Output: adapters.NewBufferOutput()}
handler := newcmd.NewHandler(deps)
err := handler.Run(newcmd.Input{Path: "/existing"})
if err == nil {
t.Error("expected error for existing file")
}
}
Test File Organization
internal/core/piece/
├── handler.go
├── handler_test.go # Unit tests (mocked deps)
├── handler_integration_test.go # Integration tests (real deps)
├── input.go
└── input_test.go # Unit tests for validation
Integration vs Unit Tests
| Aspect | Integration Test | Unit Test |
|---|---|---|
| Purpose | Prove the feature works | Prove edge cases handled |
| Dependencies | Real (filesystem, git, etc.) | Mocked |
| Build tag | //go:build integration | None |
| Speed | Slower | Fast |
| When to write | FIRST | After happy path works |
| File suffix | _integration_test.go | _test.go |
Running Tests
# Fast feedback during development (unit tests only)
go test ./...
# Full test suite (CI, before PR)
go test -tags=integration ./...
# Specific integration tests
go test -tags=integration ./internal/core/piece/... -v -run TestIntegration
What Makes a Good Integration Test?
- Tests the real thing - actual binary, filesystem, git commands
- Minimal setup - use helper functions like
setupTestEnv,setupGitRepo - Focuses on happy path - save edge cases for unit tests
- Self-contained - creates temp dirs, cleans up after itself
- Fast enough - don’t test every combination, just prove it works
What Makes a Good Unit Test?
- Tests one thing - single behavior or edge case
- Uses mocks -
MemoryFS,BufferOutput,MockExec - Table-driven for multiple cases:
func TestValidate(t *testing.T) {
tests := []struct {
name string
input Input
wantErr bool
}{
{"valid", Input{Name: "test"}, false},
{"empty name", Input{Name: ""}, true},
{"invalid chars", Input{Name: "a/b"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := Validate(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr)
}
})
}
}
Adding a New Command
1. Write integration test first
apps/mp/cli_integration_test.go:
//go:build integration
func TestCLI_NewCmd_HappyPath(t *testing.T) {
env := setupTestEnv(t)
defer env.cleanup()
env.initProject("test")
stdout, _, err := env.run("newcmd", "--name", "test")
if err != nil {
t.Fatalf("failed: %v", err)
}
// Assert on real output
}
2. Create input definition
internal/core/newcmd/input.go:
package newcmd
type Input struct {
Name string `json:"name"`
Option string `json:"option"`
}
var fields = []Field{
{Name: "name", Required: true},
{Name: "option", Default: "default_value", ValidValues: []string{"a", "b"}},
}
func Validate(input Input) error { /* use fields */ }
func Schema(workDir string) ([]byte, error) { /* generate from fields */ }
func WithDefaults(input Input, workDir string) Input { /* apply defaults */ }
3. Create handler
internal/core/newcmd/handler.go:
package newcmd
import "github.com/jewell-lgtm/monkeypuzzle/internal/core"
type Handler struct {
deps core.Deps
}
func NewHandler(deps core.Deps) *Handler {
return &Handler{deps: deps}
}
func (h *Handler) Run(input Input) error {
// Business logic using h.deps.FS, h.deps.Output, h.deps.Exec
return nil
}
4. Add unit tests for edge cases
internal/core/newcmd/handler_test.go:
package newcmd_test
func TestHandler_EmptyName(t *testing.T) {
deps := core.Deps{
FS: adapters.NewMemoryFS(),
Output: adapters.NewBufferOutput(),
}
handler := newcmd.NewHandler(deps)
err := handler.Run(newcmd.Input{Name: ""})
if err == nil {
t.Error("expected error")
}
}
5. Wire CLI command
apps/mp/newcmd.go:
package main
var newcmdCmd = &cobra.Command{
Use: "newcmd",
Short: "Description",
RunE: runNewCmd,
}
func init() {
rootCmd.AddCommand(newcmdCmd)
newcmdCmd.Flags().StringVar(&flagName, "name", "", "Name")
}
func runNewCmd(cmd *cobra.Command, args []string) error {
deps := core.Deps{
FS: adapters.NewOSFS(""),
Output: adapters.NewTextOutput(os.Stderr),
Exec: adapters.NewOSExec(),
}
// Handle input modes, run handler
return nil
}
Code Style
- Keep functions small and focused
- Use table-driven tests
- No error swallowing - propagate or handle explicitly
- Prefer composition over inheritance
- Use dependency injection for external dependencies
Pull Request Process
- Fork the repository
- Create feature branch
- Write integration test for happy path first
- Implement the feature
- Add unit tests for edge cases
- Ensure all tests pass:
go test -tags=integration ./... - Ensure linting passes:
go vet ./... - Submit pull request
Claude / Agent Skills
The repo ships one skill under .claude/skills/:
.claude/skills/managing-monkeypuzzle/SKILL.mdis generated bymp claude skill. Re-run that command to regenerate it; do not hand-edit it, as your changes will be overwritten.
When mp’s CLI surface changes, run mp claude skill to refresh it.
Project Structure Reference
See architecture.md for detailed architecture documentation.