DEV Community

Cover image for Flutter Interview Questions Part 4: Networking, Storage & Testing
Anurag Dubey
Anurag Dubey

Posted on

Flutter Interview Questions Part 4: Networking, Storage & Testing

Welcome to Part 4 of the Flutter Interview Questions series! This installment dives deep into three pillars that every production Flutter app relies on: networking, local storage, and testing. Whether you are preparing for your next Flutter interview or looking to solidify your understanding of how data flows in and out of a Flutter application and how to verify it all works, this part has you covered. This is part 4 of a 14-part series, so be sure to bookmark it and follow along as we release new parts.

What's in this part?

  • Networking -- HTTP & Dio packages, interceptors, request cancellation, REST API integration, JSON parsing (json_serializable, freezed), GraphQL, WebSockets, error handling, SSL pinning
  • Local Storage -- SharedPreferences, SQLite/sqflite/Drift, Hive, Isar, file storage with path_provider, secure storage with flutter_secure_storage
  • Testing -- Unit testing, widget testing, integration testing, mocking (Mockito & Mocktail), golden tests, test coverage, BLoC testing, async test patterns

PART 1: NETWORKING


1.1 HTTP Requests - http Package, Dio Package, Interceptors

Q1: How do you make HTTP requests in Flutter using the http package?

Answer:
Flutter provides the http package for making network requests. You add it via flutter pub add http and import it as import 'package:http/http.dart' as http;.

// GET request
Future<Album> fetchAlbum() async {
  final response = await http.get(
    Uri.parse('https://jsonplaceholder.typicode.com/albums/1'),
  );

  if (response.statusCode == 200) {
    return Album.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
  } else {
    throw Exception('Failed to load album');
  }
}

// POST request
Future<http.Response> createAlbum(String title) {
  return http.post(
    Uri.parse('https://jsonplaceholder.typicode.com/albums'),
    headers: <String, String>{
      'Content-Type': 'application/json; charset=UTF-8',
    },
    body: jsonEncode(<String, String>{'title': title}),
  );
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • Always check response.statusCode before parsing the body.
  • The http package supports GET, POST, PUT, PATCH, DELETE, and HEAD methods.
  • On Android, you must add <uses-permission android:name="android.permission.INTERNET" /> to AndroidManifest.xml.
  • On macOS, you must add the com.apple.security.network.client entitlement.
  • The http.Response object contains statusCode, body, headers, and reasonPhrase.

Q2: What is the Dio package, and why would you choose it over the http package?

Answer:
Dio is a powerful HTTP networking package for Dart/Flutter that provides features far beyond the basic http package. You would choose Dio for production applications that need advanced networking capabilities.

Key features of Dio over http:

Feature http Dio
Basic GET/POST Yes Yes
Interceptors No Yes
Global configuration No Yes
FormData / Multipart Manual Built-in
Request cancellation No Yes (CancelToken)
Timeout configuration Limited Fine-grained (connect, send, receive)
Download progress No Yes
Upload progress No Yes
Automatic JSON transformation No Yes
Request retry No Via interceptor
final dio = Dio(BaseOptions(
  baseUrl: 'https://api.example.com',
  connectTimeout: Duration(seconds: 5),
  receiveTimeout: Duration(seconds: 3),
  headers: {'Authorization': 'Bearer $token'},
));

// GET request
final response = await dio.get('/users');

// POST with automatic JSON encoding
final response = await dio.post('/users', data: {'name': 'John', 'age': 30});

// File upload with progress
final formData = FormData.fromMap({
  'file': await MultipartFile.fromFile('./image.png', filename: 'upload.png'),
});
await dio.post('/upload', data: formData,
  onSendProgress: (sent, total) {
    print('${(sent / total * 100).toStringAsFixed(0)}%');
  },
);
Enter fullscreen mode Exit fullscreen mode

Q3: What are Dio interceptors, and how do you implement them?

Answer:
Interceptors in Dio allow you to intercept requests, responses, and errors before they are handled. They are extremely useful for logging, adding authentication tokens, retrying failed requests, and caching.

class AuthInterceptor extends Interceptor {
  final TokenStorage tokenStorage;

  AuthInterceptor(this.tokenStorage);

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    final token = tokenStorage.accessToken;
    if (token != null) {
      options.headers['Authorization'] = 'Bearer $token';
    }
    handler.next(options); // Continue with the request
  }

  @override
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    // Log or modify response
    print('Response [${response.statusCode}] => ${response.requestOptions.uri}');
    handler.next(response); // Continue
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode == 401) {
      // Token expired - refresh and retry
      try {
        final newToken = await tokenStorage.refreshToken();
        err.requestOptions.headers['Authorization'] = 'Bearer $newToken';
        final retryResponse = await Dio().fetch(err.requestOptions);
        handler.resolve(retryResponse); // Return successful retry
      } catch (e) {
        handler.next(err); // Refresh failed, propagate error
      }
    } else {
      handler.next(err); // Propagate other errors
    }
  }
}

// Usage
final dio = Dio();
dio.interceptors.addAll([
  AuthInterceptor(tokenStorage),
  LogInterceptor(requestBody: true, responseBody: true),
  RetryInterceptor(dio: dio, retries: 3),
]);
Enter fullscreen mode Exit fullscreen mode

There are three handler methods in an interceptor:

  • handler.next(data) -- continue the interceptor chain
  • handler.resolve(response) -- short-circuit and return a successful response
  • handler.reject(error) -- short-circuit and return an error

Q4: How do you handle request cancellation in Dio?

Answer:
Dio uses CancelToken to cancel in-flight HTTP requests. This is commonly used when navigating away from a screen or when a user triggers a new search before the previous one completes.

class UserRepository {
  final Dio _dio;
  CancelToken? _cancelToken;

  UserRepository(this._dio);

  Future<List<User>> searchUsers(String query) async {
    // Cancel previous request if still in progress
    _cancelToken?.cancel('New search initiated');
    _cancelToken = CancelToken();

    try {
      final response = await _dio.get(
        '/users/search',
        queryParameters: {'q': query},
        cancelToken: _cancelToken,
      );
      return (response.data as List).map((e) => User.fromJson(e)).toList();
    } on DioException catch (e) {
      if (e.type == DioExceptionType.cancel) {
        // Request was cancelled - this is expected, not an error
        return [];
      }
      rethrow;
    }
  }

  void dispose() {
    _cancelToken?.cancel('Disposed');
  }
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • One CancelToken can be shared across multiple requests to cancel them all at once.
  • Cancelled requests throw a DioException with type == DioExceptionType.cancel.
  • Always cancel tokens when a widget is disposed to avoid memory leaks and unnecessary network calls.

Q5: How do you set up base configuration and multiple Dio instances for different APIs?

Answer:
In production apps, you often need to communicate with multiple API servers. You can create separate Dio instances with different base configurations.

class ApiClient {
  static Dio createMainApi() {
    return Dio(BaseOptions(
      baseUrl: 'https://api.myapp.com/v1',
      connectTimeout: const Duration(seconds: 10),
      receiveTimeout: const Duration(seconds: 10),
      sendTimeout: const Duration(seconds: 10),
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json',
      },
      validateStatus: (status) => status != null && status < 500,
    ))
      ..interceptors.addAll([
        AuthInterceptor(),
        LogInterceptor(requestBody: true, responseBody: true),
      ]);
  }

  static Dio createPaymentApi() {
    return Dio(BaseOptions(
      baseUrl: 'https://payments.myapp.com/v2',
      connectTimeout: const Duration(seconds: 30),
      receiveTimeout: const Duration(seconds: 30),
      headers: {
        'X-API-Key': Environment.paymentApiKey,
      },
    ));
  }
}
Enter fullscreen mode Exit fullscreen mode

The BaseOptions fields include:

  • baseUrl -- prepended to all relative paths
  • connectTimeout, sendTimeout, receiveTimeout -- fine-grained timeout control
  • headers -- default headers for all requests
  • contentType -- default content type
  • responseType -- ResponseType.json, .stream, .plain, .bytes
  • validateStatus -- function to determine which status codes are treated as successful

Q6: How do you download files with progress tracking using Dio?

Answer:

Future<void> downloadFile(String url, String savePath) async {
  final dio = Dio();
  try {
    await dio.download(
      url,
      savePath,
      onReceiveProgress: (received, total) {
        if (total != -1) {
          final progress = (received / total * 100).toStringAsFixed(0);
          print('Download progress: $progress%');
        }
      },
      deleteOnError: true, // Delete file if download fails
    );
    print('Download complete');
  } on DioException catch (e) {
    print('Download failed: ${e.message}');
  }
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • dio.download() writes directly to a file, which is memory-efficient for large files.
  • onReceiveProgress gives received bytes and total bytes (-1 if unknown).
  • deleteOnError: true cleans up partial downloads on failure.
  • You can combine this with CancelToken to allow users to cancel downloads.

Q7: What is the difference between connectTimeout, sendTimeout, and receiveTimeout in Dio?

Answer:

  • connectTimeout: Maximum time to establish a TCP connection to the server. If the server is unreachable or slow to accept connections, this timeout fires. Does not include DNS resolution time on all platforms.

  • sendTimeout: Maximum duration between two consecutive data packets being sent. This is relevant for large uploads. It is NOT the total time allowed for sending the entire request.

  • receiveTimeout: Maximum duration between two consecutive data packets being received. Relevant for large downloads or slow responses. It is NOT the total time for the entire response.

final dio = Dio(BaseOptions(
  connectTimeout: const Duration(seconds: 5),   // 5s to connect
  sendTimeout: const Duration(seconds: 15),      // 15s between send chunks
  receiveTimeout: const Duration(seconds: 15),   // 15s between receive chunks
));
Enter fullscreen mode Exit fullscreen mode

If you need a total request timeout regardless of chunks, wrap the call in a Future.timeout():

try {
  final response = await dio.get('/large-data')
      .timeout(const Duration(seconds: 60));
} on TimeoutException {
  print('Total request exceeded 60 seconds');
}
Enter fullscreen mode Exit fullscreen mode

Q8: How do you handle multipart/form-data uploads (images, files) in Dio?

Answer:

Future<void> uploadProfileImage(File imageFile) async {
  final dio = Dio();
  final formData = FormData.fromMap({
    'name': 'John Doe',
    'age': 25,
    'avatar': await MultipartFile.fromFile(
      imageFile.path,
      filename: 'profile.jpg',
      contentType: MediaType('image', 'jpeg'),
    ),
    // Multiple files
    'documents': [
      await MultipartFile.fromFile('./doc1.pdf', filename: 'doc1.pdf'),
      await MultipartFile.fromFile('./doc2.pdf', filename: 'doc2.pdf'),
    ],
  });

  try {
    final response = await dio.post(
      'https://api.example.com/profile',
      data: formData,
      onSendProgress: (sent, total) {
        print('Upload: ${(sent / total * 100).toStringAsFixed(0)}%');
      },
    );
    print('Upload successful: ${response.data}');
  } on DioException catch (e) {
    print('Upload failed: ${e.message}');
  }
}

// From bytes in memory (e.g., camera capture)
final multipartFile = MultipartFile.fromBytes(
  imageBytes,
  filename: 'camera_capture.jpg',
  contentType: MediaType('image', 'jpeg'),
);
Enter fullscreen mode Exit fullscreen mode

Q9: How do you implement request retry logic with Dio?

Answer:
You can implement retry logic using a custom interceptor or the dio_smart_retry package.

Custom retry interceptor:

class RetryInterceptor extends Interceptor {
  final Dio dio;
  final int maxRetries;
  final Duration retryDelay;

  RetryInterceptor({
    required this.dio,
    this.maxRetries = 3,
    this.retryDelay = const Duration(seconds: 1),
  });

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    final retryCount = err.requestOptions.extra['retryCount'] ?? 0;

    // Only retry on connection errors or 5xx server errors
    final shouldRetry = _shouldRetry(err) && retryCount < maxRetries;

    if (shouldRetry) {
      await Future.delayed(retryDelay * (retryCount + 1)); // Exponential backoff
      err.requestOptions.extra['retryCount'] = retryCount + 1;

      try {
        final response = await dio.fetch(err.requestOptions);
        handler.resolve(response);
        return;
      } catch (e) {
        // Will be caught by next iteration or fall through
      }
    }
    handler.next(err);
  }

  bool _shouldRetry(DioException err) {
    return err.type == DioExceptionType.connectionTimeout ||
        err.type == DioExceptionType.connectionError ||
        (err.response?.statusCode != null && err.response!.statusCode! >= 500);
  }
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • Implement exponential backoff to avoid overwhelming the server.
  • Only retry idempotent requests (GET, PUT, DELETE) -- retrying POST can cause duplicates.
  • Set a maximum retry limit to avoid infinite loops.

Q10: What are the different types of DioException and how do you handle each?

Answer:

Future<void> fetchData() async {
  try {
    final response = await dio.get('/data');
    // handle success
  } on DioException catch (e) {
    switch (e.type) {
      case DioExceptionType.connectionTimeout:
        // Server took too long to accept connection
        showError('Connection timed out. Check your internet.');
        break;
      case DioExceptionType.sendTimeout:
        // Sending data took too long
        showError('Request upload timed out.');
        break;
      case DioExceptionType.receiveTimeout:
        // Server took too long to respond
        showError('Server response timed out.');
        break;
      case DioExceptionType.badResponse:
        // Server responded with a non-success status code
        _handleBadResponse(e.response!);
        break;
      case DioExceptionType.cancel:
        // Request was cancelled via CancelToken
        // Usually no action needed
        break;
      case DioExceptionType.connectionError:
        // Could not connect at all (no internet, DNS failure)
        showError('No internet connection.');
        break;
      case DioExceptionType.badCertificate:
        // SSL/TLS certificate issue
        showError('Security certificate error.');
        break;
      case DioExceptionType.unknown:
        // Unexpected error
        showError('An unexpected error occurred.');
        break;
    }
  }
}

void _handleBadResponse(Response response) {
  switch (response.statusCode) {
    case 400: showError('Bad request');
    case 401: showError('Unauthorized - please log in again');
    case 403: showError('Forbidden - insufficient permissions');
    case 404: showError('Resource not found');
    case 422: showError('Validation error');
    case 429: showError('Too many requests - please wait');
    case 500: showError('Server error - try again later');
    default: showError('Error: ${response.statusCode}');
  }
}
Enter fullscreen mode Exit fullscreen mode

1.2 REST API Integration, JSON Parsing, json_serializable, freezed

Q1: How do you manually parse JSON in Flutter without code generation?

Answer:
For simple models, you can manually write fromJson and toJson methods using dart:convert.

import 'dart:convert';

class User {
  final int id;
  final String name;
  final String email;
  final Address? address;

  User({required this.id, required this.name, required this.email, this.address});

  // Factory constructor for deserialization
  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      id: json['id'] as int,
      name: json['name'] as String,
      email: json['email'] as String,
      address: json['address'] != null
          ? Address.fromJson(json['address'] as Map<String, dynamic>)
          : null,
    );
  }

  // Serialization
  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'name': name,
      'email': email,
      'address': address?.toJson(),
    };
  }
}

// Parsing a JSON string
final jsonString = '{"id": 1, "name": "John", "email": "john@example.com"}';
final user = User.fromJson(jsonDecode(jsonString));

// Parsing a JSON list
final jsonList = '[{"id": 1, "name": "John"}, {"id": 2, "name": "Jane"}]';
final users = (jsonDecode(jsonList) as List)
    .map((e) => User.fromJson(e as Map<String, dynamic>))
    .toList();

// Converting back to JSON string
final encoded = jsonEncode(user.toJson());
Enter fullscreen mode Exit fullscreen mode

Limitations of manual parsing:

  • Tedious and error-prone for large models
  • No compile-time safety for key names
  • Must be kept in sync with API changes manually

Q2: How does json_serializable work, and why should you use it?

Answer:
json_serializable is a code generation package that automatically generates fromJson and toJson methods from annotated Dart classes. It eliminates manual JSON parsing boilerplate and reduces errors.

Setup:

flutter pub add json_annotation dev:json_serializable dev:build_runner
Enter fullscreen mode Exit fullscreen mode

Usage:

import 'package:json_annotation/json_annotation.dart';

part 'user.g.dart'; // Generated file

@JsonSerializable()
class User {
  final int id;

  @JsonKey(name: 'user_name') // Map to different JSON key
  final String name;

  final String email;

  @JsonKey(defaultValue: false)
  final bool isActive;

  @JsonKey(includeIfNull: false)
  final String? phone;

  @JsonKey(fromJson: _dateFromJson, toJson: _dateToJson)
  final DateTime createdAt;

  User({
    required this.id,
    required this.name,
    required this.email,
    this.isActive = false,
    this.phone,
    required this.createdAt,
  });

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);

  static DateTime _dateFromJson(String date) => DateTime.parse(date);
  static String _dateToJson(DateTime date) => date.toIso8601String();
}
Enter fullscreen mode Exit fullscreen mode

Generate code:

dart run build_runner build --delete-conflicting-outputs
# Or watch for changes:
dart run build_runner watch --delete-conflicting-outputs
Enter fullscreen mode Exit fullscreen mode

Key @JsonKey options:

  • name -- map to a different JSON field name
  • defaultValue -- provide a default when the field is missing
  • includeIfNull -- whether to include null fields in output
  • fromJson / toJson -- custom converters
  • ignore -- exclude from serialization
  • unknownEnumValue -- fallback value for unknown enum strings

Q3: What is the freezed package, and how does it enhance data modeling?

Answer:
freezed is a code generation package that creates immutable data classes with built-in support for copyWith, == operator, toString, pattern matching (union types/sealed classes), and JSON serialization (when combined with json_serializable).

Setup:

flutter pub add freezed_annotation json_annotation
flutter pub add dev:freezed dev:json_serializable dev:build_runner
Enter fullscreen mode Exit fullscreen mode

Basic immutable class:

import 'package:freezed_annotation/freezed_annotation.dart';

part 'user.freezed.dart';
part 'user.g.dart'; // For JSON support

@freezed
class User with _$User {
  const factory User({
    required int id,
    required String name,
    required String email,
    @Default(false) bool isActive,
    String? phone,
  }) = _User;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

// Usage:
final user = User(id: 1, name: 'John', email: 'john@example.com');

// copyWith - creates a copy with modified fields
final updatedUser = user.copyWith(name: 'Jane', isActive: true);

// Deep equality works automatically
print(user == User(id: 1, name: 'John', email: 'john@example.com')); // true
Enter fullscreen mode Exit fullscreen mode

Union types / Sealed classes (incredibly powerful for state management):

@freezed
class AuthState with _$AuthState {
  const factory AuthState.initial() = _Initial;
  const factory AuthState.loading() = _Loading;
  const factory AuthState.authenticated(User user) = _Authenticated;
  const factory AuthState.error(String message) = _Error;
}

// Pattern matching with when:
Widget buildUI(AuthState state) {
  return state.when(
    initial: () => LoginScreen(),
    loading: () => CircularProgressIndicator(),
    authenticated: (user) => HomeScreen(user: user),
    error: (message) => ErrorWidget(message: message),
  );
}

// maybeWhen - handles only some cases, rest falls to orElse:
state.maybeWhen(
  authenticated: (user) => print('Logged in as ${user.name}'),
  orElse: () => print('Not logged in'),
);

// map / maybeMap - similar but gives access to the full object:
state.map(
  initial: (state) => ...,
  loading: (state) => ...,
  authenticated: (state) => Text(state.user.name),
  error: (state) => Text(state.message),
);
Enter fullscreen mode Exit fullscreen mode

Why freezed over manual immutable classes:

  • Automatic ==, hashCode, and toString
  • Deep copyWith (works with nested freezed classes)
  • Union types with exhaustive pattern matching (compiler warns if you miss a case)
  • JSON serialization integration
  • Eliminates hundreds of lines of boilerplate

Q4: How do you structure a complete REST API layer in a Flutter app?

Answer:
A well-structured API layer typically follows a layered architecture:

lib/
  data/
    api/
      api_client.dart         # Dio setup, interceptors
      api_endpoints.dart      # URL constants
      api_exceptions.dart     # Custom exception classes
    models/
      user.dart               # Data models with fromJson/toJson
      user.g.dart             # Generated
    repositories/
      user_repository.dart    # Abstracts API calls
    datasources/
      remote/
        user_remote_datasource.dart
      local/
        user_local_datasource.dart
Enter fullscreen mode Exit fullscreen mode
// api_client.dart
class ApiClient {
  late final Dio _dio;

  ApiClient() {
    _dio = Dio(BaseOptions(
      baseUrl: AppConstants.baseUrl,
      connectTimeout: const Duration(seconds: 10),
      receiveTimeout: const Duration(seconds: 10),
    ))
      ..interceptors.addAll([
        AuthInterceptor(),
        LogInterceptor(requestBody: true, responseBody: true),
      ]);
  }

  Future<Response> get(String path, {Map<String, dynamic>? queryParams}) =>
      _dio.get(path, queryParameters: queryParams);

  Future<Response> post(String path, {dynamic data}) =>
      _dio.post(path, data: data);
}

// user_repository.dart
class UserRepository {
  final ApiClient _apiClient;

  UserRepository(this._apiClient);

  Future<Either<Failure, List<User>>> getUsers() async {
    try {
      final response = await _apiClient.get('/users');
      final users = (response.data as List)
          .map((json) => User.fromJson(json))
          .toList();
      return Right(users);
    } on DioException catch (e) {
      return Left(ServerFailure(e.message ?? 'Unknown error'));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Q5: How do you handle nested JSON objects and lists during parsing?

Answer:

// API response:
// {
//   "status": "success",
//   "data": {
//     "user": { "id": 1, "name": "John" },
//     "posts": [
//       { "id": 1, "title": "Hello", "tags": ["dart", "flutter"] }
//     ]
//   }
// }

@JsonSerializable(explicitToJson: true) // Required for nested toJson
class ApiResponse {
  final String status;
  final ResponseData data;

  ApiResponse({required this.status, required this.data});
  factory ApiResponse.fromJson(Map<String, dynamic> json) =>
      _$ApiResponseFromJson(json);
  Map<String, dynamic> toJson() => _$ApiResponseToJson(this);
}

@JsonSerializable(explicitToJson: true)
class ResponseData {
  final User user;
  final List<Post> posts;

  ResponseData({required this.user, required this.posts});
  factory ResponseData.fromJson(Map<String, dynamic> json) =>
      _$ResponseDataFromJson(json);
  Map<String, dynamic> toJson() => _$ResponseDataToJson(this);
}

@JsonSerializable()
class Post {
  final int id;
  final String title;
  final List<String> tags;

  Post({required this.id, required this.title, required this.tags});
  factory Post.fromJson(Map<String, dynamic> json) => _$PostFromJson(json);
  Map<String, dynamic> toJson() => _$PostToJson(this);
}
Enter fullscreen mode Exit fullscreen mode

Important: Use @JsonSerializable(explicitToJson: true) on parent classes, otherwise toJson() on nested objects will not be called, and you will get Instance of 'User' instead of the actual JSON map.


Q6: How do you handle generic API response wrappers with json_serializable?

Answer:
Many APIs wrap responses in a standard envelope. Handling generics requires custom converters.

// Generic wrapper
class ApiResponse<T> {
  final bool success;
  final String? message;
  final T? data;

  ApiResponse({required this.success, this.message, this.data});

  factory ApiResponse.fromJson(
    Map<String, dynamic> json,
    T Function(Object? json) fromJsonT,
  ) {
    return ApiResponse(
      success: json['success'] as bool,
      message: json['message'] as String?,
      data: json['data'] != null ? fromJsonT(json['data']) : null,
    );
  }
}

// Paginated response
class PaginatedResponse<T> {
  final List<T> items;
  final int total;
  final int page;
  final int perPage;
  final bool hasMore;

  PaginatedResponse({
    required this.items,
    required this.total,
    required this.page,
    required this.perPage,
    required this.hasMore,
  });

  factory PaginatedResponse.fromJson(
    Map<String, dynamic> json,
    T Function(Map<String, dynamic>) fromJsonT,
  ) {
    return PaginatedResponse(
      items: (json['items'] as List).map((e) => fromJsonT(e)).toList(),
      total: json['total'] as int,
      page: json['page'] as int,
      perPage: json['per_page'] as int,
      hasMore: json['has_more'] as bool,
    );
  }
}

// Usage:
final response = ApiResponse<User>.fromJson(
  jsonDecode(responseBody),
  (json) => User.fromJson(json as Map<String, dynamic>),
);

final paginatedUsers = PaginatedResponse<User>.fromJson(
  jsonDecode(responseBody),
  (json) => User.fromJson(json),
);
Enter fullscreen mode Exit fullscreen mode

Q7: How do you handle enum serialization in JSON?

Answer:

// With json_serializable
enum UserRole {
  @JsonValue('admin')
  admin,
  @JsonValue('editor')
  editor,
  @JsonValue('viewer')
  viewer,
}

@JsonSerializable()
class User {
  final String name;

  @JsonKey(unknownEnumValue: UserRole.viewer) // Fallback for unknown values
  final UserRole role;

  User({required this.name, required this.role});
  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

// With freezed
@freezed
class User with _$User {
  const factory User({
    required String name,
    @JsonKey(unknownEnumValue: UserRole.viewer) required UserRole role,
  }) = _User;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

// Manual approach
enum Status { active, inactive, suspended }

Status statusFromJson(String value) {
  return Status.values.firstWhere(
    (e) => e.name == value,
    orElse: () => Status.inactive,
  );
}
Enter fullscreen mode Exit fullscreen mode

Q8: What is compute() and how do you use it for JSON parsing on a background isolate?

Answer:
Parsing large JSON payloads on the main isolate can cause UI jank. Flutter's compute() function runs a function on a separate isolate.

import 'package:flutter/foundation.dart';

// The function MUST be a top-level function or a static method
// (not a closure or instance method, because it runs in a different isolate)
List<User> parseUsers(String responseBody) {
  final parsed = jsonDecode(responseBody) as List;
  return parsed.map((json) => User.fromJson(json)).toList();
}

// Usage
Future<List<User>> fetchUsers() async {
  final response = await http.get(Uri.parse('https://api.example.com/users'));
  // Parse JSON in a background isolate
  return compute(parseUsers, response.body);
}
Enter fullscreen mode Exit fullscreen mode

When to use compute():

  • Parsing JSON responses larger than ~50KB
  • Processing lists with hundreds or thousands of items
  • Any CPU-intensive transformation of network data

Limitations:

  • The function must be top-level or static
  • Arguments and return values must be serializable across isolates
  • For more complex isolate usage, consider Isolate.spawn() or the isolates package

Q9: How do you use @JsonConverter for custom type mapping?

Answer:

class DateTimeConverter implements JsonConverter<DateTime, String> {
  const DateTimeConverter();

  @override
  DateTime fromJson(String json) => DateTime.parse(json);

  @override
  String toJson(DateTime object) => object.toIso8601String();
}

class ColorConverter implements JsonConverter<Color, int> {
  const ColorConverter();

  @override
  Color fromJson(int json) => Color(json);

  @override
  int toJson(Color object) => object.value;
}

class DurationConverter implements JsonConverter<Duration, int> {
  const DurationConverter();

  @override
  Duration fromJson(int json) => Duration(milliseconds: json);

  @override
  int toJson(Duration object) => object.inMilliseconds;
}

@JsonSerializable()
@DateTimeConverter()  // Apply to all DateTime fields in this class
class Event {
  final String name;
  final DateTime startDate;
  final DateTime endDate;

  @ColorConverter()   // Apply to a specific field
  final Color themeColor;

  @DurationConverter()
  final Duration duration;

  Event({
    required this.name,
    required this.startDate,
    required this.endDate,
    required this.themeColor,
    required this.duration,
  });

  factory Event.fromJson(Map<String, dynamic> json) => _$EventFromJson(json);
  Map<String, dynamic> toJson() => _$EventToJson(this);
}
Enter fullscreen mode Exit fullscreen mode

Q10: How does build_runner work with json_serializable and freezed?

Answer:
build_runner is Dart's code generation framework. It reads annotations in your source code and generates corresponding .g.dart (json_serializable) and .freezed.dart (freezed) files.

Commands:

# One-time build
dart run build_runner build --delete-conflicting-outputs

# Watch mode (rebuilds on file save)
dart run build_runner watch --delete-conflicting-outputs

# Clean generated files
dart run build_runner clean
Enter fullscreen mode Exit fullscreen mode

How it works:

  1. You annotate classes with @JsonSerializable() or @freezed
  2. You add part 'filename.g.dart'; and/or part 'filename.freezed.dart';
  3. build_runner reads annotations and generates the implementation files
  4. The generated code contains the actual fromJson/toJson/copyWith/== implementations

build.yaml configuration:

targets:
  $default:
    builders:
      json_serializable:
        options:
          any_map: false
          checked: true           # Adds runtime type checking
          explicit_to_json: true  # Calls toJson on nested objects
          field_rename: snake     # Converts camelCase to snake_case
          create_factory: true
          create_to_json: true
Enter fullscreen mode Exit fullscreen mode

Common issues:

  • "Conflicting outputs" -- use --delete-conflicting-outputs flag
  • Slow builds -- use watch mode during development
  • Missing part directive -- always include the correct part statement
  • Forgetting to re-run after model changes -- use watch mode

1.3 GraphQL in Flutter

Q1: How do you use GraphQL in Flutter?

Answer:
The most popular package is graphql_flutter (or graphql for non-widget usage). GraphQL allows clients to request exactly the data they need, reducing over-fetching and under-fetching.

// Setup
import 'package:graphql_flutter/graphql_flutter.dart';

final HttpLink httpLink = HttpLink('https://api.example.com/graphql');

final AuthLink authLink = AuthLink(
  getToken: () async => 'Bearer ${await getToken()}',
);

final Link link = authLink.concat(httpLink);

final ValueNotifier<GraphQLClient> client = ValueNotifier(
  GraphQLClient(
    link: link,
    cache: GraphQLCache(store: InMemoryStore()),
  ),
);

// Wrap your app
MaterialApp(
  home: GraphQLProvider(
    client: client,
    child: MyApp(),
  ),
);
Enter fullscreen mode Exit fullscreen mode

Query:

const String getUsers = r'''
  query GetUsers($limit: Int!) {
    users(limit: $limit) {
      id
      name
      email
      posts {
        id
        title
      }
    }
  }
''';

// Using Query widget
Query(
  options: QueryOptions(
    document: gql(getUsers),
    variables: {'limit': 10},
    pollInterval: const Duration(seconds: 30), // Auto-refresh
  ),
  builder: (result, {fetchMore, refetch}) {
    if (result.isLoading) return CircularProgressIndicator();
    if (result.hasException) return Text(result.exception.toString());

    final users = result.data!['users'] as List;
    return ListView.builder(
      itemCount: users.length,
      itemBuilder: (context, index) => Text(users[index]['name']),
    );
  },
);
Enter fullscreen mode Exit fullscreen mode

Mutation:

const String createUser = r'''
  mutation CreateUser($name: String!, $email: String!) {
    createUser(name: $name, email: $email) {
      id
      name
      email
    }
  }
''';

Mutation(
  options: MutationOptions(
    document: gql(createUser),
    onCompleted: (data) => print('User created: $data'),
    onError: (error) => print('Error: $error'),
  ),
  builder: (runMutation, result) {
    return ElevatedButton(
      onPressed: () => runMutation({'name': 'John', 'email': 'john@test.com'}),
      child: Text('Create User'),
    );
  },
);
Enter fullscreen mode Exit fullscreen mode

Q2: What are the advantages of GraphQL over REST in Flutter apps?

Answer:

Aspect REST GraphQL
Data fetching Fixed endpoints return fixed data Client requests exactly what it needs
Over-fetching Common (get entire user object for just the name) Eliminated (request only name field)
Under-fetching Common (need multiple calls for related data) Eliminated (get user + posts in one query)
Versioning /api/v1/, /api/v2/ Single endpoint, schema evolves
Caching HTTP caching with ETags, headers Normalized cache by type + ID
Real-time Requires WebSocket setup separately Built-in Subscriptions
Type system External (OpenAPI/Swagger) Intrinsic schema with strong typing
Tooling Postman, curl GraphiQL, Apollo DevTools
Mobile bandwidth More data transferred Minimal data transferred
Learning curve Lower Higher

When to choose GraphQL in Flutter:

  • Complex data requirements with many related entities
  • Mobile apps where bandwidth optimization matters
  • When different screens need different subsets of the same data
  • When the API serves multiple client types (web, mobile, TV)

When REST is better:

  • Simple CRUD operations
  • File uploads/downloads
  • Third-party APIs (most are REST)
  • Simpler caching needs

Q3: How do GraphQL subscriptions work in Flutter for real-time data?

Answer:
GraphQL subscriptions use WebSockets under the hood to push real-time updates from server to client.

// Setup with WebSocket link
final WebSocketLink wsLink = WebSocketLink(
  'wss://api.example.com/graphql',
  config: SocketClientConfig(
    autoReconnect: true,
    inactivityTimeout: const Duration(seconds: 30),
    initialPayload: () async => {
      'Authorization': 'Bearer ${await getToken()}',
    },
  ),
);

// Split link: use WebSocket for subscriptions, HTTP for queries/mutations
final Link link = Link.split(
  (request) => request.isSubscription,
  wsLink,
  authLink.concat(httpLink),
);

// Subscription
const String onMessageReceived = r'''
  subscription OnMessageReceived($chatId: ID!) {
    messageReceived(chatId: $chatId) {
      id
      text
      sender {
        name
      }
      createdAt
    }
  }
''';

Subscription(
  options: SubscriptionOptions(
    document: gql(onMessageReceived),
    variables: {'chatId': '123'},
  ),
  builder: (result) {
    if (result.isLoading) return Text('Connecting...');
    if (result.hasException) return Text('Error: ${result.exception}');

    final message = result.data!['messageReceived'];
    return Text('${message['sender']['name']}: ${message['text']}');
  },
);
Enter fullscreen mode Exit fullscreen mode

1.4 WebSockets

Q1: How do you implement WebSocket communication in Flutter?

Answer:
Flutter uses the web_socket_channel package for WebSocket communication. WebSockets provide full-duplex communication over a single TCP connection, unlike HTTP which is request-response only.

import 'package:web_socket_channel/web_socket_channel.dart';

// Connect
final channel = WebSocketChannel.connect(
  Uri.parse('wss://echo.websocket.org'),
);

// Listen for messages (Stream)
channel.stream.listen(
  (message) => print('Received: $message'),
  onError: (error) => print('Error: $error'),
  onDone: () => print('Connection closed'),
);

// Send messages (StreamSink)
channel.sink.add('Hello Server!');
channel.sink.add(jsonEncode({'type': 'ping', 'data': 'hello'}));

// Close connection
channel.sink.close();
Enter fullscreen mode Exit fullscreen mode

Using StreamBuilder in widgets:

class ChatScreen extends StatefulWidget { ... }

class _ChatScreenState extends State<ChatScreen> {
  late final WebSocketChannel _channel;

  @override
  void initState() {
    super.initState();
    _channel = WebSocketChannel.connect(Uri.parse('wss://chat.example.com'));
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Expanded(
          child: StreamBuilder(
            stream: _channel.stream,
            builder: (context, snapshot) {
              if (snapshot.hasData) {
                return Text('Message: ${snapshot.data}');
              }
              return Text('Waiting for messages...');
            },
          ),
        ),
        TextField(
          onSubmitted: (text) => _channel.sink.add(text),
        ),
      ],
    );
  }

  @override
  void dispose() {
    _channel.sink.close();
    super.dispose();
  }
}
Enter fullscreen mode Exit fullscreen mode

Q2: How do you implement WebSocket reconnection logic?

Answer:

class WebSocketManager {
  WebSocketChannel? _channel;
  Timer? _reconnectTimer;
  Timer? _heartbeatTimer;
  bool _isConnected = false;
  int _reconnectAttempts = 0;
  final int maxReconnectAttempts = 10;
  final String url;
  final StreamController<dynamic> _messageController = StreamController.broadcast();

  Stream<dynamic> get messages => _messageController.stream;

  WebSocketManager(this.url);

  void connect() {
    try {
      _channel = WebSocketChannel.connect(Uri.parse(url));
      _isConnected = true;
      _reconnectAttempts = 0;

      _channel!.stream.listen(
        (message) {
          _messageController.add(message);
        },
        onError: (error) {
          _isConnected = false;
          _scheduleReconnect();
        },
        onDone: () {
          _isConnected = false;
          _scheduleReconnect();
        },
      );

      _startHeartbeat();
    } catch (e) {
      _scheduleReconnect();
    }
  }

  void _scheduleReconnect() {
    if (_reconnectAttempts >= maxReconnectAttempts) return;

    _reconnectAttempts++;
    final delay = Duration(seconds: min(pow(2, _reconnectAttempts).toInt(), 60));

    _reconnectTimer?.cancel();
    _reconnectTimer = Timer(delay, connect);
  }

  void _startHeartbeat() {
    _heartbeatTimer?.cancel();
    _heartbeatTimer = Timer.periodic(
      const Duration(seconds: 30),
      (_) {
        if (_isConnected) {
          send(jsonEncode({'type': 'ping'}));
        }
      },
    );
  }

  void send(String message) {
    if (_isConnected) {
      _channel?.sink.add(message);
    }
  }

  void dispose() {
    _reconnectTimer?.cancel();
    _heartbeatTimer?.cancel();
    _channel?.sink.close();
    _messageController.close();
  }
}
Enter fullscreen mode Exit fullscreen mode

Key concepts:

  • Exponential backoff -- wait longer between each reconnection attempt
  • Heartbeat/ping -- keep the connection alive and detect disconnections early
  • Broadcast stream -- allows multiple listeners on the message stream
  • Max attempts -- prevent infinite reconnection loops

Q3: What is the difference between WebSockets, Server-Sent Events (SSE), and long polling?

Answer:

Feature WebSocket SSE Long Polling
Direction Bidirectional Server to client only Client polls server
Protocol WS/WSS HTTP HTTP
Connection Persistent Persistent Repeated short connections
Browser support Excellent Good (no IE) Universal
Flutter package web_socket_channel eventsource / http http with loop
Use case Chat, gaming, real-time collaboration Notifications, live feeds, stock tickers Fallback when others unavailable
Overhead Low (after handshake) Low High (repeated HTTP headers)
Reconnection Manual Auto-reconnect built into spec Built into polling loop

Choose WebSocket when you need bidirectional real-time communication (chat, multiplayer games).
Choose SSE when you only need server-to-client updates (notifications, live scores).
Choose long polling as a fallback or when infrastructure doesn't support persistent connections.


1.5 Error Handling in Network Calls

Q1: What is the best practice for error handling in Flutter network calls?

Answer:
Use a combination of try-catch, custom exception classes, and the Either pattern (from dartz or fpdart) for functional error handling.

// Custom exception hierarchy
abstract class AppException implements Exception {
  final String message;
  final int? statusCode;
  const AppException(this.message, {this.statusCode});
}

class NetworkException extends AppException {
  const NetworkException(super.message, {super.statusCode});
}

class ServerException extends AppException {
  const ServerException(super.message, {super.statusCode});
}

class CacheException extends AppException {
  const CacheException(super.message);
}

class UnauthorizedException extends AppException {
  const UnauthorizedException(super.message) : super(statusCode: 401);
}

// Failure classes for the domain layer (used with Either pattern)
abstract class Failure {
  final String message;
  const Failure(this.message);
}

class ServerFailure extends Failure {
  const ServerFailure(super.message);
}

class NetworkFailure extends Failure {
  const NetworkFailure(super.message);
}

class CacheFailure extends Failure {
  const CacheFailure(super.message);
}
Enter fullscreen mode Exit fullscreen mode
// Repository with Either pattern
import 'package:dartz/dartz.dart';

class UserRepository {
  final Dio _dio;

  Future<Either<Failure, User>> getUser(int id) async {
    try {
      final response = await _dio.get('/users/$id');
      return Right(User.fromJson(response.data));
    } on DioException catch (e) {
      if (e.type == DioExceptionType.connectionError) {
        return const Left(NetworkFailure('No internet connection'));
      }
      return Left(ServerFailure(e.message ?? 'Server error'));
    } on FormatException {
      return const Left(ServerFailure('Invalid response format'));
    } catch (e) {
      return Left(ServerFailure('Unexpected error: $e'));
    }
  }
}

// In the UI/BLoC
final result = await userRepository.getUser(1);
result.fold(
  (failure) => emit(UserError(failure.message)),
  (user) => emit(UserLoaded(user)),
);
Enter fullscreen mode Exit fullscreen mode

Q2: How do you implement a network-aware wrapper that handles connectivity?

Answer:

import 'package:connectivity_plus/connectivity_plus.dart';

class NetworkInfo {
  final Connectivity _connectivity;

  NetworkInfo(this._connectivity);

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

  Stream<ConnectivityResult> get onConnectivityChanged =>
      _connectivity.onConnectivityChanged;
}

// Safe network call wrapper
class SafeApiCall {
  final NetworkInfo _networkInfo;

  SafeApiCall(this._networkInfo);

  Future<Either<Failure, T>> call<T>(
    Future<T> Function() apiCall, {
    Future<T> Function()? cacheCall,
  }) async {
    if (!await _networkInfo.isConnected) {
      if (cacheCall != null) {
        try {
          return Right(await cacheCall());
        } catch (_) {
          return const Left(CacheFailure('No cached data available'));
        }
      }
      return const Left(NetworkFailure('No internet connection'));
    }

    try {
      final result = await apiCall();
      return Right(result);
    } on DioException catch (e) {
      return Left(_mapDioError(e));
    } on SocketException {
      return const Left(NetworkFailure('Connection lost'));
    } on FormatException {
      return const Left(ServerFailure('Invalid data format'));
    } catch (e) {
      return Left(ServerFailure(e.toString()));
    }
  }

  Failure _mapDioError(DioException e) {
    switch (e.type) {
      case DioExceptionType.connectionTimeout:
      case DioExceptionType.receiveTimeout:
      case DioExceptionType.sendTimeout:
        return const NetworkFailure('Connection timed out');
      case DioExceptionType.badResponse:
        return ServerFailure('Server error: ${e.response?.statusCode}');
      case DioExceptionType.cancel:
        return const NetworkFailure('Request cancelled');
      default:
        return const NetworkFailure('Network error occurred');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Q3: How do you implement offline-first architecture with network error handling?

Answer:

class UserRepository {
  final UserRemoteDataSource _remote;
  final UserLocalDataSource _local;
  final NetworkInfo _networkInfo;

  UserRepository(this._remote, this._local, this._networkInfo);

  Future<Either<Failure, List<User>>> getUsers() async {
    if (await _networkInfo.isConnected) {
      try {
        final remoteUsers = await _remote.getUsers();
        await _local.cacheUsers(remoteUsers); // Cache for offline use
        return Right(remoteUsers);
      } on DioException catch (e) {
        // Network call failed, try cache
        return _fallbackToCache();
      }
    } else {
      return _fallbackToCache();
    }
  }

  Future<Either<Failure, List<User>>> _fallbackToCache() async {
    try {
      final cachedUsers = await _local.getCachedUsers();
      return Right(cachedUsers);
    } on CacheException {
      return const Left(CacheFailure('No cached data available'));
    }
  }

  /// Stream that emits cached data first, then remote data when available
  Stream<Either<Failure, List<User>>> getUsersStream() async* {
    // Emit cached data immediately
    try {
      final cached = await _local.getCachedUsers();
      yield Right(cached);
    } catch (_) {}

    // Then try network
    if (await _networkInfo.isConnected) {
      try {
        final remote = await _remote.getUsers();
        await _local.cacheUsers(remote);
        yield Right(remote);
      } on DioException catch (e) {
        yield Left(ServerFailure(e.message ?? 'Server error'));
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Q4: How do you handle SSL pinning in Flutter for security?

Answer:
SSL pinning ensures your app only communicates with servers that have a specific SSL certificate, preventing man-in-the-middle attacks.

// Using Dio with certificate pinning
import 'dart:io';

Dio createPinnedDio() {
  final dio = Dio();

  (dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
    final client = HttpClient();

    client.badCertificateCallback = (X509Certificate cert, String host, int port) {
      // Compare certificate fingerprint
      final expectedFingerprint = 'AA:BB:CC:DD:EE:FF:...'; // Your cert fingerprint
      final actualFingerprint = cert.sha256Fingerprint;
      return actualFingerprint == expectedFingerprint;
    };

    return client;
  };

  return dio;
}

// Using the http_certificate_pinning package for a cleaner approach
// Or using dio_certificate_pinning
final dio = Dio();
dio.interceptors.add(
  CertificatePinningInterceptor(
    allowedSHAFingerprints: [
      'AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD',
    ],
  ),
);
Enter fullscreen mode Exit fullscreen mode

Best practices:

  • Pin the intermediate CA certificate, not the leaf certificate (leaf certs rotate more often)
  • Include backup pins for certificate rotation
  • Implement a mechanism to update pins remotely for emergencies
  • Disable pinning in debug builds for proxy-based debugging


PART 2: LOCAL STORAGE


2.1 SharedPreferences

Q1: What is SharedPreferences, and when should you use it?

Answer:
SharedPreferences is a Flutter plugin that provides persistent key-value storage on all platforms. It wraps platform-specific persistent storage mechanisms: NSUserDefaults on iOS/macOS, SharedPreferences on Android, localStorage on web, and file-based storage on Linux/Windows.

Use it for:

  • User preferences (theme, language, notification settings)
  • Simple flags (has seen onboarding, is first launch)
  • Auth tokens (though flutter_secure_storage is preferred for sensitive data)
  • Small cached values (last selected tab, sort order)

Do NOT use it for:

  • Large datasets (use SQLite or Hive instead)
  • Complex objects or relational data
  • Sensitive data like passwords or credit card info (use secure storage)
  • Frequently changing data (writes go to disk)
import 'package:shared_preferences/shared_preferences.dart';

// Save data
final prefs = await SharedPreferences.getInstance();
await prefs.setString('username', 'JohnDoe');
await prefs.setInt('loginCount', 5);
await prefs.setBool('isDarkMode', true);
await prefs.setDouble('rating', 4.5);
await prefs.setStringList('recentSearches', ['flutter', 'dart']);

// Read data
final username = prefs.getString('username') ?? 'Guest';
final loginCount = prefs.getInt('loginCount') ?? 0;
final isDarkMode = prefs.getBool('isDarkMode') ?? false;
final rating = prefs.getDouble('rating') ?? 0.0;
final searches = prefs.getStringList('recentSearches') ?? [];

// Remove data
await prefs.remove('username');

// Clear all data
await prefs.clear();

// Check if a key exists
final hasKey = prefs.containsKey('username');
Enter fullscreen mode Exit fullscreen mode

Supported types: int, double, bool, String, List<String> only.


Q2: How do you test code that uses SharedPreferences?

Answer:

import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';

void main() {
  test('should save and read user preferences', () async {
    // Set mock initial values BEFORE getting instance
    SharedPreferences.setMockInitialValues({
      'username': 'TestUser',
      'isDarkMode': true,
      'loginCount': 3,
    });

    final prefs = await SharedPreferences.getInstance();
    expect(prefs.getString('username'), 'TestUser');
    expect(prefs.getBool('isDarkMode'), true);
    expect(prefs.getInt('loginCount'), 3);

    // Test writing
    await prefs.setString('username', 'NewUser');
    expect(prefs.getString('username'), 'NewUser');
  });
}
Enter fullscreen mode Exit fullscreen mode

The setMockInitialValues method is critical -- it must be called before getInstance() in tests to avoid platform channel errors.


Q3: What are the limitations of SharedPreferences, and what are alternatives?

Answer:

Limitations:

  1. Only supports primitive types and List<String>
  2. No encryption -- data is stored in plain text
  3. Not suitable for large data (loads entire file into memory)
  4. No query capability -- can only look up by key
  5. No support for complex data structures
  6. Asynchronous reads (though the new SharedPreferencesAsync / SharedPreferencesWithCache APIs address this)
  7. No transactional support
  8. Platform-specific size limits (varies, but generally not for >1MB)

Alternatives by use case:

Use Case Recommended
Sensitive data flutter_secure_storage
Complex objects Hive, Isar
Relational data sqflite, drift
Large datasets sqflite, drift, Isar
Reactive/stream-based Hive (listenable), drift (streams)
Fast key-value Hive

2.2 SQLite / sqflite / Drift (Moor)

Q1: How do you use sqflite for SQLite database operations in Flutter?

Answer:
sqflite is the most mature SQLite plugin for Flutter. It provides raw SQL access to a local SQLite database.

import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';

class DatabaseHelper {
  static Database? _database;

  Future<Database> get database async {
    if (_database != null) return _database!;
    _database = await _initDB();
    return _database!;
  }

  Future<Database> _initDB() async {
    final path = join(await getDatabasesPath(), 'app.db');

    return await openDatabase(
      path,
      version: 2,
      onCreate: (db, version) async {
        await db.execute('''
          CREATE TABLE users(
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            name TEXT NOT NULL,
            email TEXT UNIQUE NOT NULL,
            age INTEGER,
            created_at TEXT DEFAULT CURRENT_TIMESTAMP
          )
        ''');
        await db.execute('''
          CREATE TABLE posts(
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id INTEGER NOT NULL,
            title TEXT NOT NULL,
            body TEXT,
            FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
          )
        ''');
      },
      onUpgrade: (db, oldVersion, newVersion) async {
        if (oldVersion < 2) {
          await db.execute('ALTER TABLE users ADD COLUMN age INTEGER');
        }
      },
    );
  }

  // CRUD Operations
  Future<int> insertUser(User user) async {
    final db = await database;
    return await db.insert(
      'users',
      user.toMap(),
      conflictAlgorithm: ConflictAlgorithm.replace,
    );
  }

  Future<List<User>> getUsers() async {
    final db = await database;
    final maps = await db.query('users', orderBy: 'name ASC');
    return maps.map((map) => User.fromMap(map)).toList();
  }

  Future<User?> getUserById(int id) async {
    final db = await database;
    final maps = await db.query(
      'users',
      where: 'id = ?',
      whereArgs: [id],
      limit: 1,
    );
    return maps.isNotEmpty ? User.fromMap(maps.first) : null;
  }

  Future<int> updateUser(User user) async {
    final db = await database;
    return await db.update(
      'users',
      user.toMap(),
      where: 'id = ?',
      whereArgs: [user.id],
    );
  }

  Future<int> deleteUser(int id) async {
    final db = await database;
    return await db.delete('users', where: 'id = ?', whereArgs: [id]);
  }

  // Raw query
  Future<List<User>> searchUsers(String query) async {
    final db = await database;
    final maps = await db.rawQuery(
      'SELECT * FROM users WHERE name LIKE ? OR email LIKE ?',
      ['%$query%', '%$query%'],
    );
    return maps.map((map) => User.fromMap(map)).toList();
  }

  // Transaction
  Future<void> transferData() async {
    final db = await database;
    await db.transaction((txn) async {
      await txn.delete('old_table');
      await txn.insert('new_table', {'data': 'value'});
    });
  }

  // Batch operations (much faster for bulk inserts)
  Future<void> insertUsersInBatch(List<User> users) async {
    final db = await database;
    final batch = db.batch();
    for (final user in users) {
      batch.insert('users', user.toMap());
    }
    await batch.commit(noResult: true);
  }
}
Enter fullscreen mode Exit fullscreen mode

Critical security note: Always use whereArgs for parameterized queries to prevent SQL injection. Never use string interpolation in SQL statements.


Q2: What is Drift (formerly Moor), and how does it improve over raw sqflite?

Answer:
Drift is a type-safe, reactive persistence library for Flutter built on top of SQLite. It was originally called Moor (an anagram of "Room" from Android).

Key advantages over raw sqflite:

Feature sqflite Drift
Type safety None (raw SQL strings) Full compile-time type safety
Code generation None Automatic from table definitions
Reactive queries Manual Built-in Streams
Migrations Manual SQL Managed migration system
Query building Raw SQL only Type-safe Dart API + raw SQL
DAOs Manual Built-in DAO support
Testing Complex Easy with in-memory databases
Join support Manual mapping Type-safe joins
import 'package:drift/drift.dart';

part 'database.g.dart';

// Define tables
class Users extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get name => text().withLength(min: 1, max: 50)();
  TextColumn get email => text().unique()();
  IntColumn get age => integer().nullable()();
  DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}

class Posts extends Table {
  IntColumn get id => integer().autoIncrement()();
  IntColumn get userId => integer().references(Users, #id)();
  TextColumn get title => text()();
  TextColumn get body => text().nullable()();
}

// Define the database
@DriftDatabase(tables: [Users, Posts], daos: [UserDao])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(_openConnection());

  @override
  int get schemaVersion => 2;

  @override
  MigrationStrategy get migration => MigrationStrategy(
    onUpgrade: (migrator, from, to) async {
      if (from < 2) {
        await migrator.addColumn(users, users.age);
      }
    },
  );
}

// DAO with type-safe queries
@DriftAccessor(tables: [Users, Posts])
class UserDao extends DatabaseAccessor<AppDatabase> with _$UserDaoMixin {
  UserDao(AppDatabase db) : super(db);

  // Reactive query -- returns a Stream that updates automatically
  Stream<List<User>> watchAllUsers() => select(users).watch();

  Future<List<User>> getAllUsers() => select(users).get();

  Future<User> getUserById(int id) =>
      (select(users)..where((u) => u.id.equals(id))).getSingle();

  Future<int> insertUser(UsersCompanion user) => into(users).insert(user);

  Future<bool> updateUser(UsersCompanion user) => update(users).replace(user);

  Future<int> deleteUser(int id) =>
      (delete(users)..where((u) => u.id.equals(id))).go();

  // Join query
  Stream<List<UserWithPosts>> watchUsersWithPosts() {
    final query = select(users).join([
      leftOuterJoin(posts, posts.userId.equalsExp(users.id)),
    ]);
    return query.watch().map((rows) {
      // Map rows to UserWithPosts objects
      return rows.map((row) {
        return UserWithPosts(
          user: row.readTable(users),
          post: row.readTableOrNull(posts),
        );
      }).toList();
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Reactive queries are the biggest advantage -- UI updates automatically when database changes:

StreamBuilder<List<User>>(
  stream: database.userDao.watchAllUsers(),
  builder: (context, snapshot) {
    if (!snapshot.hasData) return CircularProgressIndicator();
    return ListView.builder(
      itemCount: snapshot.data!.length,
      itemBuilder: (_, i) => Text(snapshot.data![i].name),
    );
  },
);
Enter fullscreen mode Exit fullscreen mode

Q3: How do you handle database migrations in sqflite and Drift?

Answer:

sqflite migrations:

await openDatabase(
  path,
  version: 3, // Increment for each schema change
  onCreate: (db, version) async {
    // Called only on first install -- create all tables at latest version
    await db.execute('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT, phone TEXT)');
  },
  onUpgrade: (db, oldVersion, newVersion) async {
    // Called when version increases
    if (oldVersion < 2) {
      await db.execute('ALTER TABLE users ADD COLUMN email TEXT');
    }
    if (oldVersion < 3) {
      await db.execute('ALTER TABLE users ADD COLUMN phone TEXT');
    }
  },
  onDowngrade: onDatabaseDowngradeDelete, // Delete and recreate on downgrade
);
Enter fullscreen mode Exit fullscreen mode

Drift migrations:

@override
int get schemaVersion => 3;

@override
MigrationStrategy get migration => MigrationStrategy(
  onCreate: (m) async {
    await m.createAll(); // Creates all tables from current schema
  },
  onUpgrade: (m, from, to) async {
    if (from < 2) {
      await m.addColumn(users, users.email);
    }
    if (from < 3) {
      await m.addColumn(users, users.phone);
      await m.createTable(posts); // Add new table
    }
  },
  beforeOpen: (details) async {
    // Enable foreign keys
    await customStatement('PRAGMA foreign_keys = ON');

    if (details.wasCreated) {
      // Seed initial data
      await into(users).insert(UsersCompanion.insert(name: 'Admin', email: 'admin@app.com'));
    }
  },
);
Enter fullscreen mode Exit fullscreen mode

Best practices for migrations:

  • Never modify existing migration steps -- only add new ones
  • Test each migration path (v1->v3, v2->v3, fresh install)
  • Back up user data before risky migrations
  • Use transactions for multi-step migrations

Q4: When would you choose sqflite vs Drift vs Hive vs Isar?

Answer:

Criteria sqflite Drift Hive Isar
Data model Relational Relational NoSQL key-value NoSQL document
Type safety None Full Partial Full
Query language Raw SQL Type-safe Dart Key lookup Type-safe Dart
Code generation No Yes Yes (TypeAdapters) Yes
Reactive No Yes (Streams) Yes (Listenable) Yes (Streams)
Performance Good Good (same SQLite) Excellent Excellent
Web support No Yes Yes Yes
Complex queries Full SQL Full SQL via Dart Limited Indexed queries
Learning curve Low (if you know SQL) Medium Low Low
Package maturity Very mature Mature Mature Newer
Encryption Via SQLCipher Via SQLCipher Built-in AES-256 Built-in

Choose sqflite when: You want direct SQL control, are experienced with SQL, or need complex queries without code generation overhead.

Choose Drift when: You want type-safe SQL, reactive queries, automatic migration management, and DAO support. Best for complex relational data.

Choose Hive when: You need fast key-value storage, simple data models, web support, and no native dependencies (pure Dart).

Choose Isar when: You need a high-performance NoSQL database with full-text search, type-safe queries, and cross-platform support including web.


2.3 Hive Database

Q1: What is Hive, and how do you use it in Flutter?

Answer:
Hive is a lightweight, blazing-fast, pure Dart key-value database. It requires no native dependencies, making it work on all platforms including web.

import 'package:hive_flutter/hive_flutter.dart';

// Initialize Hive
void main() async {
  await Hive.initFlutter(); // Uses path_provider internally

  // Register adapters for custom objects
  Hive.registerAdapter(UserAdapter());

  // Open boxes (a box is like a table/collection)
  await Hive.openBox<User>('users');
  await Hive.openBox('settings');

  runApp(MyApp());
}

// Basic CRUD operations
class UserStorage {
  final Box<User> _userBox = Hive.box<User>('users');

  // Create
  Future<void> addUser(User user) async {
    await _userBox.put(user.id, user); // Key-value put
    // or auto-increment key:
    await _userBox.add(user); // Returns int key
  }

  // Read
  User? getUser(String id) => _userBox.get(id);
  List<User> getAllUsers() => _userBox.values.toList();

  // Update
  Future<void> updateUser(User user) async {
    await _userBox.put(user.id, user);
  }

  // Delete
  Future<void> deleteUser(String id) async {
    await _userBox.delete(id);
  }

  // Clear all
  Future<void> clearAll() async {
    await _userBox.clear();
  }
}
Enter fullscreen mode Exit fullscreen mode

Q2: How do you create TypeAdapters in Hive for custom objects?

Answer:
Hive can only store primitives by default. For custom objects, you need TypeAdapters. You can generate them with hive_generator or write them manually.

With code generation (recommended):

import 'package:hive/hive.dart';

part 'user.g.dart';

@HiveType(typeId: 0) // Unique ID for each type (0-223)
class User extends HiveObject {
  @HiveField(0) // Unique field index (never change once set)
  final String id;

  @HiveField(1)
  final String name;

  @HiveField(2)
  final String email;

  @HiveField(3, defaultValue: false) // Default for backward compatibility
  final bool isActive;

  User({
    required this.id,
    required this.name,
    required this.email,
    this.isActive = false,
  });
}
Enter fullscreen mode Exit fullscreen mode

Run: dart run build_runner build

Manual TypeAdapter:

class UserAdapter extends TypeAdapter<User> {
  @override
  final int typeId = 0;

  @override
  User read(BinaryReader reader) {
    return User(
      id: reader.readString(),
      name: reader.readString(),
      email: reader.readString(),
      isActive: reader.readBool(),
    );
  }

  @override
  void write(BinaryWriter writer, User obj) {
    writer.writeString(obj.id);
    writer.writeString(obj.name);
    writer.writeString(obj.email);
    writer.writeBool(obj.isActive);
  }
}
Enter fullscreen mode Exit fullscreen mode

Important rules:

  • typeId must be unique per type and between 0-223
  • @HiveField indices must never be changed or reused (for backward compatibility)
  • Register all adapters before opening boxes that use them
  • Extending HiveObject gives you save(), delete(), and key properties

Q3: How does Hive support reactive UI updates and encryption?

Answer:

Reactive UI with ValueListenableBuilder:

// Listen to entire box changes
ValueListenableBuilder(
  valueListenable: Hive.box<User>('users').listenable(),
  builder: (context, Box<User> box, _) {
    final users = box.values.toList();
    return ListView.builder(
      itemCount: users.length,
      itemBuilder: (_, i) => Text(users[i].name),
    );
  },
);

// Listen to specific keys only
ValueListenableBuilder(
  valueListenable: Hive.box('settings').listenable(keys: ['theme', 'locale']),
  builder: (context, Box box, _) {
    final theme = box.get('theme', defaultValue: 'light');
    return Text('Theme: $theme');
  },
);
Enter fullscreen mode Exit fullscreen mode

Encryption:

import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

Future<Box<User>> openEncryptedBox() async {
  const secureStorage = FlutterSecureStorage();

  // Store encryption key in secure storage
  var encryptionKeyString = await secureStorage.read(key: 'hive_key');
  if (encryptionKeyString == null) {
    final key = Hive.generateSecureKey();
    await secureStorage.write(key: 'hive_key', value: base64UrlEncode(key));
    encryptionKeyString = base64UrlEncode(key);
  }

  final encryptionKey = base64Url.decode(encryptionKeyString);

  return await Hive.openBox<User>(
    'secure_users',
    encryptionCipher: HiveAesCipher(encryptionKey),
  );
}
Enter fullscreen mode Exit fullscreen mode

Hive uses AES-256 encryption. The key should be stored in flutter_secure_storage (which uses Keychain on iOS and EncryptedSharedPreferences on Android).


Q4: What are the limitations of Hive?

Answer:

  1. No relational queries -- No JOINs, GROUP BY, or complex SQL. You must manage relationships manually.
  2. No query language -- You can only filter using box.values.where(...), which scans the entire box.
  3. Schema migration challenges -- Changing field indices or types requires manual migration.
  4. Memory usage -- Boxes are loaded entirely into memory on open. Large datasets may cause issues.
  5. No full-text search -- Must implement manually or use a different solution.
  6. Lazy boxes help with memory but are slower since items are read from disk on access.
  7. No built-in indexing -- Lookups by anything other than the key are O(n).
// LazyBox for large datasets (not loaded into memory)
final lazyBox = await Hive.openLazyBox<User>('users');
final user = await lazyBox.get('user123'); // Reads from disk each time
Enter fullscreen mode Exit fullscreen mode

2.4 Isar Database

Q1: What is Isar, and what makes it different from other Flutter databases?

Answer:
Isar is a high-performance, cross-platform NoSQL database for Flutter. It supports all platforms including web, and provides a type-safe query API with code generation.

Key features:

  • Full-text search built in
  • Multi-isolate safe (access from any isolate)
  • ACID compliant
  • Automatic indexing
  • Lazy-loaded and memory efficient
  • Built-in Inspector for debugging
  • Synchronous AND asynchronous APIs
  • Cross-platform including web (uses IndexedDB)
import 'package:isar/isar.dart';

part 'user.g.dart';

@collection
class User {
  Id id = Isar.autoIncrement;

  @Index(type: IndexType.value)
  late String name;

  @Index(unique: true)
  late String email;

  int? age;

  @Index(type: IndexType.value, caseSensitive: false)
  late String role;

  // Links (relationships)
  final posts = IsarLinks<Post>();
}

@collection
class Post {
  Id id = Isar.autoIncrement;
  late String title;
  late String body;

  @Backlink(to: 'posts')
  final author = IsarLink<User>();
}

// Initialize
final isar = await Isar.open([UserSchema, PostSchema]);

// Write
await isar.writeTxn(() async {
  final user = User()
    ..name = 'John'
    ..email = 'john@example.com'
    ..age = 30
    ..role = 'admin';
  await isar.users.put(user);
});

// Query with type-safe filters
final admins = await isar.users
    .filter()
    .roleEqualTo('admin')
    .ageGreaterThan(18)
    .sortByName()
    .findAll();

// Full-text search
final results = await isar.users
    .filter()
    .nameContains('john', caseSensitive: false)
    .findAll();

// Watch for changes (reactive)
Stream<List<User>> watchUsers() {
  return isar.users.where().watch(fireImmediately: true);
}

// Transactions
await isar.writeTxn(() async {
  await isar.users.put(user);
  await isar.posts.putAll(posts);
  await user.posts.save(); // Save links
});
Enter fullscreen mode Exit fullscreen mode

Q2: How does Isar handle indexes and why are they important?

Answer:
Indexes dramatically speed up queries by creating optimized lookup structures. Without indexes, Isar must scan every document (full table scan).

@collection
class Product {
  Id id = Isar.autoIncrement;

  // Single-field index for fast equality/range lookups
  @Index()
  late String name;

  // Unique index -- prevents duplicate values
  @Index(unique: true)
  late String sku;

  // Composite index for multi-field queries
  @Index(composite: [CompositeIndex('price')])
  late String category;
  late double price;

  // Hash index -- uses less space, only supports equality checks
  @Index(type: IndexType.hash)
  late String description;

  // Multi-entry index for lists
  @Index(type: IndexType.value)
  late List<String> tags;
}

// Queries that benefit from indexes:
final electronics = await isar.products
    .where()
    .categoryEqualTo('Electronics')     // Uses composite index
    .priceGreaterThan(100)              // Continues with composite index
    .findAll();

final byTag = await isar.products
    .filter()
    .tagsElementContains('sale')        // Uses multi-entry index
    .findAll();
Enter fullscreen mode Exit fullscreen mode

Index types:

  • IndexType.value -- default, supports all query operations
  • IndexType.hash -- smaller storage, only equality checks
  • IndexType.hashElements -- for lists, hashes each element

2.5 File Storage & path_provider

Q1: How do you read and write files in Flutter using path_provider?

Answer:
The path_provider package provides platform-specific paths for storing files. Combined with dart:io, it enables file operations.

import 'dart:io';
import 'package:path_provider/path_provider.dart';

class FileStorageService {
  // Get platform-specific directories
  Future<String> get _localPath async {
    final directory = await getApplicationDocumentsDirectory();
    return directory.path;
  }

  Future<String> get _tempPath async {
    final directory = await getTemporaryDirectory();
    return directory.path;
  }

  Future<String> get _supportPath async {
    final directory = await getApplicationSupportDirectory();
    return directory.path;
  }

  // Write text file
  Future<File> writeTextFile(String filename, String content) async {
    final path = await _localPath;
    final file = File('$path/$filename');
    return file.writeAsString(content);
  }

  // Read text file
  Future<String> readTextFile(String filename) async {
    try {
      final path = await _localPath;
      final file = File('$path/$filename');
      return await file.readAsString();
    } catch (e) {
      return '';
    }
  }

  // Write binary data (images, etc.)
  Future<File> writeBytes(String filename, List<int> bytes) async {
    final path = await _localPath;
    final file = File('$path/$filename');
    return file.writeAsBytes(bytes);
  }

  // Delete file
  Future<void> deleteFile(String filename) async {
    final path = await _localPath;
    final file = File('$path/$filename');
    if (await file.exists()) {
      await file.delete();
    }
  }

  // List files in directory
  Future<List<FileSystemEntity>> listFiles() async {
    final path = await _localPath;
    final directory = Directory(path);
    return directory.listSync();
  }

  // Save cached network image
  Future<File> cacheImage(String url, String filename) async {
    final response = await http.get(Uri.parse(url));
    final tempDir = await _tempPath;
    final file = File('$tempDir/$filename');
    return file.writeAsBytes(response.bodyBytes);
  }
}
Enter fullscreen mode Exit fullscreen mode

Key directories:

Directory Purpose Persists across restarts Backed up
getApplicationDocumentsDirectory() User-generated content Yes Yes (iOS)
getApplicationSupportDirectory() App support files Yes Yes (iOS)
getTemporaryDirectory() Cache files No (OS may clear) No
getApplicationCacheDirectory() App cache No No
getExternalStorageDirectory() Android external storage Yes No
getDownloadsDirectory() Desktop downloads folder Yes N/A

Q2: How do you handle large file downloads and caching?

Answer:

class FileCacheManager {
  static const maxCacheAge = Duration(days: 7);
  static const maxCacheSize = 100 * 1024 * 1024; // 100MB

  Future<File> getCachedFile(String url) async {
    final cacheDir = await getTemporaryDirectory();
    final filename = _generateFilename(url);
    final file = File('${cacheDir.path}/cache/$filename');

    // Check if cached and not expired
    if (await file.exists()) {
      final lastModified = await file.lastModified();
      if (DateTime.now().difference(lastModified) < maxCacheAge) {
        return file; // Return cached file
      }
    }

    // Download fresh copy
    final response = await Dio().download(url, file.path);
    return file;
  }

  String _generateFilename(String url) {
    final bytes = utf8.encode(url);
    final hash = sha256.convert(bytes);
    return hash.toString();
  }

  Future<void> clearCache() async {
    final cacheDir = await getTemporaryDirectory();
    final cacheFolder = Directory('${cacheDir.path}/cache');
    if (await cacheFolder.exists()) {
      await cacheFolder.delete(recursive: true);
    }
  }

  Future<int> getCacheSize() async {
    final cacheDir = await getTemporaryDirectory();
    final cacheFolder = Directory('${cacheDir.path}/cache');
    if (!await cacheFolder.exists()) return 0;

    int totalSize = 0;
    await for (final entity in cacheFolder.list(recursive: true)) {
      if (entity is File) {
        totalSize += await entity.length();
      }
    }
    return totalSize;
  }
}
Enter fullscreen mode Exit fullscreen mode

2.6 Secure Storage (flutter_secure_storage)

Q1: What is flutter_secure_storage, and how does it work?

Answer:
flutter_secure_storage provides a secure way to store sensitive data like tokens, passwords, and API keys. It uses platform-specific encryption mechanisms.

Platform implementations:

  • iOS: Keychain Services (hardware-backed encryption)
  • Android: EncryptedSharedPreferences (AES-256, or KeyStore-backed)
  • macOS: Keychain Services
  • Linux: libsecret
  • Windows: Windows Credential Locker
  • Web: Not truly secure (uses sessionStorage/localStorage with encryption)
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class SecureStorageService {
  final FlutterSecureStorage _storage = const FlutterSecureStorage(
    aOptions: AndroidOptions(encryptedSharedPreferences: true),
    iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
  );

  // Store sensitive data
  Future<void> saveToken(String token) async {
    await _storage.write(key: 'auth_token', value: token);
  }

  Future<void> saveRefreshToken(String token) async {
    await _storage.write(key: 'refresh_token', value: token);
  }

  // Read sensitive data
  Future<String?> getToken() async {
    return await _storage.read(key: 'auth_token');
  }

  // Delete specific key
  Future<void> deleteToken() async {
    await _storage.delete(key: 'auth_token');
  }

  // Delete all stored data
  Future<void> clearAll() async {
    await _storage.deleteAll();
  }

  // Check if key exists
  Future<bool> hasToken() async {
    return await _storage.containsKey(key: 'auth_token');
  }

  // Read all key-value pairs
  Future<Map<String, String>> readAll() async {
    return await _storage.readAll();
  }
}
Enter fullscreen mode Exit fullscreen mode

Q2: When should you use flutter_secure_storage vs SharedPreferences?

Answer:

Criteria SharedPreferences flutter_secure_storage
Encryption None (plain text) Platform-level encryption
Speed Faster Slightly slower (encryption overhead)
Data types int, double, bool, String, List String only
Storage mechanism XML/plist file Keychain/KeyStore
Accessible after uninstall Sometimes (Android backup) No (Keychain items cleared)
Root/jailbreak safe No More resistant

Use flutter_secure_storage for:

  • Authentication tokens (access tokens, refresh tokens)
  • API keys and secrets
  • Encryption keys (e.g., for Hive encryption)
  • Biometric authentication data
  • Credit card tokens
  • User passwords (if you must store them locally)

Use SharedPreferences for:

  • Theme preference
  • Locale/language setting
  • Onboarding completion flag
  • Non-sensitive user preferences
  • UI state (last tab, sort order)

Q3: How do you handle token management (access token + refresh token) securely?

Answer:

class TokenManager {
  final FlutterSecureStorage _storage = const FlutterSecureStorage();
  static const _accessTokenKey = 'access_token';
  static const _refreshTokenKey = 'refresh_token';
  static const _tokenExpiryKey = 'token_expiry';

  Future<void> saveTokens({
    required String accessToken,
    required String refreshToken,
    required DateTime expiry,
  }) async {
    await Future.wait([
      _storage.write(key: _accessTokenKey, value: accessToken),
      _storage.write(key: _refreshTokenKey, value: refreshToken),
      _storage.write(key: _tokenExpiryKey, value: expiry.toIso8601String()),
    ]);
  }

  Future<String?> getAccessToken() async {
    final expiry = await _storage.read(key: _tokenExpiryKey);
    if (expiry != null && DateTime.parse(expiry).isBefore(DateTime.now())) {
      return null; // Token expired
    }
    return await _storage.read(key: _accessTokenKey);
  }

  Future<String?> getRefreshToken() => _storage.read(key: _refreshTokenKey);

  Future<bool> isTokenExpired() async {
    final expiry = await _storage.read(key: _tokenExpiryKey);
    if (expiry == null) return true;
    return DateTime.parse(expiry).isBefore(DateTime.now());
  }

  Future<void> clearTokens() async {
    await Future.wait([
      _storage.delete(key: _accessTokenKey),
      _storage.delete(key: _refreshTokenKey),
      _storage.delete(key: _tokenExpiryKey),
    ]);
  }
}

// Dio interceptor using TokenManager
class AuthInterceptor extends Interceptor {
  final TokenManager _tokenManager;
  final Dio _dio;

  AuthInterceptor(this._tokenManager, this._dio);

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
    final token = await _tokenManager.getAccessToken();
    if (token != null) {
      options.headers['Authorization'] = 'Bearer $token';
    }
    handler.next(options);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode == 401) {
      final refreshToken = await _tokenManager.getRefreshToken();
      if (refreshToken != null) {
        try {
          final response = await _dio.post('/auth/refresh', data: {
            'refresh_token': refreshToken,
          });
          await _tokenManager.saveTokens(
            accessToken: response.data['access_token'],
            refreshToken: response.data['refresh_token'],
            expiry: DateTime.parse(response.data['expiry']),
          );
          // Retry original request with new token
          err.requestOptions.headers['Authorization'] =
              'Bearer ${response.data['access_token']}';
          final retryResponse = await _dio.fetch(err.requestOptions);
          handler.resolve(retryResponse);
          return;
        } catch (_) {
          await _tokenManager.clearTokens();
        }
      }
    }
    handler.next(err);
  }
}
Enter fullscreen mode Exit fullscreen mode


PART 3: TESTING


3.1 Unit Testing - test Package, expect, Matchers

Q1: What is unit testing in Flutter, and how do you set it up?

Answer:
Unit testing verifies the behavior of a single function, method, or class in isolation. Flutter uses the test package for pure Dart tests and flutter_test for tests that need Flutter framework dependencies.

Setup:

flutter pub add dev:test
# flutter_test is included by default in Flutter projects
Enter fullscreen mode Exit fullscreen mode

File structure:

lib/
  services/
    calculator.dart
test/
  services/
    calculator_test.dart    # Must end with _test.dart
Enter fullscreen mode Exit fullscreen mode

Basic test:

import 'package:test/test.dart';
import 'package:my_app/services/calculator.dart';

void main() {
  late Calculator calculator;

  setUp(() {
    // Runs before EACH test
    calculator = Calculator();
  });

  tearDown(() {
    // Runs after EACH test - cleanup resources
  });

  setUpAll(() {
    // Runs once before ALL tests in this file
  });

  tearDownAll(() {
    // Runs once after ALL tests in this file
  });

  group('Addition', () {
    test('should return sum of two positive numbers', () {
      expect(calculator.add(2, 3), equals(5));
    });

    test('should handle negative numbers', () {
      expect(calculator.add(-1, -2), equals(-3));
    });

    test('should handle zero', () {
      expect(calculator.add(0, 5), equals(5));
    });
  });

  group('Division', () {
    test('should return correct quotient', () {
      expect(calculator.divide(10, 2), equals(5.0));
    });

    test('should throw on division by zero', () {
      expect(() => calculator.divide(10, 0), throwsArgumentError);
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Run tests:

flutter test                           # All tests
flutter test test/services/            # Tests in a directory
flutter test test/services/calculator_test.dart  # Specific file
flutter test --name "Addition"         # Tests matching name pattern
flutter test --tags "slow"             # Tests with specific tag
Enter fullscreen mode Exit fullscreen mode

Q2: What are the most commonly used matchers in Flutter testing?

Answer:

// Equality matchers
expect(value, equals(42));              // Deep equality
expect(value, isNot(equals(42)));       // Not equal
expect(value, same(instance));          // Same object reference (identity)

// Type matchers
expect(value, isA<String>());           // Type check
expect(value, isNull);
expect(value, isNotNull);
expect(value, isTrue);
expect(value, isFalse);
expect(value, isEmpty);
expect(value, isNotEmpty);

// Numeric matchers
expect(value, greaterThan(5));
expect(value, lessThan(10));
expect(value, greaterThanOrEqualTo(5));
expect(value, lessThanOrEqualTo(10));
expect(value, inInclusiveRange(1, 10));
expect(value, closeTo(3.14, 0.01));     // Within delta

// String matchers
expect(str, contains('hello'));
expect(str, startsWith('He'));
expect(str, endsWith('ld'));
expect(str, matches(RegExp(r'\d+')));
expect(str, equalsIgnoringCase('HELLO'));
expect(str, equalsIgnoringWhitespace('hello  world'));

// Collection matchers
expect(list, contains(42));
expect(list, containsAll([1, 2, 3]));
expect(list, containsAllInOrder([1, 2, 3]));
expect(list, hasLength(5));
expect(list, orderedEquals([1, 2, 3]));
expect(list, unorderedEquals([3, 1, 2]));
expect(list, everyElement(greaterThan(0)));
expect(list, anyElement(equals(42)));

// Map matchers
expect(map, containsPair('key', 'value'));
expect(map, isNot(containsPair('missing', anything)));

// Exception matchers
expect(() => throwingFn(), throwsException);
expect(() => throwingFn(), throwsA(isA<ArgumentError>()));
expect(() => throwingFn(), throwsA(predicate((e) =>
    e is FormatException && e.message.contains('invalid'))));
expect(() => throwingFn(), throwsStateError);
expect(() => throwingFn(), throwsArgumentError);

// Future matchers
expect(future, completes);
expect(future, completion(equals(42)));
expect(future, throwsA(isA<Exception>()));

// Stream matchers
expect(stream, emits(42));
expect(stream, emitsInOrder([1, 2, 3]));
expect(stream, emitsAnyOf([1, 2]));
expect(stream, emitsThrough(42));  // Skips until 42
expect(stream, emitsDone);
expect(stream, neverEmits(isNegative));

// Combining matchers
expect(value, allOf(isNotNull, greaterThan(0), lessThan(100)));
expect(value, anyOf(equals(1), equals(2), equals(3)));
Enter fullscreen mode Exit fullscreen mode

Q3: How do you use setUp, tearDown, setUpAll, and tearDownAll effectively?

Answer:

void main() {
  late Database database;
  late UserRepository repository;

  setUpAll(() async {
    // One-time initialization for all tests in this file
    // Good for: database connections, heavy object creation
    database = await Database.inMemory();
  });

  tearDownAll(() async {
    // Clean up after all tests complete
    await database.close();
  });

  setUp(() async {
    // Fresh state before EACH test
    // Good for: resetting state, creating fresh instances
    await database.clear();
    repository = UserRepository(database);
  });

  tearDown(() async {
    // Clean up after EACH test
    // Good for: closing streams, cancelling timers
  });

  group('UserRepository', () {
    // These also support setUp/tearDown scoped to the group
    setUp(() {
      // Additional setup for this group only
    });

    test('should insert user', () async {
      await repository.insert(User(name: 'John'));
      final users = await repository.getAll();
      expect(users, hasLength(1));
    });

    test('should start with empty database', () async {
      // This works because setUp clears the database before each test
      final users = await repository.getAll();
      expect(users, isEmpty);
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Best practices:

  • Use setUp to ensure each test has a clean state (tests should be independent)
  • Use setUpAll for expensive operations that don't affect test isolation
  • Always close resources (streams, databases, controllers) in tearDown
  • Group-level setUp/tearDown run in addition to top-level ones (outer first)

Q4: How do you organize and tag tests for selective execution?

Answer:

@Tags(['integration', 'slow'])
library;

import 'package:test/test.dart';

void main() {
  test('fast unit test', () {
    expect(1 + 1, 2);
  }, tags: 'unit');

  test('slow integration test', () async {
    // ... takes a long time
  }, tags: ['integration', 'slow']);

  test('skip this test', () {
    // ...
  }, skip: 'Waiting for API fix #1234');

  test('only on CI', () {
    // ...
  }, onPlatform: {'mac-os': Skip('Not supported on macOS')});
}
Enter fullscreen mode Exit fullscreen mode

Configure in dart_test.yaml:

tags:
  slow:
    timeout: 5m
  integration:
    timeout: 10m

# Run all except slow tests by default
override_platforms:
  chrome:
    settings:
      headless: true
Enter fullscreen mode Exit fullscreen mode

Run selectively:

flutter test --tags unit              # Only unit-tagged tests
flutter test --exclude-tags slow      # Skip slow tests
flutter test --tags "unit && !slow"   # Unit tests that aren't slow
Enter fullscreen mode Exit fullscreen mode

Q5: How do you test code that depends on the current time or timers?

Answer:
Use the clock package or fake_async package to control time in tests.

import 'package:fake_async/fake_async.dart';

void main() {
  test('debouncer should delay execution', () {
    fakeAsync((async) {
      bool called = false;
      final debouncer = Debouncer(duration: Duration(milliseconds: 300));

      debouncer.run(() => called = true);

      // Advance time by 200ms - should NOT have been called yet
      async.elapse(Duration(milliseconds: 200));
      expect(called, isFalse);

      // Advance another 200ms (total 400ms) - should have been called
      async.elapse(Duration(milliseconds: 200));
      expect(called, isTrue);
    });
  });

  test('cache should expire after TTL', () {
    fakeAsync((async) {
      final cache = Cache<String>(ttl: Duration(minutes: 5));
      cache.set('key', 'value');

      expect(cache.get('key'), equals('value'));

      async.elapse(Duration(minutes: 4));
      expect(cache.get('key'), equals('value')); // Still valid

      async.elapse(Duration(minutes: 2));
      expect(cache.get('key'), isNull); // Expired
    });
  });

  test('periodic timer fires correct number of times', () {
    fakeAsync((async) {
      int count = 0;
      Timer.periodic(Duration(seconds: 1), (_) => count++);

      async.elapse(Duration(seconds: 5));
      expect(count, equals(5));
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

3.2 Widget Testing - WidgetTester, find, pump, pumpAndSettle

Q1: How do you write widget tests in Flutter?

Answer:
Widget tests (component tests) verify that widgets render correctly and respond to interactions. They run in a test environment without needing a real device.

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/screens/counter_screen.dart';

void main() {
  testWidgets('Counter increments smoke test', (WidgetTester tester) async {
    // 1. Build the widget
    await tester.pumpWidget(const MaterialApp(home: CounterScreen()));

    // 2. Find widgets
    expect(find.text('0'), findsOneWidget);
    expect(find.text('1'), findsNothing);

    // 3. Simulate interaction
    await tester.tap(find.byIcon(Icons.add));

    // 4. Rebuild after state change
    await tester.pump();

    // 5. Verify new state
    expect(find.text('1'), findsOneWidget);
    expect(find.text('0'), findsNothing);
  });
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • Use testWidgets instead of test for widget tests
  • Always wrap widgets in MaterialApp or WidgetsApp for theme/navigation context
  • pump() triggers a single frame rebuild
  • pumpAndSettle() waits for all animations to finish
  • Tests run headlessly and very quickly

Q2: What is the difference between pump(), pumpAndSettle(), and pumpWidget()?

Answer:

Method Purpose When to use
pumpWidget(widget) Renders a widget tree for the first time At the beginning of each test
pump() Triggers a single frame (rebuild) After setState, tap, or text input
pump(Duration) Triggers a frame and advances clock by duration Animations at a specific point in time
pumpAndSettle() Pumps frames until no more are scheduled After triggering animations, page transitions
pumpFrames(int) Pumps a specific number of frames Fine-grained animation testing
testWidgets('animation test', (tester) async {
  await tester.pumpWidget(MaterialApp(home: AnimatedScreen()));

  // Trigger animation
  await tester.tap(find.byType(ElevatedButton));

  // Advance 500ms into animation
  await tester.pump(const Duration(milliseconds: 500));
  // Check intermediate animation state...

  // Wait for animation to fully complete
  await tester.pumpAndSettle();
  // Check final state...
});

// CAUTION: pumpAndSettle will timeout if there's an infinite animation
// (like a CircularProgressIndicator). Use pump() with duration instead:
testWidgets('loading indicator test', (tester) async {
  await tester.pumpWidget(MaterialApp(home: LoadingScreen()));

  // DON'T do this - will timeout because of infinite animation:
  // await tester.pumpAndSettle();

  // DO this instead:
  await tester.pump(const Duration(seconds: 1));
  expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
Enter fullscreen mode Exit fullscreen mode

Q3: What are the different Finder methods available in widget testing?

Answer:

// Find by text content
find.text('Hello');                      // Exact match
find.textContaining('Hel');              // Partial match

// Find by widget type
find.byType(ElevatedButton);
find.byType(Text);

// Find by Icon
find.byIcon(Icons.add);

// Find by Key
find.byKey(const Key('submit_button'));
find.byKey(const ValueKey('email_field'));

// Find by tooltip
find.byTooltip('Add item');

// Find by widget predicate (custom condition)
find.byWidgetPredicate(
  (widget) => widget is Text && widget.data!.startsWith('Error'),
);

// Find by element predicate
find.byElementPredicate(
  (element) => element.widget is Container && element.size!.width > 100,
);

// Find descendant / ancestor
find.descendant(
  of: find.byType(ListTile),
  matching: find.text('John'),
);

find.ancestor(
  of: find.text('John'),
  matching: find.byType(ListTile),
);

// Find by semantics label (for accessibility)
find.bySemanticsLabel('Submit button');
find.bySemanticsLabel(RegExp(r'item \d+'));

// Widget matchers
expect(find.text('Hello'), findsOneWidget);
expect(find.text('Missing'), findsNothing);
expect(find.byType(ListTile), findsNWidgets(5));
expect(find.byType(Text), findsWidgets); // At least one
expect(find.byType(ListTile), findsAtLeast(3));
expect(find.byType(ListTile), findsAtMost(10));
Enter fullscreen mode Exit fullscreen mode

Q4: How do you simulate user interactions in widget tests?

Answer:

testWidgets('user interaction tests', (tester) async {
  await tester.pumpWidget(MaterialApp(home: MyForm()));

  // TAP
  await tester.tap(find.byType(ElevatedButton));
  await tester.pump();

  // LONG PRESS
  await tester.longPress(find.byKey(const Key('item_1')));
  await tester.pump();

  // TEXT INPUT
  await tester.enterText(find.byType(TextField), 'Hello World');
  await tester.pump();

  // CLEAR TEXT
  await tester.enterText(find.byType(TextField), '');
  await tester.pump();

  // SCROLL
  await tester.drag(find.byType(ListView), const Offset(0, -300));
  await tester.pumpAndSettle();

  // FLING (fast scroll with momentum)
  await tester.fling(find.byType(ListView), const Offset(0, -500), 1000);
  await tester.pumpAndSettle();

  // SWIPE (for Dismissible, etc.)
  await tester.drag(find.byKey(const Key('item_1')), const Offset(-500, 0));
  await tester.pumpAndSettle();

  // DOUBLE TAP
  await tester.tap(find.byType(GestureDetector));
  await tester.pump(const Duration(milliseconds: 50));
  await tester.tap(find.byType(GestureDetector));
  await tester.pump();

  // KEYBOARD ACTIONS
  await tester.testTextInput.receiveAction(TextInputAction.done);
  await tester.pump();

  // PINCH TO ZOOM (for InteractiveViewer)
  final center = tester.getCenter(find.byType(InteractiveViewer));
  final gesture1 = await tester.startGesture(center + const Offset(-10, 0));
  final gesture2 = await tester.startGesture(center + const Offset(10, 0));
  await gesture1.moveBy(const Offset(-50, 0));
  await gesture2.moveBy(const Offset(50, 0));
  await gesture1.up();
  await gesture2.up();
  await tester.pumpAndSettle();
});
Enter fullscreen mode Exit fullscreen mode

Q5: How do you test widgets that depend on InheritedWidgets, providers, or navigation?

Answer:

// Testing with Provider/Riverpod
testWidgets('shows user name from provider', (tester) async {
  await tester.pumpWidget(
    ChangeNotifierProvider<UserNotifier>(
      create: (_) => UserNotifier()..setUser(User(name: 'John')),
      child: const MaterialApp(home: ProfileScreen()),
    ),
  );
  expect(find.text('John'), findsOneWidget);
});

// Testing with BLoC
testWidgets('shows users from BLoC', (tester) async {
  final mockBloc = MockUserBloc();
  when(() => mockBloc.state).thenReturn(UserLoaded([User(name: 'John')]));

  await tester.pumpWidget(
    BlocProvider<UserBloc>.value(
      value: mockBloc,
      child: const MaterialApp(home: UserListScreen()),
    ),
  );
  expect(find.text('John'), findsOneWidget);
});

// Testing navigation
testWidgets('navigates to detail screen on tap', (tester) async {
  await tester.pumpWidget(MaterialApp(
    home: const UserListScreen(),
    routes: {
      '/detail': (context) => const UserDetailScreen(),
    },
  ));

  await tester.tap(find.text('John'));
  await tester.pumpAndSettle();

  expect(find.byType(UserDetailScreen), findsOneWidget);
});

// Testing with GoRouter
testWidgets('GoRouter navigation test', (tester) async {
  final router = GoRouter(
    initialLocation: '/users',
    routes: [
      GoRoute(path: '/users', builder: (_, __) => const UserListScreen()),
      GoRoute(path: '/users/:id', builder: (_, state) =>
          UserDetailScreen(id: state.pathParameters['id']!)),
    ],
  );

  await tester.pumpWidget(MaterialApp.router(routerConfig: router));
  await tester.pumpAndSettle();

  expect(find.byType(UserListScreen), findsOneWidget);
});

// Testing with MediaQuery / theme
testWidgets('responsive layout test', (tester) async {
  // Set specific screen size
  tester.view.physicalSize = const Size(400, 800);
  tester.view.devicePixelRatio = 1.0;

  await tester.pumpWidget(const MaterialApp(home: ResponsiveScreen()));
  expect(find.byType(MobileLayout), findsOneWidget);

  // Reset
  tester.view.resetPhysicalSize();
  tester.view.resetDevicePixelRatio();
});
Enter fullscreen mode Exit fullscreen mode

Q6: How do you test widgets that make HTTP requests or use async data?

Answer:

testWidgets('displays user data from API', (tester) async {
  // Create mock repository
  final mockRepo = MockUserRepository();
  when(() => mockRepo.getUsers()).thenAnswer(
    (_) async => [User(id: 1, name: 'John', email: 'john@test.com')],
  );

  await tester.pumpWidget(
    RepositoryProvider<UserRepository>.value(
      value: mockRepo,
      child: const MaterialApp(home: UserListScreen()),
    ),
  );

  // Initially shows loading
  expect(find.byType(CircularProgressIndicator), findsOneWidget);

  // Wait for async data to load
  await tester.pumpAndSettle();

  // Now shows data
  expect(find.text('John'), findsOneWidget);
  expect(find.byType(CircularProgressIndicator), findsNothing);
});

// Testing FutureBuilder directly
testWidgets('FutureBuilder shows states correctly', (tester) async {
  final completer = Completer<String>();

  await tester.pumpWidget(MaterialApp(
    home: FutureBuilder<String>(
      future: completer.future,
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const CircularProgressIndicator();
        }
        if (snapshot.hasError) return Text('Error: ${snapshot.error}');
        return Text('Data: ${snapshot.data}');
      },
    ),
  ));

  // Loading state
  expect(find.byType(CircularProgressIndicator), findsOneWidget);

  // Complete the future
  completer.complete('Hello');
  await tester.pumpAndSettle();

  // Loaded state
  expect(find.text('Data: Hello'), findsOneWidget);
});
Enter fullscreen mode Exit fullscreen mode

Q7: How do you test custom painters and render objects?

Answer:

testWidgets('custom painter renders correctly', (tester) async {
  await tester.pumpWidget(MaterialApp(
    home: CustomPaint(
      painter: MyChartPainter(data: [10, 20, 30, 40]),
      size: const Size(300, 200),
    ),
  ));

  // Verify the widget exists
  expect(find.byType(CustomPaint), findsOneWidget);

  // Use golden tests for pixel-perfect verification
  await expectLater(
    find.byType(CustomPaint),
    matchesGoldenFile('goldens/chart_painter.png'),
  );
});

// Verify paint method was called with correct parameters
testWidgets('painter is called with correct size', (tester) async {
  final painter = MockMyPainter();

  await tester.pumpWidget(MaterialApp(
    home: SizedBox(
      width: 300,
      height: 200,
      child: CustomPaint(painter: painter),
    ),
  ));

  verify(() => painter.paint(any(), Size(300, 200))).called(1);
});
Enter fullscreen mode Exit fullscreen mode

Q8: How do you test dialogs, snackbars, and bottom sheets?

Answer:

testWidgets('shows confirmation dialog on delete', (tester) async {
  await tester.pumpWidget(const MaterialApp(home: ItemScreen()));

  // Tap delete button
  await tester.tap(find.byIcon(Icons.delete));
  await tester.pumpAndSettle();

  // Dialog is shown
  expect(find.text('Are you sure?'), findsOneWidget);
  expect(find.text('Cancel'), findsOneWidget);
  expect(find.text('Delete'), findsOneWidget);

  // Tap confirm
  await tester.tap(find.text('Delete'));
  await tester.pumpAndSettle();

  // Dialog is dismissed
  expect(find.text('Are you sure?'), findsNothing);
});

// Testing SnackBar
testWidgets('shows snackbar on save', (tester) async {
  await tester.pumpWidget(const MaterialApp(home: FormScreen()));

  await tester.tap(find.text('Save'));
  await tester.pump(); // Start animation

  expect(find.text('Saved successfully'), findsOneWidget);

  // Wait for snackbar to auto-dismiss
  await tester.pump(const Duration(seconds: 4));
  await tester.pumpAndSettle();
  expect(find.text('Saved successfully'), findsNothing);
});

// Testing BottomSheet
testWidgets('shows bottom sheet with options', (tester) async {
  await tester.pumpWidget(const MaterialApp(home: OptionsScreen()));

  await tester.tap(find.text('Show Options'));
  await tester.pumpAndSettle();

  expect(find.text('Option 1'), findsOneWidget);
  expect(find.text('Option 2'), findsOneWidget);

  // Dismiss by tapping outside (drag down)
  await tester.drag(find.text('Option 1'), const Offset(0, 300));
  await tester.pumpAndSettle();
  expect(find.text('Option 1'), findsNothing);
});
Enter fullscreen mode Exit fullscreen mode

3.3 Integration Testing - integration_test Package

Q1: What is integration testing in Flutter, and how does it differ from widget testing?

Answer:

Aspect Unit Test Widget Test Integration Test
Scope Single function/class Single widget/screen Full app or large flows
Speed Very fast (ms) Fast (ms-sec) Slow (seconds-minutes)
Dependencies Mocked Mocked/real Real (or mock server)
Platform No device needed No device needed Real device or emulator
Location test/ folder test/ folder integration_test/ folder
Confidence Low Medium High
Maintenance Low Medium High

Setup:

flutter pub add "dev:integration_test:{'sdk': 'flutter'}"
Enter fullscreen mode Exit fullscreen mode

Writing integration tests:

// integration_test/app_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:my_app/main.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('end-to-end test', () {
    testWidgets('complete user registration flow', (tester) async {
      app.main(); // Launch the real app
      await tester.pumpAndSettle();

      // Navigate to registration
      await tester.tap(find.text('Register'));
      await tester.pumpAndSettle();

      // Fill form
      await tester.enterText(
        find.byKey(const Key('name_field')), 'John Doe');
      await tester.enterText(
        find.byKey(const Key('email_field')), 'john@test.com');
      await tester.enterText(
        find.byKey(const Key('password_field')), 'Password123!');

      // Submit
      await tester.tap(find.byKey(const Key('submit_button')));
      await tester.pumpAndSettle(const Duration(seconds: 5));

      // Verify success
      expect(find.text('Welcome, John Doe!'), findsOneWidget);
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Run:

flutter test integration_test/app_test.dart
Enter fullscreen mode Exit fullscreen mode

Q2: How do you handle real API calls vs mock servers in integration tests?

Answer:

// Option 1: Use a mock HTTP server
import 'package:shelf/shelf.dart' as shelf;
import 'package:shelf/shelf_io.dart' as shelf_io;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  late HttpServer server;

  setUpAll(() async {
    // Start a local mock server
    final handler = const shelf.Pipeline()
        .addMiddleware(shelf.logRequests())
        .addHandler(_handleRequest);
    server = await shelf_io.serve(handler, 'localhost', 8080);
  });

  tearDownAll(() async {
    await server.close();
  });

  testWidgets('fetches data from mock server', (tester) async {
    // Configure app to use localhost:8080 as base URL
    app.main(baseUrl: 'http://localhost:8080');
    await tester.pumpAndSettle();
    // ...
  });
}

shelf.Response _handleRequest(shelf.Request request) {
  if (request.url.path == 'users') {
    return shelf.Response.ok(
      jsonEncode([{'id': 1, 'name': 'John'}]),
      headers: {'content-type': 'application/json'},
    );
  }
  return shelf.Response.notFound('Not found');
}

// Option 2: Use environment-based configuration
void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('login with test account', (tester) async {
    // Use staging API
    app.main(environment: Environment.staging);
    await tester.pumpAndSettle();

    await tester.enterText(find.byKey(const Key('email')), 'test@staging.com');
    await tester.enterText(find.byKey(const Key('password')), 'test123');
    await tester.tap(find.text('Login'));
    await tester.pumpAndSettle(const Duration(seconds: 10));

    expect(find.text('Dashboard'), findsOneWidget);
  });
}
Enter fullscreen mode Exit fullscreen mode

Q3: How do you take screenshots during integration tests?

Answer:

void main() {
  final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('screenshot workflow', (tester) async {
    app.main();
    await tester.pumpAndSettle();

    // Take screenshots at key points
    await binding.convertFlutterSurfaceToImage();
    await tester.pumpAndSettle();

    // Screenshot 1: Login screen
    await binding.takeScreenshot('01_login_screen');

    // Navigate and screenshot
    await tester.enterText(find.byKey(const Key('email')), 'user@test.com');
    await tester.enterText(find.byKey(const Key('password')), 'password');
    await tester.tap(find.text('Login'));
    await tester.pumpAndSettle(const Duration(seconds: 5));

    // Screenshot 2: Home screen
    await binding.takeScreenshot('02_home_screen');
  });
}
Enter fullscreen mode Exit fullscreen mode

This is especially useful for:

  • Automated screenshot generation for app store listings
  • Visual regression testing
  • Documenting user flows
  • Debugging CI failures

3.4 Mocking - Mockito, Mocktail

Q1: How do you use Mockito for mocking in Flutter?

Answer:
Mockito generates mock classes from annotations. It requires build_runner for code generation.

flutter pub add dev:mockito dev:build_runner
Enter fullscreen mode Exit fullscreen mode
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';

@GenerateMocks([UserRepository, AuthService])
import 'user_test.mocks.dart'; // Generated file

void main() {
  late MockUserRepository mockRepo;
  late MockAuthService mockAuth;

  setUp(() {
    mockRepo = MockUserRepository();
    mockAuth = MockAuthService();
  });

  test('should return user list', () async {
    // ARRANGE: Define mock behavior
    when(mockRepo.getUsers()).thenAnswer(
      (_) async => [User(id: 1, name: 'John')],
    );

    // ACT
    final result = await mockRepo.getUsers();

    // ASSERT
    expect(result, hasLength(1));
    expect(result.first.name, 'John');

    // VERIFY: Check that method was called
    verify(mockRepo.getUsers()).called(1);
    verifyNoMoreInteractions(mockRepo);
  });

  test('should throw on network error', () async {
    when(mockRepo.getUsers()).thenThrow(NetworkException('No internet'));

    expect(() => mockRepo.getUsers(), throwsA(isA<NetworkException>()));
  });

  // Argument matchers
  test('should find user by ID', () async {
    when(mockRepo.getUserById(any)).thenAnswer(
      (_) async => User(id: 1, name: 'John'),
    );

    // Specific argument
    when(mockRepo.getUserById(argThat(greaterThan(100)))).thenThrow(
      NotFoundException('User not found'),
    );

    final user = await mockRepo.getUserById(1);
    expect(user.name, 'John');

    expect(() => mockRepo.getUserById(999), throwsA(isA<NotFoundException>()));
  });

  // Capturing arguments
  test('should capture arguments', () async {
    when(mockRepo.updateUser(any)).thenAnswer((_) async => true);

    await mockRepo.updateUser(User(id: 1, name: 'Jane'));

    final captured = verify(mockRepo.updateUser(captureAny)).captured;
    expect((captured.first as User).name, 'Jane');
  });
}
Enter fullscreen mode Exit fullscreen mode

Generate mocks: dart run build_runner build


Q2: What is Mocktail, and how does it differ from Mockito?

Answer:
Mocktail provides the same mocking API as Mockito but WITHOUT code generation. You create mocks manually by extending Mock.

import 'package:mocktail/mocktail.dart';

// No code generation needed!
class MockUserRepository extends Mock implements UserRepository {}
class MockAuthService extends Mock implements AuthService {}

void main() {
  late MockUserRepository mockRepo;

  setUp(() {
    mockRepo = MockUserRepository();
  });

  test('should return users', () async {
    // Same API as Mockito
    when(() => mockRepo.getUsers()).thenAnswer(
      (_) async => [User(id: 1, name: 'John')],
    );

    final result = await mockRepo.getUsers();
    expect(result, hasLength(1));

    verify(() => mockRepo.getUsers()).called(1);
  });

  // Argument matchers
  test('with argument matchers', () async {
    // Register fallback value for custom types
    registerFallbackValue(User(id: 0, name: ''));

    when(() => mockRepo.updateUser(any())).thenAnswer((_) async => true);

    await mockRepo.updateUser(User(id: 1, name: 'Jane'));

    verify(() => mockRepo.updateUser(any(
      that: isA<User>().having((u) => u.name, 'name', 'Jane'),
    ))).called(1);
  });
}
Enter fullscreen mode Exit fullscreen mode

Key differences:

Feature Mockito Mocktail
Code generation Required (build_runner) Not needed
Mock creation @GenerateMocks annotation extends Mock implements X
Stub syntax when(mock.method()) when(() => mock.method())
Verify syntax verify(mock.method()) verify(() => mock.method())
Argument matchers any, argThat any(), any(that: matcher)
Custom types in matchers Works automatically Needs registerFallbackValue
Setup effort More (code gen) Less (manual mocks)

Recommendation: Use Mocktail for most projects. It is simpler, has no code generation step, and has the same API. Use Mockito if you need features like @GenerateNiceMocks or work on a project already using it.


Q3: How do you mock HTTP clients for network testing?

Answer:

// With Mocktail
class MockHttpClient extends Mock implements http.Client {}

void main() {
  late MockHttpClient mockClient;
  late UserApiService apiService;

  setUp(() {
    mockClient = MockHttpClient();
    apiService = UserApiService(mockClient);
    registerFallbackValue(Uri());
  });

  test('fetchUsers returns list on 200', () async {
    when(() => mockClient.get(any())).thenAnswer(
      (_) async => http.Response(
        jsonEncode([
          {'id': 1, 'name': 'John', 'email': 'john@test.com'},
          {'id': 2, 'name': 'Jane', 'email': 'jane@test.com'},
        ]),
        200,
        headers: {'content-type': 'application/json'},
      ),
    );

    final users = await apiService.fetchUsers();

    expect(users, hasLength(2));
    expect(users.first.name, 'John');
    verify(() => mockClient.get(Uri.parse('https://api.example.com/users'))).called(1);
  });

  test('fetchUsers throws on 500', () async {
    when(() => mockClient.get(any())).thenAnswer(
      (_) async => http.Response('Internal Server Error', 500),
    );

    expect(() => apiService.fetchUsers(), throwsA(isA<ServerException>()));
  });

  test('fetchUsers throws on network error', () async {
    when(() => mockClient.get(any())).thenThrow(
      const SocketException('No internet'),
    );

    expect(() => apiService.fetchUsers(), throwsA(isA<SocketException>()));
  });
}

// Mocking Dio
class MockDio extends Mock implements Dio {}

void main() {
  late MockDio mockDio;

  setUp(() {
    mockDio = MockDio();
    registerFallbackValue(RequestOptions(path: ''));
  });

  test('handles Dio error', () async {
    when(() => mockDio.get(any())).thenThrow(
      DioException(
        requestOptions: RequestOptions(path: '/users'),
        type: DioExceptionType.connectionTimeout,
        message: 'Connection timeout',
      ),
    );

    // Test your service that uses dio...
  });
}
Enter fullscreen mode Exit fullscreen mode

Q4: How do you create fake implementations instead of mocks?

Answer:
Sometimes fakes (custom implementations) are better than mocks for complex behavior.

// Fake implementation
class FakeUserRepository implements UserRepository {
  final List<User> _users = [];
  bool shouldThrow = false;

  @override
  Future<List<User>> getUsers() async {
    if (shouldThrow) throw ServerException('Error');
    return List.unmodifiable(_users);
  }

  @override
  Future<void> addUser(User user) async {
    if (shouldThrow) throw ServerException('Error');
    _users.add(user);
  }

  @override
  Future<User?> getUserById(int id) async {
    return _users.firstWhereOrNull((u) => u.id == id);
  }

  @override
  Future<void> deleteUser(int id) async {
    _users.removeWhere((u) => u.id == id);
  }

  void seed(List<User> users) {
    _users.addAll(users);
  }

  void reset() {
    _users.clear();
    shouldThrow = false;
  }
}

// Usage in tests
void main() {
  late FakeUserRepository fakeRepo;
  late UserBloc bloc;

  setUp(() {
    fakeRepo = FakeUserRepository();
    fakeRepo.seed([
      User(id: 1, name: 'John'),
      User(id: 2, name: 'Jane'),
    ]);
    bloc = UserBloc(fakeRepo);
  });

  test('loads users', () async {
    bloc.add(LoadUsers());
    await expectLater(
      bloc.stream,
      emitsInOrder([
        isA<UserLoading>(),
        isA<UserLoaded>().having((s) => s.users.length, 'count', 2),
      ]),
    );
  });

  test('handles errors', () async {
    fakeRepo.shouldThrow = true;
    bloc.add(LoadUsers());
    await expectLater(
      bloc.stream,
      emitsInOrder([
        isA<UserLoading>(),
        isA<UserError>(),
      ]),
    );
  });
}
Enter fullscreen mode Exit fullscreen mode

When to use Mocks vs Fakes:

  • Mocks: When you need to verify interactions (was a method called? how many times? with what arguments?)
  • Fakes: When you need realistic behavior (stateful, follows business logic, simulates a database)

3.5 Golden Tests

Q1: What are golden tests, and how do you implement them?

Answer:
Golden tests (snapshot tests) compare a widget's rendered output against a reference image file ("golden file"). They catch unintended visual changes.

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('ProfileCard golden test', (tester) async {
    await tester.pumpWidget(
      MaterialApp(
        theme: ThemeData.light(),
        home: Scaffold(
          body: ProfileCard(
            name: 'John Doe',
            email: 'john@example.com',
            avatarUrl: null, // Use placeholder for deterministic output
          ),
        ),
      ),
    );

    await expectLater(
      find.byType(ProfileCard),
      matchesGoldenFile('goldens/profile_card_light.png'),
    );
  });

  testWidgets('ProfileCard dark theme golden test', (tester) async {
    await tester.pumpWidget(
      MaterialApp(
        theme: ThemeData.dark(),
        home: Scaffold(
          body: ProfileCard(
            name: 'John Doe',
            email: 'john@example.com',
            avatarUrl: null,
          ),
        ),
      ),
    );

    await expectLater(
      find.byType(ProfileCard),
      matchesGoldenFile('goldens/profile_card_dark.png'),
    );
  });

  // Testing multiple screen sizes
  testWidgets('responsive layout golden', (tester) async {
    // Phone size
    tester.view.physicalSize = const Size(375, 812);
    tester.view.devicePixelRatio = 1.0;

    await tester.pumpWidget(const MaterialApp(home: HomeScreen()));
    await tester.pumpAndSettle();

    await expectLater(
      find.byType(HomeScreen),
      matchesGoldenFile('goldens/home_screen_phone.png'),
    );

    tester.view.resetPhysicalSize();
    tester.view.resetDevicePixelRatio();
  });
}
Enter fullscreen mode Exit fullscreen mode

Generate golden files:

flutter test --update-goldens
Enter fullscreen mode Exit fullscreen mode

Run golden tests (compare against existing):

flutter test
Enter fullscreen mode Exit fullscreen mode

Best practices:

  • Store golden files in version control
  • Use deterministic data (no timestamps, random values)
  • Replace network images with placeholders
  • Set fixed screen sizes for consistency
  • Run golden updates only when visual changes are intentional
  • Use CI to catch unintended golden changes
  • Consider the alchemist package for more advanced golden testing

Q2: How do you handle platform differences in golden tests?

Answer:
Golden file rendering can differ between macOS, Linux, and Windows due to font rendering differences. Solutions:

// 1. Use a custom font to ensure consistency
void main() {
  setUpAll(() async {
    // Load a custom font that renders identically on all platforms
    final font = rootBundle.load('assets/fonts/Roboto-Regular.ttf');
    final fontLoader = FontLoader('Roboto')..addFont(font);
    await fontLoader.load();
  });

  testWidgets('golden with custom font', (tester) async {
    await tester.pumpWidget(MaterialApp(
      theme: ThemeData(fontFamily: 'Roboto'),
      home: const MyWidget(),
    ));
    await expectLater(find.byType(MyWidget), matchesGoldenFile('goldens/my_widget.png'));
  });
}

// 2. Use platform-specific golden files
testWidgets('platform-specific golden', (tester) async {
  await tester.pumpWidget(const MaterialApp(home: MyWidget()));

  final platform = Platform.operatingSystem;
  await expectLater(
    find.byType(MyWidget),
    matchesGoldenFile('goldens/${platform}_my_widget.png'),
  );
});

// 3. Use the alchemist package for multi-scenario goldens
// It generates goldens across themes, sizes, and locales automatically
Enter fullscreen mode Exit fullscreen mode

3.6 Test Coverage

Q1: How do you measure and improve test coverage in Flutter?

Answer:

# Generate coverage report
flutter test --coverage

# This creates coverage/lcov.info file

# Convert to HTML report (requires lcov)
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html

# On Windows without genhtml, use the coverage package:
dart pub global activate coverage
dart pub global run coverage:format_coverage \
  --lcov --in=coverage --out=coverage/lcov.info --report-on=lib
Enter fullscreen mode Exit fullscreen mode

Using in CI (GitHub Actions):

- name: Run tests with coverage
  run: flutter test --coverage

- name: Check coverage threshold
  run: |
    COVERAGE=$(lcov --summary coverage/lcov.info 2>&1 | grep "lines" | cut -d ' ' -f 4 | cut -d '%' -f 1)
    if (( $(echo "$COVERAGE < 80" | bc -l) )); then
      echo "Coverage $COVERAGE% is below 80% threshold"
      exit 1
    fi
Enter fullscreen mode Exit fullscreen mode

Coverage report exclusions (in lcov.info):

# Remove generated files from coverage
lcov --remove coverage/lcov.info \
  '*.g.dart' \
  '*.freezed.dart' \
  '*/generated/*' \
  -o coverage/lcov_cleaned.info
Enter fullscreen mode Exit fullscreen mode

Q2: What does good test coverage look like and what should you prioritize?

Answer:

Coverage targets by layer:

Layer Target Priority
Business logic / Use cases 90%+ Highest
Repositories / Data sources 85%+ High
BLoC / Cubit / ViewModel 90%+ Highest
Utility functions 95%+ High
Widgets (critical) 70%+ Medium
Widgets (simple/UI-only) 50%+ Lower
Generated code Exclude N/A
Main/routing setup Exclude N/A

What to prioritize testing:

  1. Business logic and state management (most bugs hide here)
  2. Data transformation and parsing (JSON, model mapping)
  3. Error handling paths (network errors, validation)
  4. Edge cases (empty lists, null values, boundary conditions)
  5. Complex widget interactions (forms, dynamic lists)

What NOT to waste time testing:

  • Generated code (.g.dart, .freezed.dart)
  • Simple UI widgets with no logic (a Text widget wrapper)
  • Third-party package internals
  • Platform-specific boilerplate

3.7 BLoC Testing

Q1: How do you test BLoC/Cubit in Flutter?

Answer:
The bloc_test package provides blocTest for concise, readable BLoC tests.

flutter pub add dev:bloc_test
Enter fullscreen mode Exit fullscreen mode
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';

class MockUserRepository extends Mock implements UserRepository {}

void main() {
  late MockUserRepository mockRepo;

  setUp(() {
    mockRepo = MockUserRepository();
  });

  // Testing a Cubit
  group('UserCubit', () {
    blocTest<UserCubit, UserState>(
      'emits [loading, loaded] when loadUsers succeeds',
      build: () {
        when(() => mockRepo.getUsers()).thenAnswer(
          (_) async => [User(id: 1, name: 'John')],
        );
        return UserCubit(mockRepo);
      },
      act: (cubit) => cubit.loadUsers(),
      expect: () => [
        const UserState.loading(),
        isA<UserLoaded>().having((s) => s.users.length, 'user count', 1),
      ],
      verify: (_) {
        verify(() => mockRepo.getUsers()).called(1);
      },
    );

    blocTest<UserCubit, UserState>(
      'emits [loading, error] when loadUsers fails',
      build: () {
        when(() => mockRepo.getUsers()).thenThrow(ServerException('Error'));
        return UserCubit(mockRepo);
      },
      act: (cubit) => cubit.loadUsers(),
      expect: () => [
        const UserState.loading(),
        isA<UserError>().having((s) => s.message, 'message', 'Error'),
      ],
    );

    blocTest<UserCubit, UserState>(
      'emits nothing when loadUsers is called with existing data',
      build: () => UserCubit(mockRepo),
      seed: () => UserLoaded([User(id: 1, name: 'John')]), // Pre-set state
      act: (cubit) => cubit.loadUsers(),
      expect: () => [], // No new states emitted
    );
  });

  // Testing a BLoC (event-driven)
  group('AuthBloc', () {
    blocTest<AuthBloc, AuthState>(
      'emits [loading, authenticated] on LoginRequested',
      build: () {
        when(() => mockRepo.login(any(), any())).thenAnswer(
          (_) async => User(id: 1, name: 'John'),
        );
        return AuthBloc(mockRepo);
      },
      act: (bloc) => bloc.add(
        LoginRequested(email: 'john@test.com', password: 'pass123'),
      ),
      expect: () => [
        const AuthState.loading(),
        isA<AuthAuthenticated>(),
      ],
    );

    // Testing event transformations (debounce, etc.)
    blocTest<SearchBloc, SearchState>(
      'debounces search queries',
      build: () {
        when(() => mockRepo.search(any())).thenAnswer(
          (_) async => ['Result 1', 'Result 2'],
        );
        return SearchBloc(mockRepo);
      },
      act: (bloc) async {
        bloc.add(const SearchQueryChanged('f'));
        bloc.add(const SearchQueryChanged('fl'));
        bloc.add(const SearchQueryChanged('flu'));
        bloc.add(const SearchQueryChanged('flut'));
        await Future.delayed(const Duration(milliseconds: 500));
      },
      wait: const Duration(milliseconds: 600),
      expect: () => [
        const SearchState.loading(),
        isA<SearchLoaded>(),
      ],
      verify: (_) {
        // Only one search call due to debounce
        verify(() => mockRepo.search('flut')).called(1);
      },
    );
  });
}
Enter fullscreen mode Exit fullscreen mode

Q2: How do you test BLoC-to-BLoC communication?

Answer:

void main() {
  late MockAuthBloc mockAuthBloc;
  late MockUserRepository mockUserRepo;
  late ProfileBloc profileBloc;

  setUp(() {
    mockAuthBloc = MockAuthBloc();
    mockUserRepo = MockUserRepository();
  });

  tearDown(() {
    profileBloc.close();
  });

  blocTest<ProfileBloc, ProfileState>(
    'loads profile when auth state changes to authenticated',
    build: () {
      // Mock the auth bloc stream
      whenListen(
        mockAuthBloc,
        Stream.fromIterable([
          const AuthState.loading(),
          AuthState.authenticated(User(id: 1, name: 'John')),
        ]),
        initialState: const AuthState.initial(),
      );

      when(() => mockUserRepo.getProfile(1)).thenAnswer(
        (_) async => Profile(bio: 'Hello'),
      );

      return ProfileBloc(
        authBloc: mockAuthBloc,
        userRepository: mockUserRepo,
      );
    },
    expect: () => [
      const ProfileState.loading(),
      isA<ProfileLoaded>(),
    ],
  );
}
Enter fullscreen mode Exit fullscreen mode

The whenListen utility from bloc_test is used to mock a BLoC's stream and state for testing dependent BLoCs.


Q3: How do you test BLoC with widget integration?

Answer:

void main() {
  late MockUserCubit mockCubit;

  setUp(() {
    mockCubit = MockUserCubit();
  });

  testWidgets('displays loading indicator when state is loading', (tester) async {
    when(() => mockCubit.state).thenReturn(const UserState.loading());
    whenListen(mockCubit, const Stream<UserState>.empty(),
        initialState: const UserState.loading());

    await tester.pumpWidget(
      BlocProvider<UserCubit>.value(
        value: mockCubit,
        child: const MaterialApp(home: UserListScreen()),
      ),
    );

    expect(find.byType(CircularProgressIndicator), findsOneWidget);
  });

  testWidgets('displays user list when state is loaded', (tester) async {
    final loadedState = UserLoaded([
      User(id: 1, name: 'John'),
      User(id: 2, name: 'Jane'),
    ]);
    when(() => mockCubit.state).thenReturn(loadedState);
    whenListen(mockCubit, const Stream<UserState>.empty(),
        initialState: loadedState);

    await tester.pumpWidget(
      BlocProvider<UserCubit>.value(
        value: mockCubit,
        child: const MaterialApp(home: UserListScreen()),
      ),
    );

    expect(find.text('John'), findsOneWidget);
    expect(find.text('Jane'), findsOneWidget);
  });

  testWidgets('calls loadUsers on refresh', (tester) async {
    when(() => mockCubit.state).thenReturn(const UserState.initial());
    when(() => mockCubit.loadUsers()).thenAnswer((_) async {});
    whenListen(mockCubit, const Stream<UserState>.empty(),
        initialState: const UserState.initial());

    await tester.pumpWidget(
      BlocProvider<UserCubit>.value(
        value: mockCubit,
        child: const MaterialApp(home: UserListScreen()),
      ),
    );

    await tester.tap(find.byIcon(Icons.refresh));
    verify(() => mockCubit.loadUsers()).called(1);
  });
}
Enter fullscreen mode Exit fullscreen mode

3.8 Testing Async Code

Q1: How do you test async functions in Flutter?

Answer:

void main() {
  test('async function returns correct value', () async {
    final service = UserService();
    final result = await service.fetchUser(1);
    expect(result.name, 'John');
  });

  test('async function throws on error', () async {
    final service = UserService();
    expect(
      () async => await service.fetchUser(-1),
      throwsA(isA<NotFoundException>()),
    );
  });

  // Using Completer for fine-grained control
  test('handles concurrent requests', () async {
    final completer1 = Completer<User>();
    final completer2 = Completer<User>();

    final mockRepo = MockUserRepository();
    var callCount = 0;
    when(() => mockRepo.getUser(any())).thenAnswer((_) {
      callCount++;
      return callCount == 1 ? completer1.future : completer2.future;
    });

    final service = UserService(mockRepo);

    // Start two concurrent requests
    final future1 = service.getUser(1);
    final future2 = service.getUser(2);

    // Complete in reverse order
    completer2.complete(User(id: 2, name: 'Jane'));
    completer1.complete(User(id: 1, name: 'John'));

    final user1 = await future1;
    final user2 = await future2;

    expect(user1.name, 'John');
    expect(user2.name, 'Jane');
  });
}
Enter fullscreen mode Exit fullscreen mode

Q2: How do you test Streams in Flutter?

Answer:

void main() {
  test('stream emits correct sequence', () {
    final controller = StreamController<int>();
    final stream = controller.stream;

    // expectLater with stream matchers
    expectLater(
      stream,
      emitsInOrder([1, 2, 3, emitsDone]),
    );

    controller.add(1);
    controller.add(2);
    controller.add(3);
    controller.close();
  });

  test('stream emits error', () {
    final controller = StreamController<int>();

    expectLater(
      controller.stream,
      emitsInOrder([
        1,
        emitsError(isA<Exception>()),
        2,
      ]),
    );

    controller.add(1);
    controller.addError(Exception('oops'));
    controller.add(2);
    controller.close();
  });

  test('stream never emits negative', () {
    final controller = StreamController<int>();

    expectLater(controller.stream, neverEmits(isNegative));

    controller.add(1);
    controller.add(2);
    controller.add(3);
    controller.close();
  });

  // Testing StreamTransformers
  test('debounced search stream', () async {
    final searchBloc = SearchBloc();

    expectLater(
      searchBloc.results,
      emitsInOrder([
        [],                    // Initial empty
        ['Flutter', 'Dart'],   // Search results
      ]),
    );

    searchBloc.search('flu');
    await Future.delayed(const Duration(milliseconds: 500));
  });

  // Testing BehaviorSubject / ValueStream
  test('BehaviorSubject replays last value', () async {
    final subject = BehaviorSubject<int>.seeded(0);

    subject.add(1);
    subject.add(2);

    // New listener receives the last value (2), then 3
    expectLater(subject.stream, emitsInOrder([2, 3]));

    subject.add(3);
    await subject.close();
  });
}
Enter fullscreen mode Exit fullscreen mode

Q3: How do you test code with delays and timeouts?

Answer:

import 'package:fake_async/fake_async.dart';

void main() {
  // Using fakeAsync for code with delays
  test('retry logic retries 3 times with delay', () {
    fakeAsync((async) {
      int attempts = 0;
      final service = RetryService(
        maxRetries: 3,
        delay: const Duration(seconds: 2),
        action: () {
          attempts++;
          if (attempts < 3) throw Exception('Fail');
          return 'Success';
        },
      );

      String? result;
      service.execute().then((r) => result = r);

      // First attempt fails immediately
      async.elapse(Duration.zero);
      expect(attempts, 1);
      expect(result, isNull);

      // Wait for first retry delay
      async.elapse(const Duration(seconds: 2));
      expect(attempts, 2);
      expect(result, isNull);

      // Wait for second retry delay
      async.elapse(const Duration(seconds: 2));
      expect(attempts, 3);
      expect(result, 'Success');
    });
  });

  // Set custom timeout for slow tests
  test('slow operation completes within timeout', () async {
    final result = await slowOperation();
    expect(result, isNotNull);
  }, timeout: const Timeout(Duration(seconds: 30)));

  // Testing with real async (when fakeAsync is not suitable)
  test('websocket receives messages', () async {
    final ws = WebSocketService();
    await ws.connect('ws://localhost:8080');

    final messages = <String>[];
    final subscription = ws.messages.listen(messages.add);

    ws.send('ping');

    // Wait a bit for the response
    await Future.delayed(const Duration(milliseconds: 100));

    expect(messages, contains('pong'));

    await subscription.cancel();
    await ws.disconnect();
  });
}
Enter fullscreen mode Exit fullscreen mode

Q4: How do you test code that uses Timer, Future.delayed, or scheduleMicrotask?

Answer:

import 'package:fake_async/fake_async.dart';

class TokenRefresher {
  Timer? _timer;
  int refreshCount = 0;

  void startAutoRefresh(Duration interval) {
    _timer = Timer.periodic(interval, (_) {
      refreshCount++;
    });
  }

  void stop() {
    _timer?.cancel();
  }
}

void main() {
  test('auto-refresh fires at correct intervals', () {
    fakeAsync((async) {
      final refresher = TokenRefresher();
      refresher.startAutoRefresh(const Duration(minutes: 30));

      expect(refresher.refreshCount, 0);

      async.elapse(const Duration(minutes: 30));
      expect(refresher.refreshCount, 1);

      async.elapse(const Duration(minutes: 60));
      expect(refresher.refreshCount, 3);

      refresher.stop();

      async.elapse(const Duration(minutes: 30));
      expect(refresher.refreshCount, 3); // No more refreshes after stop
    });
  });

  // Testing microtasks
  test('microtask execution order', () {
    fakeAsync((async) {
      final order = <String>[];

      Future.microtask(() => order.add('microtask'));
      Future.delayed(Duration.zero, () => order.add('delayed'));
      scheduleMicrotask(() => order.add('scheduled'));

      // Flush microtasks
      async.flushMicrotasks();
      expect(order, ['microtask', 'scheduled']);

      // Flush timers
      async.flushTimers();
      expect(order, ['microtask', 'scheduled', 'delayed']);
    });
  });

  // Testing Zone-specific behavior
  test('error handling in zones', () {
    fakeAsync((async) {
      final errors = <Object>[];

      runZonedGuarded(() {
        Future.delayed(const Duration(seconds: 1), () {
          throw Exception('Delayed error');
        });
      }, (error, stack) {
        errors.add(error);
      });

      async.elapse(const Duration(seconds: 2));
      expect(errors, hasLength(1));
      expect(errors.first, isA<Exception>());
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Q5: How do you test code that uses Isolate.run or compute?

Answer:

// The function under test
Future<List<User>> parseUsersInBackground(String json) {
  return Isolate.run(() => _parseUsers(json));
}

List<User> _parseUsers(String json) {
  final list = jsonDecode(json) as List;
  return list.map((e) => User.fromJson(e)).toList();
}

void main() {
  // Option 1: Test the parsing function directly (skip isolate in tests)
  test('parseUsers correctly parses JSON', () {
    final json = jsonEncode([
      {'id': 1, 'name': 'John'},
      {'id': 2, 'name': 'Jane'},
    ]);

    final users = _parseUsers(json);
    expect(users, hasLength(2));
    expect(users.first.name, 'John');
  });

  // Option 2: Test with the actual isolate (slower but more realistic)
  test('parseUsersInBackground works across isolates', () async {
    final json = jsonEncode([
      {'id': 1, 'name': 'John'},
      {'id': 2, 'name': 'Jane'},
    ]);

    final users = await parseUsersInBackground(json);
    expect(users, hasLength(2));
  });

  // Option 3: Abstract the computation strategy for testability
  test('with injectable compute strategy', () async {
    final service = DataService(
      computeStrategy: (fn, arg) => Future.value(fn(arg)), // Synchronous in tests
    );

    final users = await service.loadUsers(jsonString);
    expect(users, hasLength(2));
  });
}
Enter fullscreen mode Exit fullscreen mode

Best practice: Extract the pure function being run in the isolate and test it directly. Only test the isolate integration in integration tests.


Q6: How do you handle flaky async tests?

Answer:

// 1. Never depend on real timing -- use fakeAsync
// BAD:
test('bad: depends on real timing', () async {
  startOperation();
  await Future.delayed(const Duration(seconds: 2)); // Flaky!
  expect(isComplete, isTrue);
});

// GOOD:
test('good: uses fakeAsync', () {
  fakeAsync((async) {
    startOperation();
    async.elapse(const Duration(seconds: 2));
    expect(isComplete, isTrue);
  });
});

// 2. Use Completers for explicit synchronization
test('explicit sync with Completer', () async {
  final dataLoaded = Completer<void>();
  final service = UserService(
    onLoaded: () => dataLoaded.complete(),
  );

  service.loadData();
  await dataLoaded.future; // Waits until explicitly completed
  expect(service.users, isNotEmpty);
});

// 3. Use expectLater for streams (it waits automatically)
test('stream test', () async {
  await expectLater(
    myStream,
    emitsInOrder([1, 2, 3]),
  ); // Waits for events
});

// 4. Retry flaky tests in CI (last resort)
@Retry(3)
test('network-dependent test', () async {
  // ...
});
Enter fullscreen mode Exit fullscreen mode

Key principles:

  • Never use Future.delayed to "wait for something" in tests
  • Use fakeAsync to control time deterministically
  • Use Completer for explicit synchronization points
  • Use stream matchers (emits, emitsInOrder) that wait automatically
  • If a test is flaky, fix the root cause instead of adding retries

Top comments (1)

Collapse
 
firekey_browser profile image
FireKey Team

Great discussion! One thing worth adding: browser fingerprinting goes much deeper than most devs realize. Canvas/WebGL/AudioContext can create device-level unique IDs that survive clearing cookies and switching IPs.

Even properly configured proxies fail if the environment isn't consistent — timezone, language, and font mismatches between the proxy location and browser config are immediate red flags. Platforms have been layering these checks for years.

The only real fix at scale is hardware-level spoofing per browsing context, not just cookie clearing or VPN switching.