serpentiel

betterglobekey drives macOS through cgo — a CoreGraphics event tap, Text Input Sources, an AppKit HUD (how that compiles without Xcode is its own post) — and a third of its Go is tests. Those two facts don’t coexist through discipline; they coexist through design. A test cannot press the Globe key, cannot grant Accessibility, and cannot click the system dialog that asks for it. So the app is shaped around what a test can’t do: everything unautomatable is squeezed into a few hundred lines at the edge, and everything else doesn’t know macOS exists. Ports and adapters are usually sold as architecture taste. Here they’re the testing strategy — the only one available when your dependency is an operating system.

The domain shows the shape. The switching logic — what the Globe key actually does — is pure Go behind ports, and not just the obvious ones. Reading and selecting input sources is a port. So is the wall clock, because double-press detection is time math, and time math deserves a clock you control:

// InputSources is the port through which the switcher observes and controls
// the system's input sources.
type InputSources interface {
	Current() string
	All() []string
	Select(id string)
}

// Clock is the port through which the switcher reads the current time.
type Clock interface {
	Now() time.Time
}

The tests fake both: a fakeSources that records every Select call, and a fakeClock advanced by hand. A double press is two calls with one advance(250 * time.Millisecond) between them — not a time.Sleep and a prayer. One layer up, the daemon sees the keyboard itself as a port, and its fake is two channels: Listen closes one and blocks on the other, which is enough to prove the lifecycle starts and stops cleanly on a machine with no keyboard attached to the code at all.

The interesting decisions start where the fakes end — inside the cgo packages. You can’t unit-test a call into Carbon, but “untestable” is not one category. The suite splits the border into four tiers, each claiming no more than it can prove:

  • Pure logic that happens to live near cgo is tested normally. The keyboard package maps modifier names to event-flag masks; the test exercises the lookup and the unknown-name fallback, and never installs the tap.
  • Read-only calls get live smoke tests. All, Current, and Name run against the real Text Input Sources API on the CI runner. They’re deterministic for one reason: every macOS install has at least one input source. Select is never called — the suite refuses to change the machine’s keyboard layout as a side effect of running.
  • Permission-gated calls get callability tests. Whether the process is trusted for Accessibility depends on the host’s TCC grant, so the test asserts nothing about the value:
// TestTrustedIsCallable exercises the Accessibility query. Its result depends
// on the host's grant (false in a plain test process, true under a granted
// daemon), so only its callability is asserted.
func TestTrustedIsCallable(_ *testing.T) {
	_ = Trusted()
}

And Prompt is never exercised at all — it opens a system dialog, and CI would wait for a click that never comes.

  • The rest is untested on purpose. Installing the tap, the Objective-C files, the HUD — a test cannot press fn. That code is held to one framework call per function, thin enough that a bug has nowhere to sit: it works on the first manual run or it fails loudly.

CI makes the border visible from the outside: every job that touches Go runs on macos-latest, and not only for the tests — cgo against CoreGraphics doesn’t compile anywhere else. The lint workflow carries the comment “macOS so golangci-lint can compile the macOS-only cgo.” When your linter needs a specific operating system, the border is real.

The usual question is “how do I mock cgo?” — and it’s the wrong question. You don’t mock the border. You move it: shrink the foreign side until there’s nothing left in it worth testing, then test everything that remains.

comments