Skip to content

Commit

Permalink
Feature/end-to-end-tests
Browse files Browse the repository at this point in the history
* Add end-to-end tests placeholder

* Lower flutter version
  • Loading branch information
PlugFox committed Jan 17, 2024
1 parent 533f80a commit 1283cf2
Show file tree
Hide file tree
Showing 8 changed files with 358 additions and 2 deletions.
11 changes: 11 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@
"--dart-define=octopus.measure=false"
],
"env": {}
},
{
"name": "Integration tests (Debug)",
"type": "dart",
"program": "${workspaceFolder}/example/integration_test/app_test.dart",
"request": "launch",
"cwd": "${workspaceFolder}/example",
"args": [
"--dart-define-from-file=config/development.json",
],
"env": {}
}
]
}
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## 0.0.5

- Lower the minimum version of `flutter` to `3.13.9`
- Add end-to-end tests

## 0.0.4

- Avoid duplicates in the history of navigator reports
Expand Down
77 changes: 77 additions & 0 deletions example/integration_test/app_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import 'package:example/src/common/widget/app.dart';
import 'package:example/src/feature/initialization/widget/inherited_dependencies.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

import 'src/fake/fake_dependencies.dart';
import 'src/util/tester_extension.dart';

void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('end-to-end', () {
late final Widget app;

setUpAll(() async {
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
final dependencies = await $initializeFakeDependencies();
app = InheritedDependencies(
dependencies: dependencies,
child: const App(),
);
});

testWidgets('app', (tester) async {
await tester.pumpWidget(app);
await tester.pumpAndSettle();
expect(find.byType(InheritedDependencies), findsOneWidget);
expect(find.byType(App), findsOneWidget);
expect(find.byType(MaterialApp), findsOneWidget);
});

testWidgets('sign-in', (tester) async {
await tester.pumpWidget(app);
await tester.pumpAndSettle();
expect(find.text('Sign-In'), findsAtLeastNWidgets(1));
await tester.tap(find.descendant(
of: find.byType(InkWell),
matching: find.text('Sign-Up'),
));
await tester.pumpAndPause();
await tester.tap(find.descendant(
of: find.byType(InkWell),
matching: find.text('Cancel'),
));
await tester.pumpAndPause();
await tester.enterText(
find.ancestor(
of: find.text('Username'),
matching: find.byType(TextField),
),
'app-test@gmail.com');
await tester.enterText(
find.ancestor(
of: find.text('Password'),
matching: find.byType(TextField),
),
'Password123');
await tester.tap(find.ancestor(
of: find.byIcon(Icons.visibility),
matching: find.byType(IconButton),
));
await tester.pumpAndPause();
await tester.tap(find.ancestor(
of: find.byIcon(Icons.visibility_off),
matching: find.byType(IconButton),
));
await tester.pumpAndPause();
await tester.tap(find.descendant(
of: find.byType(InkWell),
matching: find.text('Sign-In'),
));
await tester.pumpAndPause(const Duration(seconds: 1));
expect(find.text('Sign-In'), findsNothing);
expect(find.text('Home'), findsAtLeastNWidgets(1));
});
});
}
48 changes: 48 additions & 0 deletions example/integration_test/src/fake/fake_authentication.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import 'dart:async';

import 'package:example/src/feature/authentication/data/authentication_repository.dart';
import 'package:example/src/feature/authentication/model/sign_in_data.dart';
import 'package:example/src/feature/authentication/model/user.dart';

class FakeIAuthenticationRepositoryImpl implements IAuthenticationRepository {
FakeIAuthenticationRepositoryImpl();

static const String _sessionKey = 'authentication.session';
final Map<String, Object?> _sharedPreferences = <String, Object?>{};
final StreamController<User> _userController =
StreamController<User>.broadcast();
User _user = const User.unauthenticated();

@override
FutureOr<User> getUser() => _user;

@override
Stream<User> userChanges() => _userController.stream;

@override
Future<void> signIn(SignInData data) async {
final user = User.authenticated(id: data.username);
_sharedPreferences[_sessionKey] = user.toJson();
_userController.add(_user = user);
}

@override
Future<void> restore() async {
final session = _sharedPreferences[_sessionKey];
if (session == null) return;
final json = session;
if (json case Map<String, Object?> jsonMap) {
final user = User.fromJson(jsonMap);
_userController.add(_user = user);
}
}

@override
Future<void> signOut() => Future<void>.sync(
() {
const user = User.unauthenticated();
_sharedPreferences.remove(_sessionKey);
_userController.add(_user = user);
},
);
}
50 changes: 50 additions & 0 deletions example/integration_test/src/fake/fake_dependencies.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import 'package:example/src/common/model/dependencies.dart';
import 'package:example/src/feature/authentication/controller/authentication_controller.dart';
import 'package:example/src/feature/shop/controller/favorite_controller.dart';
import 'package:example/src/feature/shop/controller/shop_controller.dart';
import 'package:flutter/widgets.dart';
import 'package:shared_preferences/shared_preferences.dart';

import 'fake_authentication.dart';
import 'fake_product.dart';

Future<FakeDependencies> $initializeFakeDependencies() async {
SharedPreferences.setMockInitialValues(<String, String>{});
final fakeProductRepository = FakeProductRepository();
final dependencies = FakeDependencies()
..sharedPreferences = await SharedPreferences.getInstance()
..authenticationController = AuthenticationController(
repository: FakeIAuthenticationRepositoryImpl(),
)
..shopController = ShopController(
repository: fakeProductRepository,
)
..favoriteController = FavoriteController(
repository: fakeProductRepository,
);
return dependencies;
}

/// Fake Dependencies
class FakeDependencies implements Dependencies {
FakeDependencies();

/// The state from the closest instance of this class.
static Dependencies of(BuildContext context) => Dependencies.of(context);

/// Shared preferences
@override
late final SharedPreferences sharedPreferences;

/// Authentication controller
@override
late final AuthenticationController authenticationController;

/// Shop controller
@override
late final ShopController shopController;

/// Favorite controller
@override
late final FavoriteController favoriteController;
}
77 changes: 77 additions & 0 deletions example/integration_test/src/fake/fake_product.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import 'dart:convert';

import 'package:example/src/common/constant/assets.gen.dart' as assets;
import 'package:example/src/feature/shop/data/product_repository.dart';
import 'package:example/src/feature/shop/model/category.dart';
import 'package:example/src/feature/shop/model/product.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';

class FakeProductRepository implements IProductRepository {
FakeProductRepository();

static const String _favoriteProductsKey = 'shop.products.favorite';

final Map<String, Object?> _sharedPreferences = <String, Object?>{};

Set<ProductID>? _favoritesCache;

@override
Stream<CategoryEntity> fetchCategories() async* {
final json = await rootBundle.loadString(assets.Assets.data.categories);
final categories = await compute<String, List<Map<String, Object?>>>(
_extractCollection, json);
for (final category in categories) {
yield CategoryEntity.fromJson(category);
}
}

@override
Stream<ProductEntity> fetchProducts() async* {
final json = await rootBundle.loadString(assets.Assets.data.products);
final products = await compute<String, List<Map<String, Object?>>>(
_extractCollection, json);
for (final product in products) {
yield ProductEntity.fromJson(product);
}
}

@override
Future<Set<ProductID>> fetchFavoriteProducts() async {
if (_favoritesCache case Set<ProductID> cache)
return Set<ProductID>.of(cache);
final set = _sharedPreferences[_favoriteProductsKey];
if (set is! Iterable<String>) return <ProductID>{};
return Set<ProductID>.of(_favoritesCache =
set.map<int?>(int.tryParse).whereType<ProductID>().toSet());
}

@override
Future<void> addFavoriteProduct(ProductID id) async {
final set = await fetchFavoriteProducts();
if (!set.add(id)) return;
_favoritesCache = set;
_sharedPreferences[_favoriteProductsKey] = <String>[
...set.map<String>((e) => e.toString()),
id.toString(),
];
}

@override
Future<void> removeFavoriteProduct(ProductID id) async {
final set = await fetchFavoriteProducts();
if (!set.remove(id)) return;
_favoritesCache = set;
_sharedPreferences[_favoriteProductsKey] = <String>[
for (final e in set) e.toString(),
];
}

static List<Map<String, Object?>> _extractCollection(String json) =>
(jsonDecode(json) as Map<String, Object?>)
.values
.whereType<Iterable<Object?>>()
.reduce((v, e) => <Object?>[...v, ...e])
.whereType<Map<String, Object?>>()
.toList(growable: false);
}
88 changes: 88 additions & 0 deletions example/integration_test/src/util/tester_extension.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import 'dart:async';
import 'dart:io' as io;
import 'dart:typed_data' as td;
import 'dart:ui' as ui;

import 'package:flutter/rendering.dart' show RenderRepaintBoundary;
import 'package:flutter/widgets.dart' show WidgetsApp;
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

extension WidgetTesterExtension on WidgetTester {
/// Sleep for `duration`.
Future<void> sleep([Duration duration = const Duration(milliseconds: 500)]) =>
Future<void>.delayed(duration);

/// Pump the widget tree, wait and then pumps a frame again.
Future<void> pumpAndPause([
Duration duration = const Duration(milliseconds: 500),
]) async {
await pump();
await sleep(duration);
await pump();
}

/// Try to pump and find some widget few times.
Future<Finder> asyncFinder({
required Finder Function() finder,
Duration limit = const Duration(milliseconds: 15000),
}) async {
final stopwatch = Stopwatch()..start();
var result = finder();
try {
while (stopwatch.elapsed <= limit) {
await pumpAndSettle(const Duration(milliseconds: 100))
.timeout(limit - stopwatch.elapsed);
result = finder();
if (result.evaluate().isNotEmpty) return result;
}
return result;
} on TimeoutException {
return result;
} on Object {
rethrow;
} finally {
stopwatch.stop();
}
}

/// Returns a function that takes a screenshot of the current state of the app.
Future<List<int>> Function([String? name]) screenshot({
/// The [_$pixelRatio] describes the scale between the logical pixels and the
/// size of the output image. It is independent of the
/// [dart:ui.FlutterView.devicePixelRatio] for the device, so specifying 1.0
/// (the default) will give you a 1:1 mapping between logical pixels and the
/// output pixels in the image.
double pixelRatio = 1,

/// If provided, the screenshot will be get
/// with standard [IntegrationTestWidgetsFlutterBinding.takeScreenshot] on
/// Android and iOS devices.
IntegrationTestWidgetsFlutterBinding? binding,
}) =>
([name]) async {
await pump();
if (binding != null &&
name != null &&
(io.Platform.isAndroid || io.Platform.isIOS)) {
if (io.Platform.isAndroid)
await binding.convertFlutterSurfaceToImage();
return await binding.takeScreenshot(name);
} else {
final element = firstElement(find.byType(WidgetsApp));
RenderRepaintBoundary? boundary;
element.visitAncestorElements((element) {
final renderObject = element.renderObject;
if (renderObject is RenderRepaintBoundary) boundary = renderObject;
return true;
});
if (boundary == null)
throw StateError('No RenderRepaintBoundary found');
final image = await boundary!.toImage(pixelRatio: pixelRatio);
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
if (bytes is! td.ByteData)
throw StateError('Error converting image to bytes');
return bytes.buffer.asUint8List();
}
};
}
4 changes: 2 additions & 2 deletions pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: octopus
description: "A cross-platform declarative router for Flutter with a focus on state and nested navigation. Made with ❤️ by PlugFox."

version: 0.0.4
version: 0.0.5

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

Expand Down Expand Up @@ -35,7 +35,7 @@ platforms:

environment:
sdk: '>=3.2.0 <4.0.0'
flutter: ">=3.16.0"
flutter: ">=3.13.9"

dependencies:
flutter:
Expand Down

0 comments on commit 1283cf2

Please sign in to comment.