Testing
Test code that talks to a UniFi controller without a controller, using the generated unifi.ClientMock and the per-group Official API mocks.
Code that depends on go-unifi should depend on the unifi.Client interface, not the concrete client. That one
decision makes your code trivially testable: the library ships generated mocks that satisfy the interface, so your
tests run with no controller, no network, and no waiting.
There are two kinds of mock:
unifi.ClientMock— a moq-generated double for the whole Internal APIClient. Every method has a…Funcfield you stub and a…Calls()helper that records invocations.- The Official per-group doubles in
unifi/official(official.ClientMock,official.SitesClientMock,official.NetworksClientMock, …) — code-generated func-field test doubles (emitted by thecodegen/officialgenerator, not moq), one per resource group.
In both, an un-stubbed method panics when called, so a test that exercises an unexpected dependency fails loudly instead of returning a misleading zero value.
Depend on the interface
Write the code under test against unifi.Client:
func reportActiveNetworks(ctx context.Context, c unifi.Client) ([]string, error) {
nets, err := c.ListNetwork(ctx, "default")
if err != nil {
return nil, err
}
var names []string
for _, n := range nets {
if n.Enabled {
names = append(names, n.Name)
}
}
return names, nil
}A table-driven test with ClientMock
Stub only the method the code calls. Each …Func field is the behaviour for one method; the matching …Calls()
slice lets you assert how it was called.
func TestReportActiveNetworks(t *testing.T) {
tests := map[string]struct {
networks []unifi.Network
listErr error
want []string
wantErr bool
}{
"filters disabled": {
networks: []unifi.Network{
{Name: "LAN", Enabled: true},
{Name: "Guest", Enabled: false},
{Name: "IoT", Enabled: true},
},
want: []string{"LAN", "IoT"},
},
"propagates error": {
listErr: unifi.ErrNotFound,
wantErr: true,
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
c := &unifi.ClientMock{
ListNetworkFunc: func(_ context.Context, site string) ([]unifi.Network, error) {
if site != "default" {
t.Fatalf("unexpected site %q", site)
}
return tc.networks, tc.listErr
},
}
got, err := reportActiveNetworks(context.Background(), c)
if tc.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != len(tc.want) {
t.Fatalf("got %v, want %v", got, tc.want)
}
// Calls() helpers record every invocation and its arguments.
if n := len(c.ListNetworkCalls()); n != 1 {
t.Fatalf("ListNetwork called %d times, want 1", n)
}
if site := c.ListNetworkCalls()[0].Site; site != "default" {
t.Fatalf("called with site %q", site)
}
})
}
}Each …Calls() method returns a slice of anonymous structs whose fields are the named arguments
(ListNetworkCalls()[0].Site, IsFeatureEnabledCalls()[0].Name, and so on) — handy for asserting that your code
passed the right site, MAC, or payload.
You don't have to stub every method — only the ones your code path touches. Anything left nil panics if called,
which is usually what you want: it pins the test to exactly the controller calls you expect.
Returning sentinels from a mock
Because the mock returns whatever your …Func returns, you can drive the error branches from
Error handling directly:
func TestNotFound(t *testing.T) {
c := &unifi.ClientMock{
GetNetworkFunc: func(_ context.Context, _, _ string) (*unifi.Network, error) {
return nil, unifi.ErrNotFound
},
}
_, err := c.GetNetwork(context.Background(), "default", "missing")
if !errors.Is(err, unifi.ErrNotFound) {
t.Fatalf("want ErrNotFound, got %v", err)
}
}Mocking the Official surface
The Official API is a tree: c.Official() returns an official.Client, whose accessors (Sites(), Networks(),
…) return per-group clients. To mock it, build an official.ClientMock whose accessor returns the per-group mock,
then hand it to the top-level unifi.ClientMock via OfficialFunc:
func countSites(ctx context.Context, c unifi.Client) (int, error) {
page, err := c.Official().Sites().ListPage(ctx, nil)
if err != nil {
return 0, err
}
return page.TotalCount, nil
}
func TestCountSites(t *testing.T) {
sites := &official.SitesClientMock{
ListPageFunc: func(_ context.Context, _ *official.ListOptions) (official.Page[official.SiteOverview], error) {
return official.Page[official.SiteOverview]{
Items: []official.SiteOverview{{ID: uuid.New()}},
TotalCount: 3,
}, nil
},
}
off := &official.ClientMock{
SitesFunc: func() official.SitesClient { return sites },
}
c := &unifi.ClientMock{
OfficialFunc: func() official.Client { return off },
}
got, err := countSites(context.Background(), c)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != 3 {
t.Fatalf("got %d sites, want 3", got)
}
}The Official per-group mocks (official.SitesClientMock, official.NetworksClientMock, …) are func-field doubles
without …Calls() helpers — that recording machinery exists only on the moq-generated unifi.ClientMock. To
assert arguments on an Official mock, capture them in a closure variable inside the …Func you stub.
Keeping the mock current
unifi.ClientMock lives in client_mock.generated.go and is regenerated whenever the Client interface changes:
go generate ./unifi/...A compile-time var _ Client = &ClientMock{} assertion in that file guarantees the mock always satisfies the
interface, so a stale mock is caught at build time.
Next steps
Error Handling
React to UniFi controller failures with the ErrNotFound, Official-API, and old-style sentinels, the rich ServerError, and client-side ValidationError.
Advanced
Fine-tune the client beyond the basics — configuration, raw HTTP calls, interceptors, logging, validation, concurrency, and more.