Test without launching a browser¶
This guide shows how to use WithOpener to inject a fake opener in tests, so
your test suite never spawns a real browser or mail client — and how to assert
on the URL that would have been opened.
The problem¶
By default, OpenURL delegates to the OS URL handler. In a test, that would
pop a real browser window (or fail on a headless CI machine). You want to
exercise your code's URL-opening path and check what it produced, without any
side effect on the machine running the tests.
Inject a fake with WithOpener¶
OpenURL accepts options, and WithOpener replaces the default OS opener with
any func(rawURL string) error you supply. Point it at a closure that records
the URL instead of opening it:
func TestOpensDocsURL(t *testing.T) {
var opened string
err := browser.OpenURL(context.Background(), "https://example.com/docs",
browser.WithOpener(func(rawURL string) error {
opened = rawURL
return nil
}),
)
if err != nil {
t.Fatalf("OpenURL returned an error: %v", err)
}
if opened != "https://example.com/docs" {
t.Errorf("opened %q, want the docs URL", opened)
}
}
The fake receives the URL only after validation has passed, so a captured value confirms the URL cleared the scheme allowlist, the length bound, and the control-character check.
Validation still runs — the fake is never called on a reject¶
WithOpener does not bypass the gate. If the URL is rejected, OpenURL returns
the sentinel error and your opener is never invoked — so you can assert that a
bad URL never reached the "browser":
func TestRejectsDisallowedScheme(t *testing.T) {
called := false
err := browser.OpenURL(context.Background(), "javascript:alert(1)",
browser.WithOpener(func(string) error {
called = true
return nil
}),
)
if !errors.Is(err, browser.ErrDisallowedScheme) {
t.Fatalf("want ErrDisallowedScheme, got %v", err)
}
if called {
t.Error("opener was called for a disallowed scheme")
}
}
Simulate an OS-opener failure¶
Return an error from the fake to exercise your code's handling of a failed open
(for example, a headless host with no browser). OpenURL wraps it, so match on
your own error, not on a sentinel:
sentinel := errors.New("no browser available")
err := browser.OpenURL(context.Background(), "https://example.com",
browser.WithOpener(func(string) error {
return sentinel
}),
)
if !errors.Is(err, sentinel) {
t.Fatalf("want the opener error to propagate, got %v", err)
}
A nil opener is ignored, not a way to disable opening¶
WithOpener(nil) does not clear the opener and does not switch opening off. The
nil is discarded and whatever is already configured stays in place — the default
OS opener if no earlier option set one, or an earlier WithOpener's function if
one did:
browser.OpenURL(ctx, u,
browser.WithOpener(fake.Open),
browser.WithOpener(nil), // ignored; fake.Open is still used
)
browser.OpenURL(ctx, u,
browser.WithOpener(nil), // ignored; the real OS opener is still used
)
The second call will launch a browser. If a test needs no browser to open, pass a
real no-op closure — never nil.
Where two non-nil openers are supplied, the last one wins.
The fake applies to one call only¶
There is no global opener to install or reset. WithOpener configures the single
OpenURL call it is passed to, which is what makes it safe under t.Parallel():
two tests can each intercept their own calls with no shared mutable state and
nothing to restore in a cleanup function.
The corollary is that every call site you want to intercept needs its own
WithOpener. If your code under test calls OpenURL internally, thread the
option through — for example by having the function under test take a
...browser.Option and pass it on.
See also¶
- Open URLs safely — the production-side discipline.
- API reference — the exact semantics of the option.
- Platform behaviour — what the default opener would have done, and why you do not want it in a test.
- Threat model & the validation gate — why the opener is a seam in the first place.