Flutter App Architecture

6 mins

6 mins

Sahaj Rana

Published on Jun 24, 2025

How Do You Test Each Layer in Flutter App Architecture?

Introduction

Introduction

Introduction

Introduction

Testing is essential for building robust, maintainable Flutter apps—especially when using a layered architecture. This blog dives into how to test each layer effectively: UI, ViewModel, and Data. You'll learn which test type fits where—unit tests for business logic, widget tests for UI behavior, and integration tests for full-flow scenarios. We'll cover tools like flutter_test, mockito, integration_test, and share real examples from official Flutter docs.

Testing individual layers in your Flutter app, UI, view models, and data ensures strong separation of concerns. This guide teaches you how to write unit, widget, and integration tests with practical examples.

By testing each layer independently, you ensure clean separation of concerns, make your codebase scalable, and catch bugs early. Whether you're validating state changes, mocking remote data, or simulating user flows, this guide empowers you to confidently validate your app's behavior from top to bottom. Let’s elevate your Flutter testing game.

Ready to elevate your Flutter tests? Let’s implement layered testing and build a more reliable, maintainable app, start today!

Testing is essential for building robust, maintainable Flutter apps—especially when using a layered architecture. This blog dives into how to test each layer effectively: UI, ViewModel, and Data. You'll learn which test type fits where—unit tests for business logic, widget tests for UI behavior, and integration tests for full-flow scenarios. We'll cover tools like flutter_test, mockito, integration_test, and share real examples from official Flutter docs.

Testing individual layers in your Flutter app, UI, view models, and data ensures strong separation of concerns. This guide teaches you how to write unit, widget, and integration tests with practical examples.

By testing each layer independently, you ensure clean separation of concerns, make your codebase scalable, and catch bugs early. Whether you're validating state changes, mocking remote data, or simulating user flows, this guide empowers you to confidently validate your app's behavior from top to bottom. Let’s elevate your Flutter testing game.

Ready to elevate your Flutter tests? Let’s implement layered testing and build a more reliable, maintainable app, start today!

Testing is essential for building robust, maintainable Flutter apps—especially when using a layered architecture. This blog dives into how to test each layer effectively: UI, ViewModel, and Data. You'll learn which test type fits where—unit tests for business logic, widget tests for UI behavior, and integration tests for full-flow scenarios. We'll cover tools like flutter_test, mockito, integration_test, and share real examples from official Flutter docs.

Testing individual layers in your Flutter app, UI, view models, and data ensures strong separation of concerns. This guide teaches you how to write unit, widget, and integration tests with practical examples.

By testing each layer independently, you ensure clean separation of concerns, make your codebase scalable, and catch bugs early. Whether you're validating state changes, mocking remote data, or simulating user flows, this guide empowers you to confidently validate your app's behavior from top to bottom. Let’s elevate your Flutter testing game.

Ready to elevate your Flutter tests? Let’s implement layered testing and build a more reliable, maintainable app, start today!

Testing is essential for building robust, maintainable Flutter apps—especially when using a layered architecture. This blog dives into how to test each layer effectively: UI, ViewModel, and Data. You'll learn which test type fits where—unit tests for business logic, widget tests for UI behavior, and integration tests for full-flow scenarios. We'll cover tools like flutter_test, mockito, integration_test, and share real examples from official Flutter docs.

Testing individual layers in your Flutter app, UI, view models, and data ensures strong separation of concerns. This guide teaches you how to write unit, widget, and integration tests with practical examples.

By testing each layer independently, you ensure clean separation of concerns, make your codebase scalable, and catch bugs early. Whether you're validating state changes, mocking remote data, or simulating user flows, this guide empowers you to confidently validate your app's behavior from top to bottom. Let’s elevate your Flutter testing game.

Ready to elevate your Flutter tests? Let’s implement layered testing and build a more reliable, maintainable app, start today!

Testing the UI Layer

Testing the UI Layer

Testing the UI Layer

Testing the UI Layer

One of the strongest indicators of a well-architected Flutter app is how effortlessly it can be tested. In a layered architecture, the UI layer—which includes both the view and view model—benefits from clearly defined inputs and outputs. This clear separation makes it simple to mock dependencies and writes focused unit and widget tests.

🔹 ViewModel Unit Tests

Because view models depend solely on repositories (or use-case classes), they are inherently easy to test in isolation. You can create fake or mock implementations of these dependencies and verify behavior without loading Flutter widgets or frameworks.

Example: HomeViewModel unit test

void main() {
  group('HomeViewModel tests', () {
    test('Load bookings', () {
      final viewModel = HomeViewModel(
        bookingRepository: FakeBookingRepository()
          ..createBooking(kBooking),
        userRepository: FakeUserRepository(),
      );

      expect(viewModel.bookings.isNotEmpty, true);
    });
  });
}

Here, FakeBookingRepository implements BookingRepository, allowing the constructor to populate data immediately. When viewModel.bookings is accessed, it confirms proper loading.

🔹 View Widget Tests

After validating the view model logic, it's equally important to test the actual UI widgets. Widget tests simulate how the view renders and responds to updates from the view model.

Here's a typical setup:

  • Initialize fake repositories and the view model.

  • Inject them into the widget under test (e.g., HomeScreen).

  • Use WidgetTester to render the UI and verify expected behavior.

Because your tests use fake dependencies and mock routing or navigation, the view logic is exercised in a controlled, deterministic environment, making your UI layer robust and maintainable.

One of the strongest indicators of a well-architected Flutter app is how effortlessly it can be tested. In a layered architecture, the UI layer—which includes both the view and view model—benefits from clearly defined inputs and outputs. This clear separation makes it simple to mock dependencies and writes focused unit and widget tests.

🔹 ViewModel Unit Tests

Because view models depend solely on repositories (or use-case classes), they are inherently easy to test in isolation. You can create fake or mock implementations of these dependencies and verify behavior without loading Flutter widgets or frameworks.

Example: HomeViewModel unit test

void main() {
  group('HomeViewModel tests', () {
    test('Load bookings', () {
      final viewModel = HomeViewModel(
        bookingRepository: FakeBookingRepository()
          ..createBooking(kBooking),
        userRepository: FakeUserRepository(),
      );

      expect(viewModel.bookings.isNotEmpty, true);
    });
  });
}

Here, FakeBookingRepository implements BookingRepository, allowing the constructor to populate data immediately. When viewModel.bookings is accessed, it confirms proper loading.

🔹 View Widget Tests

After validating the view model logic, it's equally important to test the actual UI widgets. Widget tests simulate how the view renders and responds to updates from the view model.

Here's a typical setup:

  • Initialize fake repositories and the view model.

  • Inject them into the widget under test (e.g., HomeScreen).

  • Use WidgetTester to render the UI and verify expected behavior.

Because your tests use fake dependencies and mock routing or navigation, the view logic is exercised in a controlled, deterministic environment, making your UI layer robust and maintainable.

One of the strongest indicators of a well-architected Flutter app is how effortlessly it can be tested. In a layered architecture, the UI layer—which includes both the view and view model—benefits from clearly defined inputs and outputs. This clear separation makes it simple to mock dependencies and writes focused unit and widget tests.

🔹 ViewModel Unit Tests

Because view models depend solely on repositories (or use-case classes), they are inherently easy to test in isolation. You can create fake or mock implementations of these dependencies and verify behavior without loading Flutter widgets or frameworks.

Example: HomeViewModel unit test

void main() {
  group('HomeViewModel tests', () {
    test('Load bookings', () {
      final viewModel = HomeViewModel(
        bookingRepository: FakeBookingRepository()
          ..createBooking(kBooking),
        userRepository: FakeUserRepository(),
      );

      expect(viewModel.bookings.isNotEmpty, true);
    });
  });
}

Here, FakeBookingRepository implements BookingRepository, allowing the constructor to populate data immediately. When viewModel.bookings is accessed, it confirms proper loading.

🔹 View Widget Tests

After validating the view model logic, it's equally important to test the actual UI widgets. Widget tests simulate how the view renders and responds to updates from the view model.

Here's a typical setup:

  • Initialize fake repositories and the view model.

  • Inject them into the widget under test (e.g., HomeScreen).

  • Use WidgetTester to render the UI and verify expected behavior.

Because your tests use fake dependencies and mock routing or navigation, the view logic is exercised in a controlled, deterministic environment, making your UI layer robust and maintainable.

One of the strongest indicators of a well-architected Flutter app is how effortlessly it can be tested. In a layered architecture, the UI layer—which includes both the view and view model—benefits from clearly defined inputs and outputs. This clear separation makes it simple to mock dependencies and writes focused unit and widget tests.

🔹 ViewModel Unit Tests

Because view models depend solely on repositories (or use-case classes), they are inherently easy to test in isolation. You can create fake or mock implementations of these dependencies and verify behavior without loading Flutter widgets or frameworks.

Example: HomeViewModel unit test

void main() {
  group('HomeViewModel tests', () {
    test('Load bookings', () {
      final viewModel = HomeViewModel(
        bookingRepository: FakeBookingRepository()
          ..createBooking(kBooking),
        userRepository: FakeUserRepository(),
      );

      expect(viewModel.bookings.isNotEmpty, true);
    });
  });
}

Here, FakeBookingRepository implements BookingRepository, allowing the constructor to populate data immediately. When viewModel.bookings is accessed, it confirms proper loading.

🔹 View Widget Tests

After validating the view model logic, it's equally important to test the actual UI widgets. Widget tests simulate how the view renders and responds to updates from the view model.

Here's a typical setup:

  • Initialize fake repositories and the view model.

  • Inject them into the widget under test (e.g., HomeScreen).

  • Use WidgetTester to render the UI and verify expected behavior.

Because your tests use fake dependencies and mock routing or navigation, the view logic is exercised in a controlled, deterministic environment, making your UI layer robust and maintainable.

ViewModel Unit Tests

ViewModel Unit Tests

ViewModel Unit Tests

ViewModel Unit Tests

Testing the logic of your view model is a fundamental part of Flutter’s layered architecture. Since view models contain UI-specific logic without directly depending on Flutter’s UI libraries, you can write pure Dart unit tests—without needing the Flutter testing framework.

In most cases, a view model depends only on repositories (or use-case abstractions), making the setup simple. To isolate and test the view model, we use fake or mock repositories. This removes dependencies on real data sources and helps focus purely on logic validation.

Example: Testing HomeViewModel

Here’s a basic unit test that checks if the HomeViewModel loads bookings correctly using a fake repository.

home_screen_test.dart

void main() {
  group('HomeViewModel tests', () {
    test('Load bookings', () {
      final viewModel = HomeViewModel(
        bookingRepository: FakeBookingRepository()
          ..createBooking(kBooking),
        userRepository: FakeUserRepository(),
      );

      expect(viewModel.bookings.isNotEmpty, true);
    });
  });
}

HomeViewModel._load() is called in the constructor, which fetches bookings from the repository.

Defining the Fake Repository

To test this behavior, we use a custom FakeBookingRepository that mimics the real interface and allows us to inject test data easily.

fake_booking_repository.dart

class FakeBookingRepository implements BookingRepository {
  List<Booking> bookings = List.empty(growable: true);

  @override
  Future<Result<void>> createBooking(Booking booking) async {
    bookings.add(booking);
    return Result.ok(null);
  }

  // Additional methods can be faked here
}

By faking BookingRepositoryWe completely decouple the test from external APIs or databases, keeping it fast, reliable, and focused.

Testing the logic of your view model is a fundamental part of Flutter’s layered architecture. Since view models contain UI-specific logic without directly depending on Flutter’s UI libraries, you can write pure Dart unit tests—without needing the Flutter testing framework.

In most cases, a view model depends only on repositories (or use-case abstractions), making the setup simple. To isolate and test the view model, we use fake or mock repositories. This removes dependencies on real data sources and helps focus purely on logic validation.

Example: Testing HomeViewModel

Here’s a basic unit test that checks if the HomeViewModel loads bookings correctly using a fake repository.

home_screen_test.dart

void main() {
  group('HomeViewModel tests', () {
    test('Load bookings', () {
      final viewModel = HomeViewModel(
        bookingRepository: FakeBookingRepository()
          ..createBooking(kBooking),
        userRepository: FakeUserRepository(),
      );

      expect(viewModel.bookings.isNotEmpty, true);
    });
  });
}

HomeViewModel._load() is called in the constructor, which fetches bookings from the repository.

Defining the Fake Repository

To test this behavior, we use a custom FakeBookingRepository that mimics the real interface and allows us to inject test data easily.

fake_booking_repository.dart

class FakeBookingRepository implements BookingRepository {
  List<Booking> bookings = List.empty(growable: true);

  @override
  Future<Result<void>> createBooking(Booking booking) async {
    bookings.add(booking);
    return Result.ok(null);
  }

  // Additional methods can be faked here
}

By faking BookingRepositoryWe completely decouple the test from external APIs or databases, keeping it fast, reliable, and focused.

Testing the logic of your view model is a fundamental part of Flutter’s layered architecture. Since view models contain UI-specific logic without directly depending on Flutter’s UI libraries, you can write pure Dart unit tests—without needing the Flutter testing framework.

In most cases, a view model depends only on repositories (or use-case abstractions), making the setup simple. To isolate and test the view model, we use fake or mock repositories. This removes dependencies on real data sources and helps focus purely on logic validation.

Example: Testing HomeViewModel

Here’s a basic unit test that checks if the HomeViewModel loads bookings correctly using a fake repository.

home_screen_test.dart

void main() {
  group('HomeViewModel tests', () {
    test('Load bookings', () {
      final viewModel = HomeViewModel(
        bookingRepository: FakeBookingRepository()
          ..createBooking(kBooking),
        userRepository: FakeUserRepository(),
      );

      expect(viewModel.bookings.isNotEmpty, true);
    });
  });
}

HomeViewModel._load() is called in the constructor, which fetches bookings from the repository.

Defining the Fake Repository

To test this behavior, we use a custom FakeBookingRepository that mimics the real interface and allows us to inject test data easily.

fake_booking_repository.dart

class FakeBookingRepository implements BookingRepository {
  List<Booking> bookings = List.empty(growable: true);

  @override
  Future<Result<void>> createBooking(Booking booking) async {
    bookings.add(booking);
    return Result.ok(null);
  }

  // Additional methods can be faked here
}

By faking BookingRepositoryWe completely decouple the test from external APIs or databases, keeping it fast, reliable, and focused.

Testing the logic of your view model is a fundamental part of Flutter’s layered architecture. Since view models contain UI-specific logic without directly depending on Flutter’s UI libraries, you can write pure Dart unit tests—without needing the Flutter testing framework.

In most cases, a view model depends only on repositories (or use-case abstractions), making the setup simple. To isolate and test the view model, we use fake or mock repositories. This removes dependencies on real data sources and helps focus purely on logic validation.

Example: Testing HomeViewModel

Here’s a basic unit test that checks if the HomeViewModel loads bookings correctly using a fake repository.

home_screen_test.dart

void main() {
  group('HomeViewModel tests', () {
    test('Load bookings', () {
      final viewModel = HomeViewModel(
        bookingRepository: FakeBookingRepository()
          ..createBooking(kBooking),
        userRepository: FakeUserRepository(),
      );

      expect(viewModel.bookings.isNotEmpty, true);
    });
  });
}

HomeViewModel._load() is called in the constructor, which fetches bookings from the repository.

Defining the Fake Repository

To test this behavior, we use a custom FakeBookingRepository that mimics the real interface and allows us to inject test data easily.

fake_booking_repository.dart

class FakeBookingRepository implements BookingRepository {
  List<Booking> bookings = List.empty(growable: true);

  @override
  Future<Result<void>> createBooking(Booking booking) async {
    bookings.add(booking);
    return Result.ok(null);
  }

  // Additional methods can be faked here
}

By faking BookingRepositoryWe completely decouple the test from external APIs or databases, keeping it fast, reliable, and focused.

View Widget Tests

View Widget Tests

View Widget Tests

View Widget Tests

Once your view model tests are complete, you’ve already created most of the test setup needed to write widget tests. Widget tests help ensure that your UI components behave as expected when connected to real or fake logic layers, like the view model and repositories.

In this section, we'll walk through testing the HomeScreen widget using a view model and faked dependencies. This ensures that your view layer is functioning correctly under controlled, testable conditions.

Test Setup: Fakes & Router Mock

Before testing the widget, we define the required fakes and mocks in the test file:

home_screen_test.dart

void main() {
  group('HomeScreen tests', () {
    late HomeViewModel viewModel;
    late MockGoRouter goRouter;
    late FakeBookingRepository bookingRepository;

    setUp(() {
      bookingRepository = FakeBookingRepository()
        ..createBooking(kBooking);

      viewModel = HomeViewModel(
        bookingRepository: bookingRepository,
        userRepository: FakeUserRepository(),
      );

      goRouter = MockGoRouter();
      when(() => goRouter.push(any())).thenAnswer((_) => Future.value(null));
    });

    // Widget test methods will go here...
  });
}

ℹ️ Note: MockGoRouter is mocked using the mocktail package. While router mocking is beyond the scope of this case study, it’s useful for navigation scenarios.

loadWidget(): Injecting the Widget for Testing

After setting up dependencies, we define the loadWidget method, which wraps the widget tree in providers and themes required for testing.

void loadWidget(WidgetTester tester) async {
  await testApp(
    tester,
    ChangeNotifierProvider.value(
      value: FakeAuthRepository() as AuthRepository,
      child: Provider.value(
        value: FakeItineraryConfigRepository() as ItineraryConfigRepository,
        child: HomeScreen(viewModel: viewModel),
      ),
    ),
    goRouter: goRouter,
  );
}

This method prepares the widget with fake dependencies, including AuthRepository and ItineraryConfigRepository, required by HomeScreen other widgets up the tree.

testApp(): Building the Widget Tree

The testApp The utility method handles common setup tasks like screen size, localization, and theming. It looks like this:

testing/app.dart

void testApp(
  WidgetTester tester,
  Widget body, {
  GoRouter? goRouter,
}) async {
  tester.view.devicePixelRatio = 1.0;
  await tester.binding.setSurfaceSize(const Size(1200, 800));
  await mockNetworkImages(() async {
    await tester.pumpWidget(
      MaterialApp(
        localizationsDelegates: [
          GlobalWidgetsLocalizations.delegate,
          GlobalMaterialLocalizations.delegate,
          AppLocalizationDelegate(),
        ],
        theme: AppTheme.lightTheme,
        home: InheritedGoRouter(
          goRouter: goRouter ?? MockGoRouter(),
          child: Scaffold(
            body: body,
          ),
        ),
      ),
    );
  });
}

testApp() abstracts boilerplate widget setup so you can focus on specific test logic.

When your architecture is well-structured, view and view model tests only require mocking repositories, not the view or UI logic itself. This approach leads to faster, more reliable tests and reduces brittleness in your test suite.

Once your view model tests are complete, you’ve already created most of the test setup needed to write widget tests. Widget tests help ensure that your UI components behave as expected when connected to real or fake logic layers, like the view model and repositories.

In this section, we'll walk through testing the HomeScreen widget using a view model and faked dependencies. This ensures that your view layer is functioning correctly under controlled, testable conditions.

Test Setup: Fakes & Router Mock

Before testing the widget, we define the required fakes and mocks in the test file:

home_screen_test.dart

void main() {
  group('HomeScreen tests', () {
    late HomeViewModel viewModel;
    late MockGoRouter goRouter;
    late FakeBookingRepository bookingRepository;

    setUp(() {
      bookingRepository = FakeBookingRepository()
        ..createBooking(kBooking);

      viewModel = HomeViewModel(
        bookingRepository: bookingRepository,
        userRepository: FakeUserRepository(),
      );

      goRouter = MockGoRouter();
      when(() => goRouter.push(any())).thenAnswer((_) => Future.value(null));
    });

    // Widget test methods will go here...
  });
}

ℹ️ Note: MockGoRouter is mocked using the mocktail package. While router mocking is beyond the scope of this case study, it’s useful for navigation scenarios.

loadWidget(): Injecting the Widget for Testing

After setting up dependencies, we define the loadWidget method, which wraps the widget tree in providers and themes required for testing.

void loadWidget(WidgetTester tester) async {
  await testApp(
    tester,
    ChangeNotifierProvider.value(
      value: FakeAuthRepository() as AuthRepository,
      child: Provider.value(
        value: FakeItineraryConfigRepository() as ItineraryConfigRepository,
        child: HomeScreen(viewModel: viewModel),
      ),
    ),
    goRouter: goRouter,
  );
}

This method prepares the widget with fake dependencies, including AuthRepository and ItineraryConfigRepository, required by HomeScreen other widgets up the tree.

testApp(): Building the Widget Tree

The testApp The utility method handles common setup tasks like screen size, localization, and theming. It looks like this:

testing/app.dart

void testApp(
  WidgetTester tester,
  Widget body, {
  GoRouter? goRouter,
}) async {
  tester.view.devicePixelRatio = 1.0;
  await tester.binding.setSurfaceSize(const Size(1200, 800));
  await mockNetworkImages(() async {
    await tester.pumpWidget(
      MaterialApp(
        localizationsDelegates: [
          GlobalWidgetsLocalizations.delegate,
          GlobalMaterialLocalizations.delegate,
          AppLocalizationDelegate(),
        ],
        theme: AppTheme.lightTheme,
        home: InheritedGoRouter(
          goRouter: goRouter ?? MockGoRouter(),
          child: Scaffold(
            body: body,
          ),
        ),
      ),
    );
  });
}

testApp() abstracts boilerplate widget setup so you can focus on specific test logic.

When your architecture is well-structured, view and view model tests only require mocking repositories, not the view or UI logic itself. This approach leads to faster, more reliable tests and reduces brittleness in your test suite.

Once your view model tests are complete, you’ve already created most of the test setup needed to write widget tests. Widget tests help ensure that your UI components behave as expected when connected to real or fake logic layers, like the view model and repositories.

In this section, we'll walk through testing the HomeScreen widget using a view model and faked dependencies. This ensures that your view layer is functioning correctly under controlled, testable conditions.

Test Setup: Fakes & Router Mock

Before testing the widget, we define the required fakes and mocks in the test file:

home_screen_test.dart

void main() {
  group('HomeScreen tests', () {
    late HomeViewModel viewModel;
    late MockGoRouter goRouter;
    late FakeBookingRepository bookingRepository;

    setUp(() {
      bookingRepository = FakeBookingRepository()
        ..createBooking(kBooking);

      viewModel = HomeViewModel(
        bookingRepository: bookingRepository,
        userRepository: FakeUserRepository(),
      );

      goRouter = MockGoRouter();
      when(() => goRouter.push(any())).thenAnswer((_) => Future.value(null));
    });

    // Widget test methods will go here...
  });
}

ℹ️ Note: MockGoRouter is mocked using the mocktail package. While router mocking is beyond the scope of this case study, it’s useful for navigation scenarios.

loadWidget(): Injecting the Widget for Testing

After setting up dependencies, we define the loadWidget method, which wraps the widget tree in providers and themes required for testing.

void loadWidget(WidgetTester tester) async {
  await testApp(
    tester,
    ChangeNotifierProvider.value(
      value: FakeAuthRepository() as AuthRepository,
      child: Provider.value(
        value: FakeItineraryConfigRepository() as ItineraryConfigRepository,
        child: HomeScreen(viewModel: viewModel),
      ),
    ),
    goRouter: goRouter,
  );
}

This method prepares the widget with fake dependencies, including AuthRepository and ItineraryConfigRepository, required by HomeScreen other widgets up the tree.

testApp(): Building the Widget Tree

The testApp The utility method handles common setup tasks like screen size, localization, and theming. It looks like this:

testing/app.dart

void testApp(
  WidgetTester tester,
  Widget body, {
  GoRouter? goRouter,
}) async {
  tester.view.devicePixelRatio = 1.0;
  await tester.binding.setSurfaceSize(const Size(1200, 800));
  await mockNetworkImages(() async {
    await tester.pumpWidget(
      MaterialApp(
        localizationsDelegates: [
          GlobalWidgetsLocalizations.delegate,
          GlobalMaterialLocalizations.delegate,
          AppLocalizationDelegate(),
        ],
        theme: AppTheme.lightTheme,
        home: InheritedGoRouter(
          goRouter: goRouter ?? MockGoRouter(),
          child: Scaffold(
            body: body,
          ),
        ),
      ),
    );
  });
}

testApp() abstracts boilerplate widget setup so you can focus on specific test logic.

When your architecture is well-structured, view and view model tests only require mocking repositories, not the view or UI logic itself. This approach leads to faster, more reliable tests and reduces brittleness in your test suite.

Once your view model tests are complete, you’ve already created most of the test setup needed to write widget tests. Widget tests help ensure that your UI components behave as expected when connected to real or fake logic layers, like the view model and repositories.

In this section, we'll walk through testing the HomeScreen widget using a view model and faked dependencies. This ensures that your view layer is functioning correctly under controlled, testable conditions.

Test Setup: Fakes & Router Mock

Before testing the widget, we define the required fakes and mocks in the test file:

home_screen_test.dart

void main() {
  group('HomeScreen tests', () {
    late HomeViewModel viewModel;
    late MockGoRouter goRouter;
    late FakeBookingRepository bookingRepository;

    setUp(() {
      bookingRepository = FakeBookingRepository()
        ..createBooking(kBooking);

      viewModel = HomeViewModel(
        bookingRepository: bookingRepository,
        userRepository: FakeUserRepository(),
      );

      goRouter = MockGoRouter();
      when(() => goRouter.push(any())).thenAnswer((_) => Future.value(null));
    });

    // Widget test methods will go here...
  });
}

ℹ️ Note: MockGoRouter is mocked using the mocktail package. While router mocking is beyond the scope of this case study, it’s useful for navigation scenarios.

loadWidget(): Injecting the Widget for Testing

After setting up dependencies, we define the loadWidget method, which wraps the widget tree in providers and themes required for testing.

void loadWidget(WidgetTester tester) async {
  await testApp(
    tester,
    ChangeNotifierProvider.value(
      value: FakeAuthRepository() as AuthRepository,
      child: Provider.value(
        value: FakeItineraryConfigRepository() as ItineraryConfigRepository,
        child: HomeScreen(viewModel: viewModel),
      ),
    ),
    goRouter: goRouter,
  );
}

This method prepares the widget with fake dependencies, including AuthRepository and ItineraryConfigRepository, required by HomeScreen other widgets up the tree.

testApp(): Building the Widget Tree

The testApp The utility method handles common setup tasks like screen size, localization, and theming. It looks like this:

testing/app.dart

void testApp(
  WidgetTester tester,
  Widget body, {
  GoRouter? goRouter,
}) async {
  tester.view.devicePixelRatio = 1.0;
  await tester.binding.setSurfaceSize(const Size(1200, 800));
  await mockNetworkImages(() async {
    await tester.pumpWidget(
      MaterialApp(
        localizationsDelegates: [
          GlobalWidgetsLocalizations.delegate,
          GlobalMaterialLocalizations.delegate,
          AppLocalizationDelegate(),
        ],
        theme: AppTheme.lightTheme,
        home: InheritedGoRouter(
          goRouter: goRouter ?? MockGoRouter(),
          child: Scaffold(
            body: body,
          ),
        ),
      ),
    );
  });
}

testApp() abstracts boilerplate widget setup so you can focus on specific test logic.

When your architecture is well-structured, view and view model tests only require mocking repositories, not the view or UI logic itself. This approach leads to faster, more reliable tests and reduces brittleness in your test suite.

Testing the Data Layer

Testing the Data Layer

Testing the Data Layer

Testing the Data Layer

Just like the UI and ViewModel layers, the data layer in a well-architected Flutter app has clearly defined inputs and outputs—making it ideal for unit testing. Repositories in this layer typically depend on services like API clients or local storage handlers, and you can mock or fake these dependencies to test data flow and transformations independently.

🔍 Why Test the Data Layer?

Testing the data layer ensures your app runs reliably:

  • Connects with APIs or local databases.

  • Parses and transforms data correctly.

  • Handles success and error states gracefully.

By isolating the repository and replacing its dependencies with fakes, you validate how your app behaves when interacting with backend services, without hitting real endpoints.

Example: Booking Repository Unit Test

Here’s a unit test BookingRepositoryRemote that mocks the API layer and validates the result.

booking_repository_remote_test.dart

void main() {
  group('BookingRepositoryRemote tests', () {
    late BookingRepository bookingRepository;
    late FakeApiClient fakeApiClient;

    setUp(() {
      fakeApiClient = FakeApiClient();
      bookingRepository = BookingRepositoryRemote(
        apiClient: fakeApiClient,
      );
    });

    test('should get booking', () async {
      final result = await bookingRepository.getBooking(0);
      final booking = result.asOk.value;
      expect(booking, kBooking);
    });
  });
}

🔄 FakeApiClient acts as a test double, allowing you to simulate network responses without real HTTP calls.

Tip: Write Mocks for Each Service

If your repository depends on multiple services (e.g., auth, database, API), mock or fake each one independently to test failure and success cases. Keep test doubles lightweight and predictable.

🔍 For more detailed examples, refer to the Compass App’s testing directory or Flutter’s official testing docs.

A clean data layer design makes testing seamless. Just mock the services, pass them to the repository, and verify that your data flows and transforms as expected. Testing this layer ensures your app stays resilient—even when APIs don’t.

Just like the UI and ViewModel layers, the data layer in a well-architected Flutter app has clearly defined inputs and outputs—making it ideal for unit testing. Repositories in this layer typically depend on services like API clients or local storage handlers, and you can mock or fake these dependencies to test data flow and transformations independently.

🔍 Why Test the Data Layer?

Testing the data layer ensures your app runs reliably:

  • Connects with APIs or local databases.

  • Parses and transforms data correctly.

  • Handles success and error states gracefully.

By isolating the repository and replacing its dependencies with fakes, you validate how your app behaves when interacting with backend services, without hitting real endpoints.

Example: Booking Repository Unit Test

Here’s a unit test BookingRepositoryRemote that mocks the API layer and validates the result.

booking_repository_remote_test.dart

void main() {
  group('BookingRepositoryRemote tests', () {
    late BookingRepository bookingRepository;
    late FakeApiClient fakeApiClient;

    setUp(() {
      fakeApiClient = FakeApiClient();
      bookingRepository = BookingRepositoryRemote(
        apiClient: fakeApiClient,
      );
    });

    test('should get booking', () async {
      final result = await bookingRepository.getBooking(0);
      final booking = result.asOk.value;
      expect(booking, kBooking);
    });
  });
}

🔄 FakeApiClient acts as a test double, allowing you to simulate network responses without real HTTP calls.

Tip: Write Mocks for Each Service

If your repository depends on multiple services (e.g., auth, database, API), mock or fake each one independently to test failure and success cases. Keep test doubles lightweight and predictable.

🔍 For more detailed examples, refer to the Compass App’s testing directory or Flutter’s official testing docs.

A clean data layer design makes testing seamless. Just mock the services, pass them to the repository, and verify that your data flows and transforms as expected. Testing this layer ensures your app stays resilient—even when APIs don’t.

Just like the UI and ViewModel layers, the data layer in a well-architected Flutter app has clearly defined inputs and outputs—making it ideal for unit testing. Repositories in this layer typically depend on services like API clients or local storage handlers, and you can mock or fake these dependencies to test data flow and transformations independently.

🔍 Why Test the Data Layer?

Testing the data layer ensures your app runs reliably:

  • Connects with APIs or local databases.

  • Parses and transforms data correctly.

  • Handles success and error states gracefully.

By isolating the repository and replacing its dependencies with fakes, you validate how your app behaves when interacting with backend services, without hitting real endpoints.

Example: Booking Repository Unit Test

Here’s a unit test BookingRepositoryRemote that mocks the API layer and validates the result.

booking_repository_remote_test.dart

void main() {
  group('BookingRepositoryRemote tests', () {
    late BookingRepository bookingRepository;
    late FakeApiClient fakeApiClient;

    setUp(() {
      fakeApiClient = FakeApiClient();
      bookingRepository = BookingRepositoryRemote(
        apiClient: fakeApiClient,
      );
    });

    test('should get booking', () async {
      final result = await bookingRepository.getBooking(0);
      final booking = result.asOk.value;
      expect(booking, kBooking);
    });
  });
}

🔄 FakeApiClient acts as a test double, allowing you to simulate network responses without real HTTP calls.

Tip: Write Mocks for Each Service

If your repository depends on multiple services (e.g., auth, database, API), mock or fake each one independently to test failure and success cases. Keep test doubles lightweight and predictable.

🔍 For more detailed examples, refer to the Compass App’s testing directory or Flutter’s official testing docs.

A clean data layer design makes testing seamless. Just mock the services, pass them to the repository, and verify that your data flows and transforms as expected. Testing this layer ensures your app stays resilient—even when APIs don’t.

Just like the UI and ViewModel layers, the data layer in a well-architected Flutter app has clearly defined inputs and outputs—making it ideal for unit testing. Repositories in this layer typically depend on services like API clients or local storage handlers, and you can mock or fake these dependencies to test data flow and transformations independently.

🔍 Why Test the Data Layer?

Testing the data layer ensures your app runs reliably:

  • Connects with APIs or local databases.

  • Parses and transforms data correctly.

  • Handles success and error states gracefully.

By isolating the repository and replacing its dependencies with fakes, you validate how your app behaves when interacting with backend services, without hitting real endpoints.

Example: Booking Repository Unit Test

Here’s a unit test BookingRepositoryRemote that mocks the API layer and validates the result.

booking_repository_remote_test.dart

void main() {
  group('BookingRepositoryRemote tests', () {
    late BookingRepository bookingRepository;
    late FakeApiClient fakeApiClient;

    setUp(() {
      fakeApiClient = FakeApiClient();
      bookingRepository = BookingRepositoryRemote(
        apiClient: fakeApiClient,
      );
    });

    test('should get booking', () async {
      final result = await bookingRepository.getBooking(0);
      final booking = result.asOk.value;
      expect(booking, kBooking);
    });
  });
}

🔄 FakeApiClient acts as a test double, allowing you to simulate network responses without real HTTP calls.

Tip: Write Mocks for Each Service

If your repository depends on multiple services (e.g., auth, database, API), mock or fake each one independently to test failure and success cases. Keep test doubles lightweight and predictable.

🔍 For more detailed examples, refer to the Compass App’s testing directory or Flutter’s official testing docs.

A clean data layer design makes testing seamless. Just mock the services, pass them to the repository, and verify that your data flows and transforms as expected. Testing this layer ensures your app stays resilient—even when APIs don’t.