| name | test-mock-registry |
| description | Generate canonical fake implementations (working in-memory test doubles) from port interfaces in the hexagonal architecture. Maintains a shared fake registry in pkg/testutil/fakes/ that all test-writing skills import from. |
| user-invocable | true |
| argument-hint | [port-name | all] |
| allowed-tools | Read, Write, Edit, Glob, Grep, Bash |
Role
You are a test infrastructure engineer who generates canonical fake
implementations from port interfaces in the hexagonal architecture. You produce
working in-memory implementations — not mocks that record calls, not stubs that
return fixed values. Fakes have real (simplified) logic that exercises actual
behavior paths.
Fake vs Mock vs Stub (Critical Distinction)
| Type | Behavior | Example | This Skill? |
|---|
| Fake | Working implementation with simplified internals | In-memory map instead of SQLite | YES |
| Mock | Records calls, asserts on call sequence | mock.AssertCalled(t, "Get", id) | NO |
| Stub | Returns hardcoded values, no logic | return nil, nil always | NO |
This skill generates fakes only. Fakes are preferred per the project's
Chicago-school testing philosophy. They allow tests to exercise real behavior
paths without coupling to call sequences.
When to Use
- After port interfaces are defined in
internal/core/port/
- When a new port is added or an existing port's method signature changes
- Before running
@tdd-go, @test-functional, or @integration-test on
features that depend on ports
- When a test file imports from
pkg/testutil/fakes/ and the fake is missing
- To regenerate the entire fake registry:
/test-mock-registry all
- To generate or update a single fake:
/test-mock-registry repository
When NOT to Use
- Writing test logic or assertions (use
@tdd-go, @test-functional)
- Designing which tests to write (use
@test-design)
- Creating one-off test helpers specific to a single test file (put those
in the test file itself or a
_test.go helper in the same package)
- Building adapter implementations (those go in
internal/adapter/)
- Generating mocks that record call sequences (use
gomock or testify/mock
directly in the rare cases London-school mocks are needed)
Relationship to Other Skills
test-mock-registry (THIS SKILL) -- produces pkg/testutil/fakes/*.go
|
|-- consumed-by --> tdd-go -- imports fakes for unit tests
|-- consumed-by --> tdd-workflow -- language-agnostic TDD engine
|-- consumed-by --> integration-test -- imports fakes for boundary tests
|-- consumed-by --> test-functional -- imports fakes for functional tests
|
|-- reads-from --> internal/core/port/ -- source of truth for interfaces
|-- validates-with --> testing -- go build + go vet verification
Instructions
Step 1: Scan Port Interfaces
Use the Glob tool with pattern internal/core/port/**/*.go to find all port
files. Exclude any *_test.go matches.
For each file, extract every type ... interface definition. Record:
- Interface name (e.g.,
Repository, EventBus, Dispatcher)
- Method signatures (name, params, return types)
- Any embedded interfaces
- Import paths needed by method signatures
If $ARGUMENTS specifies a single port name, filter to only that interface.
If $ARGUMENTS is all or empty, process every port interface found.
Step 2: Check Existing Fakes
Use the Glob tool with pattern pkg/testutil/fakes/*.go to find existing fakes.
For each existing fake, compare its method set against the current port
interface. Identify:
- Missing fakes: Port exists but no corresponding fake file
- Stale fakes: Fake exists but methods don't match current interface
- Current fakes: Fake matches interface exactly (skip these)
Report the delta before generating.
Step 3: Generate Fakes
For each port that needs a new or updated fake, generate a file following
the canonical pattern below.
Output Location
pkg/testutil/fakes/fake_{port_name_snake}.go
One file per port interface. Package name: fakes.
Canonical Fake Structure
Every generated fake follows this structure:
package fakes
import (
"sync"
"github.com/<org>/<project>/internal/core/domain"
"github.com/<org>/<project>/internal/core/port"
)
var _ port.Repository = (*FakeRepository)(nil)
type FakeRepository struct {
mu sync.RWMutex
errors map[string]error
items map[string]domain.Item
}
func NewFakeRepository() *FakeRepository {
return &FakeRepository{
errors: make(map[string]error),
items: make(map[string]domain.Item),
}
}
func (f *FakeRepository) SetError(methodName , err ) {
f.mu.Lock()
f.mu.Unlock()
err == {
(f.errors, methodName)
} {
f.errors[methodName] = err
}
}
getError(methodName ) {
f.mu.RLock()
f.mu.RUnlock()
f.errors[methodName]
}
GetItem(id ) (domain.Item, ) {
err := f.getError(); err != {
domain.Item{}, err
}
f.mu.RLock()
f.mu.RUnlock()
item, ok := f.items[id]
!ok {
domain.Item{}, port.ErrNotFound
}
item,
}
SaveItem(item domain.Item) {
err := f.getError(); err != {
err
}
f.mu.Lock()
f.mu.Unlock()
f.items[item.ID] = item
}
DeleteItem(id ) {
err := f.getError(); err != {
err
}
f.mu.Lock()
f.mu.Unlock()
(f.items, id)
}
ListItems() ([]domain.Item, ) {
err := f.getError(); err != {
, err
}
f.mu.RLock()
f.mu.RUnlock()
result := ([]domain.Item, , (f.items))
_, item := f.items {
result = (result, item)
}
result,
}
Reset() {
f.mu.Lock()
f.mu.Unlock()
f.items = ([]domain.Item)
f.errors = ([])
}
SeedItems(items ...domain.Item) {
f.mu.Lock()
f.mu.Unlock()
_, item := items {
f.items[item.ID] = item
}
}
Count() {
f.mu.RLock()
f.mu.RUnlock()
(f.items)
}
Fake Pattern: Event Bus Port
For event/message-passing ports, use a channel-based approach:
package fakes
import (
"context"
"sync"
"github.com/<org>/<project>/internal/core/domain"
"github.com/<org>/<project>/internal/core/port"
)
var _ port.EventBus = (*FakeEventBus)(nil)
type FakeEventBus struct {
mu sync.RWMutex
errors map[string]error
published []domain.Event
subscribers map[string][]chan domain.Event
}
func NewFakeEventBus() *FakeEventBus {
return &FakeEventBus{
errors: make(map[string]error),
published: make([]domain.Event, 0),
subscribers: make(map[string][]chan domain.Event),
}
}
func (f *FakeEventBus) SetError(methodName string, err error) {
f.mu.Lock()
defer f.mu.Unlock()
if err == nil {
delete(f.errors, methodName)
} else {
f.errors[methodName] = err
}
}
func (f *FakeEventBus) getError(methodName ) {
f.mu.RLock()
f.mu.RUnlock()
f.errors[methodName]
}
Publish(ctx context.Context, event domain.Event) {
err := f.getError(); err != {
err
}
f.mu.Lock()
f.published = (f.published, event)
subs := ([] domain.Event, (f.subscribers[event.Topic]))
(subs, f.subscribers[event.Topic])
f.mu.Unlock()
_, ch := subs {
{
ch <- event:
<-ctx.Done():
ctx.Err()
}
}
}
Subscribe(topic ) (<- domain.Event, ) {
err := f.getError(); err != {
, err
}
ch := ( domain.Event, )
f.mu.Lock()
f.mu.Unlock()
f.subscribers[topic] = (f.subscribers[topic], ch)
ch,
}
Reset() {
f.mu.Lock()
f.mu.Unlock()
f.published = ([]domain.Event, )
topic, chs := f.subscribers {
_, ch := chs {
(ch)
}
(f.subscribers, topic)
}
f.errors = ([])
}
Published() []domain.Event {
f.mu.RLock()
f.mu.RUnlock()
out := ([]domain.Event, (f.published))
(out, f.published)
out
}
Drain(ch <- domain.Event) []domain.Event {
events []domain.Event
{
{
e, ok := <-ch:
!ok {
events
}
events = (events, e)
:
events
}
}
}
Fake Pattern: Dispatcher Port
For ports that dispatch commands or actions. Note: the dispatched slice
captures commands for test assertions — this is for verifying the system's
observable behavior, not for mock-style call-sequence verification.
package fakes
import (
"context"
"sync"
"github.com/<org>/<project>/internal/core/domain"
"github.com/<org>/<project>/internal/core/port"
)
var _ port.Dispatcher = (*FakeDispatcher)(nil)
type FakeDispatcher struct {
mu sync.RWMutex
errors map[string]error
dispatched []domain.Command
results map[string]domain.DispatchResult
}
func NewFakeDispatcher() *FakeDispatcher {
return &FakeDispatcher{
errors: make(map[string]error),
dispatched: make([]domain.Command, 0),
results: make(map[string]domain.DispatchResult),
}
}
func (f *FakeDispatcher) SetError(methodName string, err error) {
f.mu.Lock()
defer f.mu.Unlock()
if err == nil {
delete(f.errors, methodName)
} else {
f.errors[methodName] = err
}
}
func (f *FakeDispatcher) getError(methodName string) {
f.mu.RLock()
f.mu.RUnlock()
f.errors[methodName]
}
Dispatch(ctx context.Context, cmd domain.Command) (domain.DispatchResult, ) {
err := f.getError(); err != {
domain.DispatchResult{}, err
}
f.mu.Lock()
f.dispatched = (f.dispatched, cmd)
result, ok := f.results[cmd.Type]
f.mu.Unlock()
!ok {
domain.DispatchResult{Status: },
}
result,
}
Reset() {
f.mu.Lock()
f.mu.Unlock()
f.dispatched = ([]domain.Command, )
f.results = ([]domain.DispatchResult)
f.errors = ([])
}
SetResult(cmdType , result domain.DispatchResult) {
f.mu.Lock()
f.mu.Unlock()
f.results[cmdType] = result
}
Dispatched() []domain.Command {
f.mu.RLock()
f.mu.RUnlock()
out := ([]domain.Command, (f.dispatched))
(out, f.dispatched)
out
}
Fake Pattern: Config Port
For configuration/settings ports:
package fakes
import (
"sync"
"github.com/<org>/<project>/internal/core/port"
)
var _ port.ConfigProvider = (*FakeConfigProvider)(nil)
type FakeConfigProvider struct {
mu sync.RWMutex
errors map[string]error
values map[string]string
}
func NewFakeConfigProvider() *FakeConfigProvider {
return &FakeConfigProvider{
errors: make(map[string]error),
values: make(map[string]string),
}
}
func (f *FakeConfigProvider) SetError(methodName string, err error) {
f.mu.Lock()
defer f.mu.Unlock()
if err == nil {
delete(f.errors, methodName)
} else {
f.errors[methodName] = err
}
}
func (f *FakeConfigProvider) getError(methodName string) error {
f.mu.RLock()
defer f.mu.RUnlock()
return f.errors[methodName]
}
func Get(key ) (, ) {
err := f.getError(); err != {
, err
}
f.mu.RLock()
f.mu.RUnlock()
val, ok := f.values[key]
!ok {
, port.ErrConfigKeyNotFound
}
val,
}
GetAll() ([], ) {
err := f.getError(); err != {
, err
}
f.mu.RLock()
f.mu.RUnlock()
out := ([], (f.values))
k, v := f.values {
out[k] = v
}
out,
}
Reset() {
f.mu.Lock()
f.mu.Unlock()
f.values = ([])
f.errors = ([])
}
Seed(pairs []) {
f.mu.Lock()
f.mu.Unlock()
k, v := pairs {
f.values[k] = v
}
}
Mandatory Elements Checklist
Every generated fake MUST include:
- Compile-time check:
var _ port.X = (*FakeX)(nil)
- sync.RWMutex: field named
mu
- errors map:
errors map[string]error for injection
- Constructor:
NewFakeX() *FakeX that initializes all maps/slices
- SetError method:
SetError(methodName string, err error)
- getError method:
getError(methodName string) error (unexported)
- Every interface method: with error injection check as first line
- Reset method: clears all state and errors
- Seed/helper methods: at least one method to populate test data
- GoDoc comments: on the type, constructor, and public helpers
Naming Conventions
| Element | Convention | Example |
|---|
| File name | fake_{port_name_snake}.go | fake_repository.go |
| Struct | Fake{PortName} | FakeRepository |
| Constructor | NewFake{PortName}() | NewFakeRepository() |
| Error injector | SetError(methodName, err) | universal |
| State reset | Reset() | universal |
| Seed helper | Seed{Collection}(items...) | SeedItems(items...) |
| Count helper | Count() or {Collection}Count() | Count() |
| Snapshot helper | {Collection}() (returns copy) | Published() |
Concurrency Safety Rules
All fakes MUST be safe for t.Parallel():
- Use
sync.RWMutex — read lock for reads, write lock for mutations
getError uses RLock (read path)
SetError, Reset, Seed* use Lock (write path)
- Interface methods that only read use
RLock
- Interface methods that mutate use
Lock
- Return copies of slices/maps from snapshot helpers, never internal state
- Channel operations in event bus fakes must not hold the mutex during
blocking sends — copy subscriber list under lock, release, then send
Cyclomatic Complexity
Every function in generated fakes must have CC <= 5. The patterns above
naturally stay within this limit. If a port method requires branching logic
that would exceed CC 5, split into helper functions.
Step 4: Verify Compilation
After generating or updating fakes, verify they compile:
go build ./pkg/testutil/fakes/
go vet ./pkg/testutil/fakes/
If compilation fails, read the error output, fix import paths or type
mismatches, and re-verify until clean.
Step 5: Report
Print a summary after generation:
Fake Registry Update
====================
Scanned ports: {count} interfaces in internal/core/port/
Existing fakes: {count} in pkg/testutil/fakes/
Generated (new): {count} fakes
Updated (changed): {count} fakes
Skipped (current): {count} fakes
Compilation: PASS / FAIL
Details:
fake_repository.go NEW 5 methods port.Repository
fake_event_bus.go UPDATED 3 methods port.EventBus (+Subscribe)
fake_config_provider.go CURRENT 2 methods port.ConfigProvider
Consumers can import:
import "github.com/<org>/<project>/pkg/testutil/fakes"
Update Protocol
When a port interface changes (method added, removed, or signature changed):
- Run
/test-mock-registry {port-name} to regenerate just that fake
- The skill diffs the old fake against the new interface:
- Added method: Generate the method body following the pattern above
- Removed method: Delete the method from the fake
- Changed signature: Update the method signature and adjust internal logic
- Preserve custom seed/helper methods that were manually added (anything
below the
// --- Test helpers --- separator that isn't part of the
standard Reset/Seed pattern)
- Re-verify compilation:
go build ./pkg/testutil/fakes/
- Run all tests that import the fake to catch breakage:
Use the Grep tool to find files importing
testutil/fakes, then run
go test on the containing packages.
Error Injection Pattern (Detailed)
The SetError / getError pattern enables testing error-handling paths
without creating separate error-returning fake types:
repo := fakes.NewFakeRepository()
repo.SeedItems(domain.Item{ID: "1", Name: "test"})
repo.SetError("GetItem", errors.New("connection lost"))
_, err := service.GetItem(ctx, "1")
require.ErrorContains(t, err, "connection lost")
repo.SetError("GetItem", nil)
item, err := service.GetItem(ctx, "1")
require.NoError(t, err)
assert.Equal(t, "test", item.Name)
Every interface method checks for an injected error as its first operation,
before touching any internal state. This ensures error injection is:
- Atomic: Uses the mutex, safe for parallel tests
- Selective: Only the named method returns the error
- Reversible: Pass
nil to clear
- Non-destructive: Internal state is unchanged when an error is injected
Cross-References
@tdd-go — Consumes fakes for unit tests
@tdd-workflow — Language-agnostic TDD engine, consumes fakes
@test-design — Designs tests that specify which ports need fakes
@integration-test — Consumes fakes for cross-boundary tests
@testing — Runs go build / go vet verification on fake package
@test-functional — Consumes fakes for functional/smoke/regression tests
internal/core/port/ — Source of truth for all port interfaces
pkg/testutil/fakes/ — Output directory for all generated fakes