Skip to content

Commit 1283cf2

Browse files
authored
Feature/end-to-end-tests
* Add end-to-end tests placeholder * Lower flutter version
1 parent 533f80a commit 1283cf2

File tree

8 files changed

+358
-2
lines changed

8 files changed

+358
-2
lines changed

.vscode/launch.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@
2525
"--dart-define=octopus.measure=false"
2626
],
2727
"env": {}
28+
},
29+
{
30+
"name": "Integration tests (Debug)",
31+
"type": "dart",
32+
"program": "${workspaceFolder}/example/integration_test/app_test.dart",
33+
"request": "launch",
34+
"cwd": "${workspaceFolder}/example",
35+
"args": [
36+
"--dart-define-from-file=config/development.json",
37+
],
38+
"env": {}
2839
}
2940
]
3041
}

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## 0.0.5
2+
3+
- Lower the minimum version of `flutter` to `3.13.9`
4+
- Add end-to-end tests
5+
16
## 0.0.4
27

38
- Avoid duplicates in the history of navigator reports
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import 'package:example/src/common/widget/app.dart';
2+
import 'package:example/src/feature/initialization/widget/inherited_dependencies.dart';
3+
import 'package:flutter/material.dart';
4+
import 'package:flutter_test/flutter_test.dart';
5+
import 'package:integration_test/integration_test.dart';
6+
7+
import 'src/fake/fake_dependencies.dart';
8+
import 'src/util/tester_extension.dart';
9+
10+
void main() {
11+
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
12+
group('end-to-end', () {
13+
late final Widget app;
14+
15+
setUpAll(() async {
16+
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
17+
final dependencies = await $initializeFakeDependencies();
18+
app = InheritedDependencies(
19+
dependencies: dependencies,
20+
child: const App(),
21+
);
22+
});
23+
24+
testWidgets('app', (tester) async {
25+
await tester.pumpWidget(app);
26+
await tester.pumpAndSettle();
27+
expect(find.byType(InheritedDependencies), findsOneWidget);
28+
expect(find.byType(App), findsOneWidget);
29+
expect(find.byType(MaterialApp), findsOneWidget);
30+
});
31+
32+
testWidgets('sign-in', (tester) async {
33+
await tester.pumpWidget(app);
34+
await tester.pumpAndSettle();
35+
expect(find.text('Sign-In'), findsAtLeastNWidgets(1));
36+
await tester.tap(find.descendant(
37+
of: find.byType(InkWell),
38+
matching: find.text('Sign-Up'),
39+
));
40+
await tester.pumpAndPause();
41+
await tester.tap(find.descendant(
42+
of: find.byType(InkWell),
43+
matching: find.text('Cancel'),
44+
));
45+
await tester.pumpAndPause();
46+
await tester.enterText(
47+
find.ancestor(
48+
of: find.text('Username'),
49+
matching: find.byType(TextField),
50+
),
51+
'app-test@gmail.com');
52+
await tester.enterText(
53+
find.ancestor(
54+
of: find.text('Password'),
55+
matching: find.byType(TextField),
56+
),
57+
'Password123');
58+
await tester.tap(find.ancestor(
59+
of: find.byIcon(Icons.visibility),
60+
matching: find.byType(IconButton),
61+
));
62+
await tester.pumpAndPause();
63+
await tester.tap(find.ancestor(
64+
of: find.byIcon(Icons.visibility_off),
65+
matching: find.byType(IconButton),
66+
));
67+
await tester.pumpAndPause();
68+
await tester.tap(find.descendant(
69+
of: find.byType(InkWell),
70+
matching: find.text('Sign-In'),
71+
));
72+
await tester.pumpAndPause(const Duration(seconds: 1));
73+
expect(find.text('Sign-In'), findsNothing);
74+
expect(find.text('Home'), findsAtLeastNWidgets(1));
75+
});
76+
});
77+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import 'dart:async';
2+
3+
import 'package:example/src/feature/authentication/data/authentication_repository.dart';
4+
import 'package:example/src/feature/authentication/model/sign_in_data.dart';
5+
import 'package:example/src/feature/authentication/model/user.dart';
6+
7+
class FakeIAuthenticationRepositoryImpl implements IAuthenticationRepository {
8+
FakeIAuthenticationRepositoryImpl();
9+
10+
static const String _sessionKey = 'authentication.session';
11+
final Map<String, Object?> _sharedPreferences = <String, Object?>{};
12+
final StreamController<User> _userController =
13+
StreamController<User>.broadcast();
14+
User _user = const User.unauthenticated();
15+
16+
@override
17+
FutureOr<User> getUser() => _user;
18+
19+
@override
20+
Stream<User> userChanges() => _userController.stream;
21+
22+
@override
23+
Future<void> signIn(SignInData data) async {
24+
final user = User.authenticated(id: data.username);
25+
_sharedPreferences[_sessionKey] = user.toJson();
26+
_userController.add(_user = user);
27+
}
28+
29+
@override
30+
Future<void> restore() async {
31+
final session = _sharedPreferences[_sessionKey];
32+
if (session == null) return;
33+
final json = session;
34+
if (json case Map<String, Object?> jsonMap) {
35+
final user = User.fromJson(jsonMap);
36+
_userController.add(_user = user);
37+
}
38+
}
39+
40+
@override
41+
Future<void> signOut() => Future<void>.sync(
42+
() {
43+
const user = User.unauthenticated();
44+
_sharedPreferences.remove(_sessionKey);
45+
_userController.add(_user = user);
46+
},
47+
);
48+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import 'package:example/src/common/model/dependencies.dart';
2+
import 'package:example/src/feature/authentication/controller/authentication_controller.dart';
3+
import 'package:example/src/feature/shop/controller/favorite_controller.dart';
4+
import 'package:example/src/feature/shop/controller/shop_controller.dart';
5+
import 'package:flutter/widgets.dart';
6+
import 'package:shared_preferences/shared_preferences.dart';
7+
8+
import 'fake_authentication.dart';
9+
import 'fake_product.dart';
10+
11+
Future<FakeDependencies> $initializeFakeDependencies() async {
12+
SharedPreferences.setMockInitialValues(<String, String>{});
13+
final fakeProductRepository = FakeProductRepository();
14+
final dependencies = FakeDependencies()
15+
..sharedPreferences = await SharedPreferences.getInstance()
16+
..authenticationController = AuthenticationController(
17+
repository: FakeIAuthenticationRepositoryImpl(),
18+
)
19+
..shopController = ShopController(
20+
repository: fakeProductRepository,
21+
)
22+
..favoriteController = FavoriteController(
23+
repository: fakeProductRepository,
24+
);
25+
return dependencies;
26+
}
27+
28+
/// Fake Dependencies
29+
class FakeDependencies implements Dependencies {
30+
FakeDependencies();
31+
32+
/// The state from the closest instance of this class.
33+
static Dependencies of(BuildContext context) => Dependencies.of(context);
34+
35+
/// Shared preferences
36+
@override
37+
late final SharedPreferences sharedPreferences;
38+
39+
/// Authentication controller
40+
@override
41+
late final AuthenticationController authenticationController;
42+
43+
/// Shop controller
44+
@override
45+
late final ShopController shopController;
46+
47+
/// Favorite controller
48+
@override
49+
late final FavoriteController favoriteController;
50+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import 'dart:convert';
2+
3+
import 'package:example/src/common/constant/assets.gen.dart' as assets;
4+
import 'package:example/src/feature/shop/data/product_repository.dart';
5+
import 'package:example/src/feature/shop/model/category.dart';
6+
import 'package:example/src/feature/shop/model/product.dart';
7+
import 'package:flutter/foundation.dart';
8+
import 'package:flutter/services.dart';
9+
10+
class FakeProductRepository implements IProductRepository {
11+
FakeProductRepository();
12+
13+
static const String _favoriteProductsKey = 'shop.products.favorite';
14+
15+
final Map<String, Object?> _sharedPreferences = <String, Object?>{};
16+
17+
Set<ProductID>? _favoritesCache;
18+
19+
@override
20+
Stream<CategoryEntity> fetchCategories() async* {
21+
final json = await rootBundle.loadString(assets.Assets.data.categories);
22+
final categories = await compute<String, List<Map<String, Object?>>>(
23+
_extractCollection, json);
24+
for (final category in categories) {
25+
yield CategoryEntity.fromJson(category);
26+
}
27+
}
28+
29+
@override
30+
Stream<ProductEntity> fetchProducts() async* {
31+
final json = await rootBundle.loadString(assets.Assets.data.products);
32+
final products = await compute<String, List<Map<String, Object?>>>(
33+
_extractCollection, json);
34+
for (final product in products) {
35+
yield ProductEntity.fromJson(product);
36+
}
37+
}
38+
39+
@override
40+
Future<Set<ProductID>> fetchFavoriteProducts() async {
41+
if (_favoritesCache case Set<ProductID> cache)
42+
return Set<ProductID>.of(cache);
43+
final set = _sharedPreferences[_favoriteProductsKey];
44+
if (set is! Iterable<String>) return <ProductID>{};
45+
return Set<ProductID>.of(_favoritesCache =
46+
set.map<int?>(int.tryParse).whereType<ProductID>().toSet());
47+
}
48+
49+
@override
50+
Future<void> addFavoriteProduct(ProductID id) async {
51+
final set = await fetchFavoriteProducts();
52+
if (!set.add(id)) return;
53+
_favoritesCache = set;
54+
_sharedPreferences[_favoriteProductsKey] = <String>[
55+
...set.map<String>((e) => e.toString()),
56+
id.toString(),
57+
];
58+
}
59+
60+
@override
61+
Future<void> removeFavoriteProduct(ProductID id) async {
62+
final set = await fetchFavoriteProducts();
63+
if (!set.remove(id)) return;
64+
_favoritesCache = set;
65+
_sharedPreferences[_favoriteProductsKey] = <String>[
66+
for (final e in set) e.toString(),
67+
];
68+
}
69+
70+
static List<Map<String, Object?>> _extractCollection(String json) =>
71+
(jsonDecode(json) as Map<String, Object?>)
72+
.values
73+
.whereType<Iterable<Object?>>()
74+
.reduce((v, e) => <Object?>[...v, ...e])
75+
.whereType<Map<String, Object?>>()
76+
.toList(growable: false);
77+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import 'dart:async';
2+
import 'dart:io' as io;
3+
import 'dart:typed_data' as td;
4+
import 'dart:ui' as ui;
5+
6+
import 'package:flutter/rendering.dart' show RenderRepaintBoundary;
7+
import 'package:flutter/widgets.dart' show WidgetsApp;
8+
import 'package:flutter_test/flutter_test.dart';
9+
import 'package:integration_test/integration_test.dart';
10+
11+
extension WidgetTesterExtension on WidgetTester {
12+
/// Sleep for `duration`.
13+
Future<void> sleep([Duration duration = const Duration(milliseconds: 500)]) =>
14+
Future<void>.delayed(duration);
15+
16+
/// Pump the widget tree, wait and then pumps a frame again.
17+
Future<void> pumpAndPause([
18+
Duration duration = const Duration(milliseconds: 500),
19+
]) async {
20+
await pump();
21+
await sleep(duration);
22+
await pump();
23+
}
24+
25+
/// Try to pump and find some widget few times.
26+
Future<Finder> asyncFinder({
27+
required Finder Function() finder,
28+
Duration limit = const Duration(milliseconds: 15000),
29+
}) async {
30+
final stopwatch = Stopwatch()..start();
31+
var result = finder();
32+
try {
33+
while (stopwatch.elapsed <= limit) {
34+
await pumpAndSettle(const Duration(milliseconds: 100))
35+
.timeout(limit - stopwatch.elapsed);
36+
result = finder();
37+
if (result.evaluate().isNotEmpty) return result;
38+
}
39+
return result;
40+
} on TimeoutException {
41+
return result;
42+
} on Object {
43+
rethrow;
44+
} finally {
45+
stopwatch.stop();
46+
}
47+
}
48+
49+
/// Returns a function that takes a screenshot of the current state of the app.
50+
Future<List<int>> Function([String? name]) screenshot({
51+
/// The [_$pixelRatio] describes the scale between the logical pixels and the
52+
/// size of the output image. It is independent of the
53+
/// [dart:ui.FlutterView.devicePixelRatio] for the device, so specifying 1.0
54+
/// (the default) will give you a 1:1 mapping between logical pixels and the
55+
/// output pixels in the image.
56+
double pixelRatio = 1,
57+
58+
/// If provided, the screenshot will be get
59+
/// with standard [IntegrationTestWidgetsFlutterBinding.takeScreenshot] on
60+
/// Android and iOS devices.
61+
IntegrationTestWidgetsFlutterBinding? binding,
62+
}) =>
63+
([name]) async {
64+
await pump();
65+
if (binding != null &&
66+
name != null &&
67+
(io.Platform.isAndroid || io.Platform.isIOS)) {
68+
if (io.Platform.isAndroid)
69+
await binding.convertFlutterSurfaceToImage();
70+
return await binding.takeScreenshot(name);
71+
} else {
72+
final element = firstElement(find.byType(WidgetsApp));
73+
RenderRepaintBoundary? boundary;
74+
element.visitAncestorElements((element) {
75+
final renderObject = element.renderObject;
76+
if (renderObject is RenderRepaintBoundary) boundary = renderObject;
77+
return true;
78+
});
79+
if (boundary == null)
80+
throw StateError('No RenderRepaintBoundary found');
81+
final image = await boundary!.toImage(pixelRatio: pixelRatio);
82+
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
83+
if (bytes is! td.ByteData)
84+
throw StateError('Error converting image to bytes');
85+
return bytes.buffer.asUint8List();
86+
}
87+
};
88+
}

pubspec.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: octopus
22
description: "A cross-platform declarative router for Flutter with a focus on state and nested navigation. Made with ❤️ by PlugFox."
33

4-
version: 0.0.4
4+
version: 0.0.5
55

66
homepage: https://github.com/PlugFox/octopus
77

@@ -35,7 +35,7 @@ platforms:
3535

3636
environment:
3737
sdk: '>=3.2.0 <4.0.0'
38-
flutter: ">=3.16.0"
38+
flutter: ">=3.13.9"
3939

4040
dependencies:
4141
flutter:

0 commit comments

Comments
 (0)