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 withflutter_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}),
);
}
Key points:
- Always check
response.statusCodebefore parsing the body. - The
httppackage supports GET, POST, PUT, PATCH, DELETE, and HEAD methods. - On Android, you must add
<uses-permission android:name="android.permission.INTERNET" />toAndroidManifest.xml. - On macOS, you must add the
com.apple.security.network.cliententitlement. - The
http.Responseobject containsstatusCode,body,headers, andreasonPhrase.
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)}%');
},
);
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),
]);
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');
}
}
Key points:
- One
CancelTokencan be shared across multiple requests to cancel them all at once. - Cancelled requests throw a
DioExceptionwithtype == 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,
},
));
}
}
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}');
}
}
Key points:
-
dio.download()writes directly to a file, which is memory-efficient for large files. -
onReceiveProgressgivesreceivedbytes andtotalbytes (-1 if unknown). -
deleteOnError: truecleans up partial downloads on failure. - You can combine this with
CancelTokento 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
));
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');
}
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'),
);
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);
}
}
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}');
}
}
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());
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
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();
}
Generate code:
dart run build_runner build --delete-conflicting-outputs
# Or watch for changes:
dart run build_runner watch --delete-conflicting-outputs
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
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
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),
);
Why freezed over manual immutable classes:
- Automatic
==,hashCode, andtoString - 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
// 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'));
}
}
}
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);
}
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),
);
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,
);
}
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);
}
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 theisolatespackage
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);
}
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
How it works:
- You annotate classes with
@JsonSerializable()or@freezed - You add
part 'filename.g.dart';and/orpart 'filename.freezed.dart'; -
build_runnerreads annotations and generates the implementation files - 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
Common issues:
- "Conflicting outputs" -- use
--delete-conflicting-outputsflag - Slow builds -- use
watchmode during development - Missing
partdirective -- always include the correctpartstatement - Forgetting to re-run after model changes -- use
watchmode
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(),
),
);
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']),
);
},
);
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'),
);
},
);
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']}');
},
);
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();
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();
}
}
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();
}
}
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);
}
// 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)),
);
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');
}
}
}
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'));
}
}
}
}
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',
],
),
);
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_storageis 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');
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');
});
}
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:
- Only supports primitive types and
List<String> - No encryption -- data is stored in plain text
- Not suitable for large data (loads entire file into memory)
- No query capability -- can only look up by key
- No support for complex data structures
- Asynchronous reads (though the new
SharedPreferencesAsync/SharedPreferencesWithCacheAPIs address this) - No transactional support
- 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);
}
}
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();
});
}
}
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),
);
},
);
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
);
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'));
}
},
);
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();
}
}
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,
});
}
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);
}
}
Important rules:
-
typeIdmust be unique per type and between 0-223 -
@HiveFieldindices must never be changed or reused (for backward compatibility) - Register all adapters before opening boxes that use them
- Extending
HiveObjectgives yousave(),delete(), andkeyproperties
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');
},
);
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),
);
}
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:
- No relational queries -- No JOINs, GROUP BY, or complex SQL. You must manage relationships manually.
-
No query language -- You can only filter using
box.values.where(...), which scans the entire box. - Schema migration challenges -- Changing field indices or types requires manual migration.
- Memory usage -- Boxes are loaded entirely into memory on open. Large datasets may cause issues.
- No full-text search -- Must implement manually or use a different solution.
- Lazy boxes help with memory but are slower since items are read from disk on access.
- 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
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
});
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();
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);
}
}
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;
}
}
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();
}
}
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);
}
}
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
File structure:
lib/
services/
calculator.dart
test/
services/
calculator_test.dart # Must end with _test.dart
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);
});
});
}
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
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)));
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);
});
});
}
Best practices:
- Use
setUpto ensure each test has a clean state (tests should be independent) - Use
setUpAllfor expensive operations that don't affect test isolation - Always close resources (streams, databases, controllers) in
tearDown - Group-level
setUp/tearDownrun 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')});
}
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
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
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));
});
});
}
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);
});
}
Key points:
- Use
testWidgetsinstead oftestfor widget tests - Always wrap widgets in
MaterialApporWidgetsAppfor 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);
});
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));
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();
});
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();
});
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);
});
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);
});
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);
});
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'}"
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);
});
});
}
Run:
flutter test integration_test/app_test.dart
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);
});
}
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');
});
}
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
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');
});
}
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);
});
}
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...
});
}
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>(),
]),
);
});
}
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();
});
}
Generate golden files:
flutter test --update-goldens
Run golden tests (compare against existing):
flutter test
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
alchemistpackage 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
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
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
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
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:
- Business logic and state management (most bugs hide here)
- Data transformation and parsing (JSON, model mapping)
- Error handling paths (network errors, validation)
- Edge cases (empty lists, null values, boundary conditions)
- 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
Textwidget 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
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);
},
);
});
}
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>(),
],
);
}
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);
});
}
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');
});
}
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();
});
}
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();
});
}
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>());
});
});
}
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));
});
}
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 {
// ...
});
Key principles:
- Never use
Future.delayedto "wait for something" in tests - Use
fakeAsyncto control time deterministically - Use
Completerfor 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)
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.