Back to Blog
FlutterTesting Ability

Sức mạnh của `@visibleForTesting`: cách mock dependencies trong Flutter packages

Khi build một Flutter app, dependency injection thường là bài toán đã có lời giải. Kéo get_it, injectable, hay provider vào, đăng ký service khi app khởi động, rồi resolve ở bất cứ đâu cần. Đơn giản.

Nhưng ngay khi bạn bắt đầu viết một package — thứ mà các developer khác sẽ kéo vào app của họ — cách tiếp cận đó sụp đổ. Bạn không thể ép get_it lên consumer. Bạn không kiểm soát composition root của app họ. Thậm chí bạn không thể giả định họ dùng service locator hay không.

Vậy làm sao để viết code package vừa:

  • Dễ dùng cho consumer (không cần ceremony setup)
  • Gọn gàng bên trong (với các collaborator và dependency riêng)
  • Vẫn testable, để bạn có thể mock network, file system, hay bất cứ thứ gì package chạm vào?
  • Câu trả lời, khá thường xuyên, là một annotation khiêm tốn từ package:meta: @visibleForTesting.

    The Power of visibleForTesting - How to Mock Dependencies in Flutter Packages.png

    Bài Toán Của Người Viết Package

    Giả sử bạn đang viết một package nhỏ dùng để fetch và cache user preferences từ một remote server. Bên trong nó cần:

  • Một HTTP client để gọi API
  • Một tầng local storage để cache kết quả
  • Một clock để quyết định khi nào cache cũ
  • Trong một app, bạn sẽ đăng ký cả ba với get_it và inject chúng vào. Trong một package thì không thể. Public API của bạn cần trông như thế này:

    final prefs = await UserPrefsClient().fetch(userId);
    

    Chỉ vậy thôi. Không setup, không đăng ký, không DI container. Thêm gì nữa là bạn đang đẩy lựa chọn kiến trúc của mình lên mọi consumer.

    Nhưng bên trong, UserPrefsClient rõ ràng cần HTTP client, cache, và clock. Và trong test, bạn chắc chắn phải thay thế cả ba — bạn không thể để test suite gọi thẳng vào API thật.

    Tại Sao Constructor Injection Một Mình Không Đủ

    Phản xạ đầu tiên là constructor injection:

    class UserPrefsClient {
      UserPrefsClient({
        required this.http,
        required this.cache,
        required this.clock,
      });
    
      final http.Client http;
      final Cache cache;
      final Clock clock;
    }
    

    Cách này testable, nhưng public API của bạn vừa phình ra. Consumer phải biết về http.Client, Cache, và Clock — những implementation detail bên trong chẳng liên quan gì đến mục đích của package. Tệ hơn, bạn đã để lộ dependency thành một phần của stable API. Đổi Cache thành Storage ở v2 và bạn phá vỡ mọi người.

    Nhập Cuộc: @visibleForTesting

    Pattern giải quyết điều này là:

    1. Đặt default cho dependency trong public constructor để consumer không nhìn thấy chúng.
    2. Expose một internal constructor được annotate bằng @visibleForTesting để test có thể hoán đổi chúng.

    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
      }
    }
    

    Giờ consumer chỉ thấy đúng một constructor: UserPrefsClient(). Không bị ô nhiễm surface area. Trong khi đó, trong test file của bạn:

    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));
    });
    

    Dart analyzer thực thi ranh giới này. Nếu consumer cố gắng gọi UserPrefsClient.test(...) từ bên ngoài file *_test.dart (hoặc bên ngoài package của bạn), họ sẽ nhận được cảnh báo đại loại:

    The member UserPrefsClient.test can only be used within its package or a test.

    Không phải lỗi compile cứng, nhưng đủ ồn ào để không ai vô tình làm vậy.

    Biến Thể Dùng Setter

    Với những dependency cần hoán đổi được trong lúc test — hoặc khi bạn không muốn thêm constructor thứ hai — bạn có thể dùng @visibleForTesting setter thay thế:

    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;
    
      // ...
    }
    

    Đây là pattern mà chính Flutter sử dụng rộng rãi. Nhìn vào source code của framework, bạn sẽ thấy @visibleForTesting trên các property như debugDefaultTargetPlatformOverride, binding instance hooks, và nhiều internal seam khác. Flutter team hiểu từ lâu rằng mockable surface area phải nhìn thấy được* với test và *vô hình với mọi người khác.

    Dùng biến thể setter khi:

  • Bạn cần hoán đổi dependency sau khi construction
  • Bạn muốn một canonical constructor duy nhất
  • Dependency là true singleton được set một lần trong test setup
  • Dùng biến thể named constructor khi:

  • Bạn muốn truyền tất cả dependency vào cùng một lúc
  • Bạn không muốn bất kỳ mutation nào sau construction
  • Immutability quan trọng với bạn
  • Static Dependencies: Trường Hợp Khó

    Còn với static hay global-ish dependency thì sao? Có thể package của bạn có một top-level function:

    Future<bool> hasNetwork() async {
      final result = await Connectivity().checkConnectivity();
      return result != ConnectivityResult.none;
    }
    

    Bạn không thể constructor-inject vào một top-level function. Thủ thuật là nâng dependency lên thành một library-private variable với @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;
    }
    

    Đây chính xác là cách package:flutter expose những thứ như debugDefaultTargetPlatformOverride. Tiền tố debug là một naming convention mạnh — bất kỳ ai đọc call site đều hiểu ngay đây không phải production usage.

    Luôn reset những thứ này trong tearDown:

    tearDown(() {
      debugConnectivityOverride = null;
    });
    

    Nếu không, thứ tự test bắt đầu quan trọng, và bạn sẽ mất cả buổi chiều debug flaky test.

    @visibleForTesting Thực Sự Làm Gì

    Đáng hiểu rõ cơ chế, vì nó không phải ma thuật. @visibleForTesting thuần túy là một analyzer hint. Ở runtime, một member được annotate là một public member hoàn toàn bình thường — ai cũng có thể gọi. Annotation chỉ báo cho analyzer:

    Emit cảnh báo nếu thứ này được tham chiếu từ một file không nằm trong test/ hay integration_test/, và không thuộc cùng package.

    Chỉ vậy. Không có bytecode ẩn, không có tree-shaking trick. Sự bảo vệ mang tính xã hội, không phải kỹ thuật. Nhưng vậy là đủ. Cảnh báo đủ ồn ào, convention đủ nổi tiếng, và không consumer nào hợp lý sẽ suppress một analyzer warning chỉ để chọc vào internal state của bạn.

    Các annotation đồng hành hoạt động tương tự:

  • @visibleForOverriding — cho member chỉ dành để subclass override
  • @protected — cho member chỉ nên được chạm vào trong subclass của class khai báo
  • @internal — cho declaration chỉ nên được tham chiếu từ trong cùng package
  • Kết hợp chúng khi cần.

    Những Pattern Cần Tránh

    Một vài bẫy đáng lưu ý.

    Đừng expose mutable state mà không có cách reset. Nếu bạn để test override một dependency, hãy cho chúng cách đặt lại. API gọn nhất là setter chấp nhận null để khôi phục default.

    Đừng dùng @visibleForTesting như workaround cho thiết kế kém. Nếu bạn thấy mình phải annotate sáu setter khác nhau chỉ để một method testable được, method đó có lẽ đang làm quá nhiều thứ. Refactor thành unit nhỏ hơn trước.

    Đừng để lộ test type ra public surface. Các @visibleForTesting setter của bạn nên chấp nhận production interface (http.Client, Cache), không phải test double (MockHttpClient). Nếu không, consumer sẽ thấy test infrastructure trong autocomplete của họ.

    Đừng quên analyzer. Nếu pubspec của consumer không bật lint, họ sẽ không thấy cảnh báo. Không sao — đó là app của họ, không phải vấn đề của bạn — nhưng hãy đảm bảo package của mình có analysis_options.yaml được cấu hình với package:lints/recommended.yaml để cảnh báo bắn ra trong lúc bạn phát triển.

    Kết

    Dependency injection trong app là về việc nối dây. Dependency injection trong package là về ranh giới — consumer nhìn thấy gì, và gì ở lại private? Service locator như get_it rất tốt bên trong một app bạn kiểm soát, nhưng lại là công cụ sai khi bạn publish một library: bạn sẽ buộc mọi consumer phải chấp nhận lựa chọn DI của mình chỉ để dùng code của bạn.

    @visibleForTesting cho bạn cả hai: một public API gọn gàng, tối giản, không cần ceremony DI cho consumer, và toàn quyền kiểm soát mọi collaborator bên trong lúc test. Đây là cách chính Flutter framework duy trì tính testable, và nó scale từ một utility package năm dòng đến một multi-module SDK.

    Lần tới khi bạn muốn expose một dependency "chỉ để test", hãy dùng annotation này. Analyzer sẽ giữ mọi người trung thực.

    Happy coding : )

    Enjoyed this read?

    0likes

    Found this helpful?

    Support my work.

    If this post saved you time or taught you something, you can buy me a coffee. Every bit keeps me writing and shipping.

    Support my work.

    If this post saved you time or taught you something, you can buy me a coffee. Every bit keeps me writing and shipping.

    TP BankVietnam

    Scan with any VN banking app

    Show QRHide QR
    Scan to support via Vietnamese bank (VietQR)Scan to pay
    PayPalGlobal

    Pay with your PayPal account

    Support via PayPal
    Show QRHide QR
    Scan to support via PayPalScan to pay
    GitHubGlobal

    Visa / Mastercard · one-time or monthly

    Sponsor on GitHub
    Show QRHide QR
    Scan to sponsor on GitHubScan to pay
    Comments

    Loading…

    END OF SHEET — LET'S BUILD

    Got a hard problem? Let's talk.