When you're building a Flutter app, dependency injection is usually a solved problem. Pull in get_it, injectable, or provider, register your services at app startup, and resolve them wherever you need them. Easy.
But the moment you start writing a package — something other developers will pull into their own apps — that approach falls apart. You can't force get_it on your consumers. You don't control their app's composition root. You can't even assume they use a service locator at all.
So how do you write package code that is:
The answer, surprisingly often, is a humble annotation from package:meta: @visibleForTesting.

The Package Author's Dilemma
Let's say you're writing a small package that fetches and caches user preferences from a remote server. Internally it needs:
In an app, you'd register all three with get_it and inject them. In a package, you can't. Your public API needs to look something like this:
final prefs = await UserPrefsClient().fetch(userId);
That's it. No setup, no registration, no DI container. Anything more and you're pushing your architectural choices onto every consumer of your package.
But internally, UserPrefsClient clearly needs an HTTP client, a cache, and a clock. And in tests, you absolutely need to replace all three — you can't have your test suite hitting a real API.
Why Constructor Injection Alone Isn't Enough
The first instinct is constructor injection:
class UserPrefsClient {
UserPrefsClient({
required this.http,
required this.cache,
required this.clock,
});
final http.Client http;
final Cache cache;
final Clock clock;
}
This is testable, but now your public API has exploded. Consumers have to know about http.Client, Cache, and Clock — internal implementation details that have nothing to do with the package's purpose. Even worse, you've leaked your dependencies as part of your stable API. Change Cache to Storage in v2 and you've broken everyone.
Enter @visibleForTesting
The pattern that solves this is:
1. Default the dependencies in the public constructor so consumers don't see them.
2. Expose an internal constructor annotated with @visibleForTesting so tests can swap them out.
import 'package:http/http.dart' as http;
import 'package:meta/meta.dart';
class UserPrefsClient {
/// Public constructor — what consumers use.
UserPrefsClient()
: _http = http.Client(),
_cache = DefaultCache(),
_clock = const SystemClock();
/// Test-only constructor. Do not use in production code.
@visibleForTesting
UserPrefsClient.test({
required http.Client http,
required Cache cache,
required Clock clock,
}) : _http = http,
_cache = cache,
_clock = clock;
final http.Client _http;
final Cache _cache;
final Clock _clock;
Future<UserPrefs> fetch(String userId) async {
// ... uses _http, _cache, _clock
}
}
Now consumers see exactly one constructor: UserPrefsClient(). No surface area pollution. Meanwhile, in your test file:
test('returns cached prefs when fresh', () async {
final client = UserPrefsClient.test(
http: MockHttpClient(),
cache: FakeCache({'user-1': freshPrefs}),
clock: FakeClock(DateTime(2026, 1, 1)),
);
final result = await client.fetch('user-1');
expect(result, equals(freshPrefs));
});
The Dart analyzer enforces the boundary. If a consumer ever tries to call UserPrefsClient.test(...) from outside a *_test.dart file (or outside your own package), they get a warning along the lines of:
The member UserPrefsClient.test can only be used within its package or a test.
It's not a hard compile error, but it's loud enough that no one does it by accident.
The Setter Variant
For dependencies that need to be swappable mid-test — or for cases where you don't want to introduce a second constructor — you can use a @visibleForTesting setter instead:
class UserPrefsClient {
UserPrefsClient();
http.Client _http = http.Client();
Cache _cache = DefaultCache();
Clock _clock = const SystemClock();
@visibleForTesting
set debugHttpClient(http.Client value) => _http = value;
@visibleForTesting
set debugCache(Cache value) => _cache = value;
@visibleForTesting
set debugClock(Clock value) => _clock = value;
// ...
}
This is the pattern Flutter itself uses extensively. Look at the framework's source and you'll see @visibleForTesting on properties like debugDefaultTargetPlatformOverride, binding instance hooks, and many internal seams. The Flutter team learned long ago that mockable surface area should be visible* to tests and *invisible to everyone else.
Use the setter variant when:
Use the named constructor variant when:
Static Dependencies: The Tricky Case
What about static or global-ish dependencies? Maybe your package has a top-level function:
Future<bool> hasNetwork() async {
final result = await Connectivity().checkConnectivity();
return result != ConnectivityResult.none;
}
You can't constructor-inject into a top-level function. The trick is to lift the dependency into a library-private variable with a @visibleForTesting setter:
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:meta/meta.dart';
Connectivity _connectivity = Connectivity();
@visibleForTesting
set debugConnectivityOverride(Connectivity? value) {
_connectivity = value ?? Connectivity();
}
Future<bool> hasNetwork() async {
final result = await _connectivity.checkConnectivity();
return result != ConnectivityResult.none;
}
This is exactly how package:flutter exposes things like debugDefaultTargetPlatformOverride. The debug prefix is a strong naming convention — anyone reading the call site knows immediately this isn't production usage.
Always reset these in tearDown:
tearDown(() {
debugConnectivityOverride = null;
});
Otherwise, test order starts mattering, and you'll spend an afternoon you don't have debugging flaky tests.
What @visibleForTesting Actually Does
It's worth being clear about the mechanism, because it's not magic. @visibleForTesting is purely an analyzer hint. At runtime, an annotated member is a perfectly ordinary public member — anyone can call it. The annotation simply tells the analyzer:
Emit a warning if this is referenced from a file that isn't undertest/orintegration_test/, and isn't in the same package.
That's it. No bytecode hiding, no tree-shaking trickery. The protection is social, not technical. But that's enough. The warning is loud, the convention is well-known, and no reasonable consumer is going to suppress an analyzer warning just to reach into your private state.
The companion annotations work the same way:
@visibleForOverriding — for members only meant to be overridden by subclasses@protected — for members that should only be touched within subclasses of the declaring class@internal — for declarations that should only be referenced from within the same packageCombine them as needed.
Patterns to Avoid
A few traps worth flagging.
Don't expose mutable state without a reset. If you let tests override a dependency, give them a way to put it back. The cleanest API is a setter that accepts null to restore the default.
Don't use @visibleForTesting as a workaround for poor design. If you find yourself annotating six different setters just to make one method testable, the method is probably doing too much. Refactor into smaller units first.
Don't leak test types into the public surface. Your @visibleForTesting setters should accept production interfaces (http.Client, Cache), not test doubles (MockHttpClient). Otherwise consumers see test infrastructure in their autocomplete.
Don't forget the analyzer. If a consumer's pubspec doesn't have lints enabled, they won't see the warning. That's fine — it's their app, not your problem — but make sure your own package has analysis_options.yaml configured with package:lints/recommended.yaml so the warning fires during your own development.
Conclusion
Dependency injection in apps is about wiring. Dependency injection in packages is about boundaries — what does the consumer see, and what stays private? Service locators like get_it are great inside an app you control, but they're the wrong tool when you're publishing a library: you'd be forcing every consumer to adopt your DI choices just to use your code.
@visibleForTesting lets you have the best of both worlds: a clean, minimal public API with zero DI ceremony for consumers, and full test-time control over every collaborator inside. It's how the Flutter framework itself stays testable, and it scales from a five-line utility package to a multi-module SDK.
Next time you're tempted to expose a dependency "just for testing," reach for the annotation. The analyzer will keep everyone honest.
Happy coding : )



Loading…