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:
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:
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— passafero.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— anyDebug/Warnpair; capture with aslog.Handlerto assert on the fallback diagnostics, or leave nil to discard.Progress— anio.Writer, so the server's sideband output (including push rejection reasons) is captured into abytes.Bufferand can be asserted on.SSH.Path— a resolved path, so no test needst.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.