Skip to content

Test with the role mocks

Two options, and the choice matters more than which mock library you use:

You want to assert… Use
Interactions — that your code called Push, or handled ErrNoWorktree a role mock
Behaviour — that the resulting commit is correct an in-memory repository

A mock proves you called git; only a real repository proves you called it correctly. Reach for the mock when the git operation is incidental to what you are testing, and for the real thing when it is the point.

Mock the narrowest role

Mocks for all eleven exported roles are published in the module's mocks subpackage, so you never hand-roll one:

import repomocks "gitlab.com/phpboyscout/go/repo/mocks"

Alias the import — mocks alone collides the moment a test needs mocks from two modules.

Because your function should take the narrowest role it actually needs, the mock you construct is small:

func TestIndexTree(t *testing.T) {
    t.Parallel()

    tree := repomocks.NewMockTreeReader(t)
    tree.EXPECT().
        WalkTree(mock.Anything).
        Return(nil).
        Once()

    require.NoError(t, indexTree(tree))
}

NewMockTreeReader(t) registers cleanup, so unmet expectations fail the test on their own. Had indexTree taken RepoLike, this test would need a mock satisfying all nine composed roles to exercise one method.

Test the failure paths

Every method has a defined unopened state, which makes error handling cheap to cover without contriving a broken repository:

tree.EXPECT().
    GetFile("go.mod").
    Return(nil, repo.ErrNoRepository).
    Once()

Assert with errors.Is — the sentinels are wrapped, not returned bare.

Prefer a real repository for behaviour

An in-memory repository is a real repository with no disk and no network, so it is fast and hermetic enough for unit tests while running the real go-git code paths — no mock to drift out of step with reality:

r, err := repo.NewRepo(repo.Settings{FS: afero.NewMemMapFs()})
require.NoError(t, err)

_, _, err = r.OpenInMemory(fixtureURL, "main")
require.NoError(t, err)

fs, err := r.WorkFS()
require.NoError(t, err)

require.NoError(t, afero.WriteFile(fs, "VERSION", []byte("v2"), 0o644))
require.NoError(t, r.AddAll())

_, err = r.Commit("bump", nil)
require.NoError(t, err)

Everything is injectable, so tests need no global state

Each seam exists partly so a test can control it without touching the process:

  • FS — pass afero.NewMemMapFs() and SSH key files are read from memory.
  • Token — a closure, so a test can supply a literal, or one that panics if called to prove the SSH path never resolves a token.
  • Logger — any Debug/Warn pair; capture with a slog.Handler to assert on the fallback diagnostics, or leave nil to discard.
  • Progress — an io.Writer, so the server's sideband output (including push rejection reasons) is captured into a bytes.Buffer and can be asserted on.
  • SSH.Path — a resolved path, so no test needs t.Setenv.

That last point is what keeps these tests t.Parallel()-safe. t.Setenv and t.Parallel are mutually exclusive in Go's testing package, so any package reading configuration from the environment forces its consumers' tests to run serially. Nothing here does — see why.