Testing with varlinktest¶
Code that talks to systemd is usually hard to test: you need root, the right
systemd version, and a host you're allowed to change. varlinktest removes
all three. It runs a fake Varlink service on a temporary Unix socket,
answering from handlers you register. Your code connects to it exactly as it
would to systemd: same wire protocol, same fd passing, same errors.
func TestResolve(t *testing.T) {
srv := varlinktest.NewServer(t) // (1)!
srv.Handle("io.systemd.Resolve.ResolveHostname", // (2)!
func(in resolve.ResolveHostnameIn) (resolve.ResolveHostnameOut, error) {
return resolve.ResolveHostnameOut{
Name: in.Name,
Addresses: []resolve.ResolvedAddress{{Family: 2, Address: []int64{127, 0, 0, 1}}},
}, nil
})
c, err := resolve.DialPath(context.Background(), srv.Path()) // (3)!
if err != nil {
t.Fatal(err)
}
defer c.Close()
out, err := c.ResolveHostname(context.Background(), resolve.ResolveHostnameIn{Name: "db.internal"})
if err != nil {
t.Fatal(err)
}
if out.Name != "db.internal" || len(out.Addresses) != 1 {
t.Fatalf("out = %+v", out)
}
}
- Listens on a fresh socket under the system temp dir and shuts down, closing
every connection, when the test ends (
t.Cleanup). - Handlers are keyed by the fully qualified method name.
- Point the code under test at
srv.Path()instead of the default socket. That's the only seam you need.
Design for the seam
Accept a client (or a varlink.Caller) instead of dialing inside your
functions, as the testing-with-fake example
does with its Resolver{Client: ...} struct. Production code passes
resolve.Dial(ctx); tests pass resolve.DialPath(ctx, srv.Path()).
Registering handlers¶
Three styles, from most convenient to most control:
Handle accepts a function of any of these shapes, where In and Out
are structs (or pointers to structs):
func(In) (Out, error)
func(context.Context, In) (Out, error)
func(context.Context, In) iter.Seq2[Out, error] // streaming
srv.Handle("io.systemd.Hostname.Describe",
func(hostname.DescribeIn) (hostname.DescribeOut, error) {
return hostname.DescribeOut{
Hostname: "test-host",
KernelName: "Linux",
KernelRelease: "6.14.0",
MachineID: "4b0e31a7c1f24f0c8f6d51c0b3a2e9d4",
}, nil
})
A mismatched signature panics at registration, so mistakes show up on the first run.
The generic functions are checked at compile time:
varlinktest.On(srv, "io.systemd.Hostname.Describe",
func(ctx context.Context, in hostname.DescribeIn) (hostname.DescribeOut, error) {
return hostname.DescribeOut{Hostname: "test-host"}, nil
})
varlinktest.OnStream(srv, "io.systemd.UserDatabase.GetUserRecord",
func(ctx context.Context, in userdatabase.GetUserRecordIn) iter.Seq2[userdatabase.GetUserRecordOut, error] {
return func(yield func(userdatabase.GetUserRecordOut, error) bool) {
yield(userdatabase.GetUserRecordOut{Record: json.RawMessage(`{"userName":"root"}`)}, nil)
}
})
Raw handlers see the call as received and write Responses, so you can
send any JSON, including fields the bindings don't know:
srv.HandleRaw("io.systemd.Resolve.ResolveHostname",
func(ctx context.Context, call *varlinktest.Call) iter.Seq2[*varlinktest.Response, error] {
return func(yield func(*varlinktest.Response, error) bool) {
// call.Method, call.Parameters, call.More, call.Oneway, call.Files
yield(&varlinktest.Response{Parameters: json.RawMessage(
`{"name":"a","flags":1,"addresses":[],"addedInSystemd300":true}`)}, nil)
}
})
That's how sdvarlink's own tests check that unknown fields land in Extra.
Handlers run on the server's goroutines, one per connection, so make shared state safe for concurrent use, just as you would in a real service.
Errors¶
Return an error from a handler and the client receives the matching Varlink error reply:
| Handler returns | Client sees |
|---|---|
A generated error type, e.g. &resolve.NoSuchResourceRecordError{} |
That typed error, with its parameters. errors.Is(err, resolve.ErrNoSuchResourceRecord) holds. |
*varlink.Error{Name: ..., Parameters: ...} |
An error reply with exactly that name and parameters. |
A syscall.Errno, e.g. syscall.EACCES |
io.systemd.System with that errno: a *varlink.SystemError where errors.Is(err, syscall.EACCES) holds. |
| Any other Go error | io.github.rossedman.sdvarlink.varlinktest.HandlerError (varlinktest.HandlerError) with {"message": err.Error()}. |
func TestPermissionDenied(t *testing.T) {
srv := varlinktest.NewServer(t)
srv.Handle("io.systemd.Journal.Rotate", func(struct{}) (struct{}, error) {
return struct{}{}, syscall.EPERM
})
c, err := journal.DialPath(context.Background(), srv.Path())
if err != nil {
t.Fatal(err)
}
defer c.Close()
err = c.Rotate(context.Background())
if !errors.Is(err, syscall.EPERM) {
t.Fatalf("err = %v, want EPERM", err)
}
}
func TestDNSSECFailure(t *testing.T) {
srv := varlinktest.NewServer(t)
srv.Handle("io.systemd.Resolve.ResolveHostname",
func(resolve.ResolveHostnameIn) (resolve.ResolveHostnameOut, error) {
return resolve.ResolveHostnameOut{}, &resolve.DNSSECValidationFailedError{Result: "invalid"}
})
c, _ := resolve.DialPath(context.Background(), srv.Path())
defer c.Close()
_, err := c.ResolveHostname(context.Background(), resolve.ResolveHostnameIn{Name: "x"})
var dv *resolve.DNSSECValidationFailedError
if !errors.As(err, &dv) || dv.Result != "invalid" {
t.Fatalf("err = %v", err)
}
}
Methods without a handler fail like a real service would:
org.varlink.service.MethodNotFound if the interface is known (registered via
a handler or AddInterface), otherwise org.varlink.service.InterfaceNotFound.
If the input doesn't decode into your handler's In type, the client gets
org.varlink.service.InvalidParameter.
Asserting on calls¶
The server records every call it receives, in order. Assert on what your code actually sent, not just on what it did with the reply:
func TestSendsFamily(t *testing.T) {
srv := varlinktest.NewServer(t)
srv.Handle("io.systemd.Resolve.ResolveHostname",
func(in resolve.ResolveHostnameIn) (resolve.ResolveHostnameOut, error) {
return resolve.ResolveHostnameOut{Name: in.Name}, nil
})
c, _ := resolve.DialPath(context.Background(), srv.Path())
defer c.Close()
c.ResolveHostname(context.Background(), resolve.ResolveHostnameIn{
Name: "example.com",
Family: varlink.Ptr(int64(2)),
})
calls := srv.Calls()
if len(calls) != 1 {
t.Fatalf("%d calls", len(calls))
}
got := calls[0]
if got.Method != "io.systemd.Resolve.ResolveHostname" || got.More {
t.Fatalf("call = %+v", got)
}
// Optional fields that weren't set are omitted; required ones are always sent.
if string(got.Parameters) != `{"name":"example.com","family":2}` {
t.Fatalf("parameters = %s", got.Parameters)
}
}
Each varlinktest.Call has Method, Parameters (raw JSON), More and
Oneway. Decode Parameters into a struct for field-level assertions.
Introspection and Supports¶
Register an interface's IDL and the fake answers GetInfo,
GetInterfaceDescription and therefore Supports like the real service.
Generated packages embed their IDL as InterfaceDescription, so you can make a
fake behave like a specific systemd release:
func TestNeedsV260(t *testing.T) {
srv := varlinktest.NewServer(t)
srv.AddInterface(resolve257.InterfaceDescription) // behave like systemd v257
c, err := resolve257.DialPath(context.Background(), srv.Path())
if err != nil {
t.Fatal(err)
}
defer c.Close()
ok, err := c.Supports(context.Background(), "DumpDNSConfiguration")
if err != nil || ok {
t.Fatalf("Supports = %v, %v; v257 has no DumpDNSConfiguration", ok, err)
}
// ... now assert your code takes its fallback path.
}
Streaming fakes¶
Return an iter.Seq2 and each value becomes one reply; the last is sent
without continues. Stop early when yield returns false (the client went
away):
func TestListUsers(t *testing.T) {
srv := varlinktest.NewServer(t)
users := []string{"root", "ross", "nobody"}
srv.Handle("io.systemd.UserDatabase.GetUserRecord",
func(ctx context.Context, in userdatabase.GetUserRecordIn) iter.Seq2[userdatabase.GetUserRecordOut, error] {
return func(yield func(userdatabase.GetUserRecordOut, error) bool) {
for _, u := range users {
rec, _ := json.Marshal(map[string]string{"userName": u})
if !yield(userdatabase.GetUserRecordOut{Record: rec}, nil) {
return
}
}
}
})
c, err := userdatabase.DialPath(context.Background(), srv.Path())
if err != nil {
t.Fatal(err)
}
defer c.Close()
var got []string
in := userdatabase.GetUserRecordIn{Service: "io.systemd.Multiplexer"}
for out, err := range c.GetUserRecordMore(context.Background(), in) {
if err != nil {
t.Fatal(err)
}
var rec struct {
UserName string `json:"userName"`
}
json.Unmarshal(out.Record, &rec)
got = append(got, rec.UserName)
}
if !slices.Equal(got, users) {
t.Fatalf("got %v", got)
}
}
To end a stream with an error after some replies (as machined does with
NoSuchMachine), yield the error: yield(zero, &machine.NoSuchMachineError{}).
File-descriptor fixtures¶
Fakes send and receive descriptors too, so fd-handling code is testable without root. Inside a handler:
- Received files (fd fields of
In) belong to the handler: close them. - Returned files (fd fields of
Out, orResponse.Files) are closed by the server after they're sent, like a real service giving up its copy.
func TestMountImage(t *testing.T) {
srv := varlinktest.NewServer(t)
srv.Handle("io.systemd.MountFileSystem.MountImage",
func(in mountfilesystem.MountImageIn) (mountfilesystem.MountImageOut, error) {
defer in.ImageFileDescriptor.Close()
// Hand back a fresh descriptor for the same file as the "mount".
mnt, err := os.Open(fmt.Sprintf("/proc/self/fd/%d", in.ImageFileDescriptor.Fd()))
if err != nil {
return mountfilesystem.MountImageOut{}, err
}
return mountfilesystem.MountImageOut{
SingleFileSystem: true,
Partitions: []mountfilesystem.PartitionInfo{{
Designator: mountfilesystem.PartitionDesignatorRoot,
FileSystemType: "ext4",
MountFileDescriptor: mnt,
}},
}, nil
})
img, err := os.CreateTemp(t.TempDir(), "img")
if err != nil {
t.Fatal(err)
}
defer img.Close()
img.WriteString("ext4!")
c, _ := mountfilesystem.DialPath(context.Background(), srv.Path())
defer c.Close()
out, err := c.MountImage(context.Background(), mountfilesystem.MountImageIn{ImageFileDescriptor: img})
if err != nil {
t.Fatal(err)
}
m := out.Partitions[0].MountFileDescriptor
defer m.Close()
m.Seek(0, io.SeekStart)
if b, _ := io.ReadAll(m); string(b) != "ext4!" {
t.Fatalf("read %q through the returned fd", b)
}
// The JSON carried an index, not a file.
if p := string(srv.Calls()[0].Parameters); p != `{"imageFileDescriptor":0}` {
t.Fatalf("parameters = %s", p)
}
}
Note
/proc/self/fd makes this test Linux-only. On other systems, return a
temp file you opened in the handler instead.
Raw connections to the fake¶
srv.Dial() returns a *varlink.Conn to the server that's closed when the
test ends, handy for testing core-level code:
type echo struct {
Text string `json:"text"`
}
func TestRaw(t *testing.T) {
srv := varlinktest.NewServer(t)
srv.Handle("com.example.Echo.Echo", func(in echo) (echo, error) { return in, nil })
conn := srv.Dial()
var out echo
err := conn.Call(context.Background(), "com.example.Echo.Echo", echo{Text: "hi"}, &out)
if err != nil || out.Text != "hi" {
t.Fatalf("out = %+v, err = %v", out, err)
}
}
The fake isn't limited to io.systemd.*: any interface name works, which makes
varlinktest handy for testing your own Varlink clients too.
Record and replay¶
Hand-written fakes drift from reality. Record and Replay close the gap:
record a session against real systemd once, commit the golden file, and
replay it in CI anywhere.
flowchart LR
subgraph rec ["go test -update (on a systemd host)"]
T1["test code"] --> R["varlinktest.Record<br/>(fake socket)"] --> J["real journald"]
R -. "writes" .-> G[("testdata/journal.json")]
end
subgraph rep ["go test (anywhere, CI)"]
T2["test code"] --> P["varlinktest.Replay<br/>(fake socket)"]
G -. "reads" .-> P
end
varlinktest.Record(t, upstream, golden)starts a fake that forwards every call to the real socket atupstream(fds included), and writes the exchanges togoldenas indented JSON when the test ends.varlinktest.Replay(t, golden)starts a fake that answers from the file. Calls must arrive in the recorded order per method, with equal parameters (compared as JSON); anything else fails the test.
The record-replay example checks journald's health. Its test switches mode on a flag:
package health
import (
"context"
"flag"
"strings"
"testing"
"github.com/rossedman/sdvarlink/systemd/v257/journal"
"github.com/rossedman/sdvarlink/varlinktest"
)
var update = flag.Bool("update", false, "record testdata/journal.json against the live journald")
func TestCheckJournal(t *testing.T) {
var srv *varlinktest.Server
if *update {
srv = varlinktest.Record(t, journal.DefaultSocket, "testdata/journal.json")
} else {
srv = varlinktest.Replay(t, "testdata/journal.json")
}
c, err := journal.DialPath(context.Background(), srv.Path())
if err != nil {
t.Fatal(err)
}
defer c.Close()
rep, err := CheckJournal(context.Background(), c)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(rep.Product, "journald") {
t.Fatalf("report = %+v", rep)
}
}
The golden file below was recorded against a real systemd-journald v255.
It's plain JSON, easy to review in a pull request:
[
{
"call": {
"method": "org.varlink.service.GetInfo",
"parameters": {}
},
"replies": [
{
"vendor": "The systemd Project",
"product": "systemd (systemd-journald)",
"version": "255 (255.4-1ubuntu8.17)",
"url": "https://systemd.io/",
"interfaces": [
"io.systemd",
"io.systemd.Journal",
"org.varlink.service"
]
}
]
},
{
"call": {
"method": "io.systemd.Journal.Synchronize",
"parameters": {}
},
"replies": [
{}
]
}
]
go test ./examples/record-replay/ # replay: runs anywhere
sudo go test ./examples/record-replay/ -update # re-record against the live journald
What's recorded
Calls, parameters, replies and error replies. File descriptors are forwarded while recording but not stored, so replayed fd methods return replies without descriptors.
Without a socket: fake Callers¶
Generated clients accept any varlink.Caller via New. For the smallest
possible unit tests, or to inject transport failures, implement it directly:
// failingCaller fails every call with err.
type failingCaller struct{ err error }
func (f failingCaller) Call(ctx context.Context, method string, in, out any, opts ...varlink.CallOption) error {
return f.err
}
func (f failingCaller) Stream(ctx context.Context, method string, in any, opts ...varlink.CallOption) iter.Seq2[*varlink.Reply, error] {
return func(yield func(*varlink.Reply, error) bool) { yield(nil, f.err) }
}
func (f failingCaller) Oneway(ctx context.Context, method string, in any, opts ...varlink.CallOption) error {
return f.err
}
func TestResolverHandlesOutage(t *testing.T) {
c := resolve.New(failingCaller{err: io.ErrUnexpectedEOF})
_, err := c.ResolveHostname(context.Background(), resolve.ResolveHostnameIn{Name: "x"})
if !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("err = %v", err)
}
}
Checklist¶
- One
NewServerper test: they're cheap and isolated, andt.Parallel()is fine. -
AddInterface(pkg.InterfaceDescription)when the code under test callsSupports. - Assert on
srv.Calls()for anything your code sends. - Return generated error types, not strings, to exercise error paths.
- Record a golden file against real systemd for behavior you don't want to guess.