ML-166 Golden tests (#167)

* setup golden toolkit

* implemented `GoldenTestApplicationMock`

* added devices with dark theme

* implemented MeteringScreen golden test

* moved platform channel logic to app mock

* implemented SettingsScreen golden test

* gitignore golden tests failures

* Create dart_test.yaml

* adjusted `RulerSlider` ticks height

* set master screenshots

* run golden tests on ci

* fixed `LightSensorService` tests

* removed golden workflow call from PR check

* Update pr_check.yml
This commit is contained in:
Vadim 2024-04-12 08:07:20 +02:00 committed by GitHub
parent 2f8bb983d5
commit 27d56d1061
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 501 additions and 47 deletions

View file

@ -12,9 +12,9 @@ on:
branches: ["main"] branches: ["main"]
jobs: jobs:
analyze_and_test: analyze-and-test:
name: Analyze & test name: Analyze & test
runs-on: macos-11 runs-on: macos-latest
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: 8BitJonny/gh-get-current-pr@2.2.0 - uses: 8BitJonny/gh-get-current-pr@2.2.0
@ -54,7 +54,7 @@ jobs:
run: flutter analyze lib --fatal-infos run: flutter analyze lib --fatal-infos
- name: Run tests - name: Run tests
run: flutter test run: flutter test --dart-define cameraStubImage=assets/camera_stub_image.jpg
- name: Analyze project source with stub - name: Analyze project source with stub
if: steps.override-iap.conclusion != 'success' if: steps.override-iap.conclusion != 'success'

62
.github/workflows/run_golden_tests.yml vendored Normal file
View file

@ -0,0 +1,62 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
name: Run golden tests
on:
workflow_dispatch:
inputs:
update-goldens:
type: boolean
description: Update goldens
default: false
jobs:
run-golden-tests:
name: Run golden tests
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.13.9"
- name: Prepare app
run: |
flutter --version
flutter pub get
flutter pub run intl_utils:generate
- name: Run tests
env:
UPDATE_GOLDENS: ${{inputs.update-goldens && '--update-goldens' || '' }}ƒ
run: |
goldens=$(find ./test -name "*_golden_test.dart" -print)
for f in $goldens; do
flutter test "$f"\
--dart-define cameraStubImage=assets/camera_stub_image.jpg \
$UPDATE_GOLDENS
done
- name: Commit changes
if: ${{ inputs.update-goldens }}
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add -A
git commit -m "Updated goldens"
- name: Push to main
if: ${{ inputs.update-goldens }}
uses: CasperWA/push-protected@v2
with:
token: ${{ secrets.PUSH_TO_MAIN_TOKEN }}
branch: ${{ github.ref_name }}
unprotect_reviews: true

1
.gitignore vendored
View file

@ -62,4 +62,5 @@ ios/Runner/GoogleService-Info.plist
coverage/ coverage/
test/coverage_helper_test.dart test/coverage_helper_test.dart
**/failures/*.png
screenshots/generated/ screenshots/generated/

2
dart_test.yaml Normal file
View file

@ -0,0 +1,2 @@
tags:
golden:

View file

@ -7,7 +7,7 @@ class LightSensorService {
const LightSensorService(this.localPlatform); const LightSensorService(this.localPlatform);
Future<bool> hasSensor() async { Future<bool> hasSensor() async {
if (!localPlatform.isAndroid) { if (localPlatform.isIOS) {
return false; return false;
} }
try { try {
@ -18,7 +18,7 @@ class LightSensorService {
} }
Stream<int> luxStream() { Stream<int> luxStream() {
if (!localPlatform.isAndroid) { if (localPlatform.isIOS) {
return const Stream<int>.empty(); return const Stream<int>.empty();
} }
return LightSensor.luxStream(); return LightSensor.luxStream();

View file

@ -77,43 +77,48 @@ class _Ruler extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final mainTicksFontSize = Theme.of(context).textTheme.bodySmall!.fontSize!;
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final bool showAllMainTicks = Dimens.cameraSliderHandleArea * mainTicksCount <= constraints.maxHeight; final bool showAllMainTicks =
return Column( mainTicksFontSize * mainTicksCount + (1 * mainTicksCount - 1) <= constraints.maxHeight;
crossAxisAlignment: CrossAxisAlignment.end, return Padding(
mainAxisAlignment: MainAxisAlignment.spaceBetween, padding: EdgeInsets.symmetric(vertical: (Dimens.cameraSliderHandleArea - mainTicksFontSize) / 2),
children: List.generate( child: Column(
itemsCount, crossAxisAlignment: CrossAxisAlignment.end,
(index) { mainAxisAlignment: MainAxisAlignment.spaceBetween,
final bool isMainTick = index % 2 == 0.0; children: List.generate(
if (!showAllMainTicks && !isMainTick) { itemsCount,
return const SizedBox(); (index) {
} final bool isMainTick = index % 2 == 0.0;
final bool showValue = (index % (showAllMainTicks ? 2 : 4) == 0.0); if (!showAllMainTicks && !isMainTick) {
return SizedBox( return const SizedBox();
height: index == itemsCount - 1 || showValue ? Dimens.cameraSliderHandleArea : 1, }
child: Row( final bool showValue = (index % (showAllMainTicks ? 2 : 4) == 0.0);
mainAxisAlignment: MainAxisAlignment.end, return SizedBox(
children: [ height: index == itemsCount - 1 || showValue ? mainTicksFontSize : 1,
if (showValue) child: Row(
Text( mainAxisAlignment: MainAxisAlignment.end,
rulerValueAdapter(index / 2 + min), children: [
style: Theme.of(context).textTheme.bodySmall, if (showValue)
Text(
rulerValueAdapter(index / 2 + min),
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(width: Dimens.grid4),
ColoredBox(
color: Theme.of(context).colorScheme.onBackground,
child: SizedBox(
height: 1,
width: isMainTick ? Dimens.grid8 : Dimens.grid4,
),
), ),
const SizedBox(width: Dimens.grid4), ],
ColoredBox( ),
color: Theme.of(context).colorScheme.onBackground, );
child: SizedBox( },
height: 1, ).reversed.toList(),
width: isMainTick ? Dimens.grid8 : Dimens.grid4, ),
),
),
],
),
);
},
).reversed.toList(),
); );
}, },
); );

View file

@ -49,6 +49,7 @@ dev_dependencies:
flutter_native_splash: 2.3.5 flutter_native_splash: 2.3.5
flutter_test: flutter_test:
sdk: flutter sdk: flutter
golden_toolkit: 0.15.0
google_fonts: 3.0.1 google_fonts: 3.0.1
integration_test: integration_test:
sdk: flutter sdk: flutter

View file

@ -1,16 +1,23 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:light_sensor/light_sensor.dart';
import 'package:lightmeter/application_wrapper.dart';
import 'package:lightmeter/data/models/supported_locale.dart';
import 'package:lightmeter/environment.dart';
import 'package:lightmeter/generated/l10n.dart'; import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/providers/user_preferences_provider.dart';
import 'package:lightmeter/res/theme.dart'; import 'package:lightmeter/res/theme.dart';
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
import '../integration_test/mocks/paid_features_mock.dart';
import '../integration_test/utils/platform_channel_mock.dart';
/// Provides [MaterialApp] with default theme and "en" localization /// Provides [MaterialApp] with default theme and "en" localization
class WidgetTestApplicationMock extends StatelessWidget { class WidgetTestApplicationMock extends StatelessWidget {
final IAPProductStatus productStatus;
final Widget child; final Widget child;
const WidgetTestApplicationMock({ const WidgetTestApplicationMock({
this.productStatus = IAPProductStatus.purchased,
required this.child, required this.child,
super.key, super.key,
}); });
@ -35,3 +42,86 @@ class WidgetTestApplicationMock extends StatelessWidget {
); );
} }
} }
class GoldenTestApplicationMock extends StatefulWidget {
final IAPProductStatus productStatus;
final Widget child;
const GoldenTestApplicationMock({
this.productStatus = IAPProductStatus.purchased,
required this.child,
super.key,
});
@override
State<GoldenTestApplicationMock> createState() => _GoldenTestApplicationMockState();
}
class _GoldenTestApplicationMockState extends State<GoldenTestApplicationMock> {
@override
void initState() {
super.initState();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
LightSensor.methodChannel,
(methodCall) async {
switch (methodCall.method) {
case "sensor":
return true;
default:
return null;
}
},
);
setupLightSensorStreamHandler();
}
@override
void dispose() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
LightSensor.methodChannel,
null,
);
super.dispose();
}
@override
Widget build(BuildContext context) {
return IAPProducts(
products: [
IAPProduct(
storeId: IAPProductType.paidFeatures.storeId,
status: widget.productStatus,
),
],
child: ApplicationWrapper(
const Environment.dev(),
child: MockIAPProviders(
equipmentProfiles: mockEquipmentProfiles,
selectedEquipmentProfileId: mockEquipmentProfiles.first.id,
films: films,
child: Builder(
builder: (context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: UserPreferencesProvider.themeOf(context),
locale: Locale(UserPreferencesProvider.localeOf(context).intlName),
localizationsDelegates: const [
S.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: S.delegate.supportedLocales,
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0),
child: child!,
),
home: widget.child,
);
},
),
),
),
);
}
}

View file

@ -44,19 +44,19 @@ void main() {
}); });
test('true - Android', () async { test('true - Android', () async {
when(() => localPlatform.isAndroid).thenReturn(true); when(() => localPlatform.isIOS).thenReturn(false);
setMockSensorAvailability(hasSensor: true); setMockSensorAvailability(hasSensor: true);
expectLater(service.hasSensor(), completion(true)); expectLater(service.hasSensor(), completion(true));
}); });
test('false - Android', () async { test('false - Android', () async {
when(() => localPlatform.isAndroid).thenReturn(true); when(() => localPlatform.isIOS).thenReturn(false);
setMockSensorAvailability(hasSensor: false); setMockSensorAvailability(hasSensor: false);
expectLater(service.hasSensor(), completion(false)); expectLater(service.hasSensor(), completion(false));
}); });
test('false - iOS', () async { test('false - iOS', () async {
when(() => localPlatform.isAndroid).thenReturn(false); when(() => localPlatform.isIOS).thenReturn(true);
expectLater(service.hasSensor(), completion(false)); expectLater(service.hasSensor(), completion(false));
}); });
}, },
@ -64,7 +64,7 @@ void main() {
group('luxStream', () { group('luxStream', () {
test('Android', () async { test('Android', () async {
when(() => localPlatform.isAndroid).thenReturn(true); when(() => localPlatform.isIOS).thenReturn(false);
final stream = service.luxStream(); final stream = service.luxStream();
final List<int> result = []; final List<int> result = [];
final subscription = stream.listen(result.add); final subscription = stream.listen(result.add);
@ -77,7 +77,7 @@ void main() {
}); });
test('iOS', () async { test('iOS', () async {
when(() => localPlatform.isAndroid).thenReturn(false); when(() => localPlatform.isIOS).thenReturn(true);
expect(service.luxStream(), const Stream<int>.empty()); expect(service.luxStream(), const Stream<int>.empty());
}); });
}); });

View file

@ -0,0 +1,47 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:golden_toolkit/golden_toolkit.dart';
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
return GoldenToolkit.runWithConfiguration(
() async {
await loadAppFonts();
await testMain();
},
config: GoldenToolkitConfiguration(
defaultDevices: _defaultDevices +
_defaultDevices
.map(
(d) => Device(
name: '${d.name} (Dark)',
size: d.size,
devicePixelRatio: d.devicePixelRatio,
safeArea: d.safeArea,
brightness: Brightness.dark,
),
)
.toList(),
),
);
}
const _defaultDevices = <Device>[
Device(
name: 'iPhone 8',
size: Size(375, 667),
devicePixelRatio: 2.0,
),
Device(
name: 'iPhone 13 Pro',
size: Size(390, 844),
devicePixelRatio: 3.0,
safeArea: EdgeInsets.only(top: 44, bottom: 34),
),
Device(
name: 'iPhone 15 Pro Max',
size: Size(430, 932),
devicePixelRatio: 3.0,
safeArea: EdgeInsets.only(top: 44, bottom: 34),
),
];

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View file

@ -0,0 +1,144 @@
import 'dart:convert';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:golden_toolkit/golden_toolkit.dart';
import 'package:lightmeter/data/models/ev_source_type.dart';
import 'package:lightmeter/data/models/metering_screen_layout_config.dart';
import 'package:lightmeter/data/models/theme_type.dart';
import 'package:lightmeter/data/shared_prefs_service.dart';
import 'package:lightmeter/providers/user_preferences_provider.dart';
import 'package:lightmeter/screens/metering/components/bottom_controls/components/measure_button/widget_button_measure.dart';
import 'package:lightmeter/screens/metering/flow_metering.dart';
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../integration_test/utils/platform_channel_mock.dart';
import '../../application_mock.dart';
class _MeteringScreenConfig {
final IAPProductStatus iapProductStatus;
final EvSourceType evSourceType;
_MeteringScreenConfig(
this.iapProductStatus,
this.evSourceType,
);
@override
String toString() {
final buffer = StringBuffer();
buffer.write(iapProductStatus.toString().split('.')[1]);
buffer.write(' - ');
buffer.write(evSourceType.toString().split('.')[1]);
return buffer.toString();
}
}
final _testScenarios = [IAPProductStatus.purchased, IAPProductStatus.purchasable].expand(
(iapProductStatus) => EvSourceType.values.map(
(evSourceType) => _MeteringScreenConfig(iapProductStatus, evSourceType),
),
);
void main() {
Future<void> setEvSource(WidgetTester tester, Key scenarioWidgetKey, EvSourceType evSourceType) async {
final flow = find.descendant(
of: find.byKey(scenarioWidgetKey),
matching: find.byType(MeteringFlow),
);
final BuildContext context = tester.element(flow);
if (UserPreferencesProvider.evSourceTypeOf(context) != evSourceType) {
UserPreferencesProvider.of(context).toggleEvSourceType();
}
await tester.pumpAndSettle();
}
Future<void> setTheme(WidgetTester tester, Key scenarioWidgetKey, ThemeType themeType) async {
final flow = find.descendant(
of: find.byKey(scenarioWidgetKey),
matching: find.byType(MeteringFlow),
);
final BuildContext context = tester.element(flow);
UserPreferencesProvider.of(context).setThemeType(themeType);
await tester.pumpAndSettle();
}
Future<void> takePhoto(WidgetTester tester, Key scenarioWidgetKey) async {
final button = find.descendant(
of: find.byKey(scenarioWidgetKey),
matching: find.byType(MeteringMeasureButton),
);
await tester.tap(button);
await tester.pump(const Duration(seconds: 2)); // wait for circular progress indicator
await tester.pump(const Duration(seconds: 1)); // wait for circular progress indicator
await tester.pumpAndSettle();
}
Future<void> toggleIncidentMetering(WidgetTester tester, Key scenarioWidgetKey, double ev) async {
final button = find.descendant(
of: find.byKey(scenarioWidgetKey),
matching: find.byType(MeteringMeasureButton),
);
await tester.tap(button);
await sendMockIncidentEv(ev);
await tester.tap(button);
await tester.pumpAndSettle();
}
setUpAll(() {
SharedPreferences.setMockInitialValues({
UserPreferencesService.evSourceTypeKey: EvSourceType.camera.index,
UserPreferencesService.meteringScreenLayoutKey: json.encode(
{
MeteringScreenLayoutFeature.equipmentProfiles: true,
MeteringScreenLayoutFeature.extremeExposurePairs: true,
MeteringScreenLayoutFeature.filmPicker: true,
}.toJson(),
),
});
});
testGoldens(
'MeteringScreen golden test',
(tester) async {
final builder = DeviceBuilder();
for (final scenario in _testScenarios) {
builder.addScenario(
name: scenario.toString(),
widget: _MockMeteringFlow(productStatus: scenario.iapProductStatus),
onCreate: (scenarioWidgetKey) async {
await setEvSource(tester, scenarioWidgetKey, scenario.evSourceType);
if (scenarioWidgetKey.toString().contains('Dark')) {
await setTheme(tester, scenarioWidgetKey, ThemeType.dark);
}
if (scenario.evSourceType == EvSourceType.camera) {
await takePhoto(tester, scenarioWidgetKey);
} else {
await toggleIncidentMetering(tester, scenarioWidgetKey, 7.3);
}
},
);
}
await tester.pumpDeviceBuilder(builder);
await screenMatchesGolden(
tester,
'metering_screen',
);
},
);
}
class _MockMeteringFlow extends StatelessWidget {
final IAPProductStatus productStatus;
const _MockMeteringFlow({required this.productStatus});
@override
Widget build(BuildContext context) {
return GoldenTestApplicationMock(
productStatus: productStatus,
child: const MeteringFlow(),
);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

View file

@ -0,0 +1,102 @@
import 'dart:convert';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:golden_toolkit/golden_toolkit.dart';
import 'package:lightmeter/data/models/ev_source_type.dart';
import 'package:lightmeter/data/models/metering_screen_layout_config.dart';
import 'package:lightmeter/data/models/theme_type.dart';
import 'package:lightmeter/data/shared_prefs_service.dart';
import 'package:lightmeter/providers/user_preferences_provider.dart';
import 'package:lightmeter/screens/settings/flow_settings.dart';
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../application_mock.dart';
class _SettingsScreenConfig {
final IAPProductStatus iapProductStatus;
_SettingsScreenConfig(this.iapProductStatus);
@override
String toString() {
final buffer = StringBuffer();
buffer.write(iapProductStatus.toString().split('.')[1]);
return buffer.toString();
}
}
final _testScenarios = [IAPProductStatus.purchased, IAPProductStatus.purchasable].map(
(iapProductStatus) => _SettingsScreenConfig(iapProductStatus),
);
void main() {
Future<void> setTheme(WidgetTester tester, Key scenarioWidgetKey, ThemeType themeType) async {
final flow = find.descendant(
of: find.byKey(scenarioWidgetKey),
matching: find.byType(SettingsFlow),
);
final BuildContext context = tester.element(flow);
UserPreferencesProvider.of(context).setThemeType(themeType);
await tester.pumpAndSettle();
}
setUpAll(() {
SharedPreferences.setMockInitialValues({
UserPreferencesService.evSourceTypeKey: EvSourceType.camera.index,
UserPreferencesService.meteringScreenLayoutKey: json.encode(
{
MeteringScreenLayoutFeature.equipmentProfiles: true,
MeteringScreenLayoutFeature.extremeExposurePairs: true,
MeteringScreenLayoutFeature.filmPicker: true,
}.toJson(),
),
});
PackageInfo.setMockInitialValues(
appName: 'Lightmeter',
packageName: 'com.vodemn.lightmeter',
version: '0.18.0',
buildNumber: '48',
buildSignature: '',
);
});
testGoldens(
'SettingsScreen golden test',
(tester) async {
final builder = DeviceBuilder();
for (final scenario in _testScenarios) {
builder.addScenario(
name: scenario.toString(),
widget: _MockSettingsFlow(productStatus: scenario.iapProductStatus),
onCreate: (scenarioWidgetKey) async {
if (scenarioWidgetKey.toString().contains('Dark')) {
await setTheme(tester, scenarioWidgetKey, ThemeType.dark);
}
},
);
}
await tester.pumpDeviceBuilder(builder);
await screenMatchesGolden(
tester,
'settings_screen',
);
},
);
}
class _MockSettingsFlow extends StatelessWidget {
final IAPProductStatus productStatus;
const _MockSettingsFlow({required this.productStatus});
@override
Widget build(BuildContext context) {
return GoldenTestApplicationMock(
productStatus: productStatus,
child: const SettingsFlow(),
);
}
}