Slide 1

Slide 1 text

UIΧλϩάΞϓϦͰ࣮ݱ͢Δ Visual Regression Testing FlutterKaigi 2021/11/30

Slide 2

Slide 2 text

About Me ҏ౻ ګฏ גࣜձࣾαΠόʔΤʔδΣϯτ Github: KyoheiG3 Twitter: KyoheiG3

Slide 3

Slide 3 text

• ֓ཁ • UI ΧλϩάΞϓϦ • ը໘Ωϟϓνϟ • ςετ ΞδΣϯμ

Slide 4

Slide 4 text

• ֓ཁ • UI ΧλϩάΞϓϦ • ը໘Ωϟϓνϟ • ςετ ΞδΣϯμ

Slide 5

Slide 5 text

• Unit Testing • UI Testing • Snapshot Testing • E2E Testing ςετͱ͸

Slide 6

Slide 6 text

Golden Testing • ༧Ί༻ҙ͓͍ͯͨ͠Ϛελʔը૾ͱͷࠩ෼Λൺֱ͢Δςετ • ࠩ෼͕͋Ε͹ςετࣦഊ • UI ͷมߋ఺Λ໨ࢹͰ֬ೝ͢Δ΋ͷͰ͸ͳ͍

Slide 7

Slide 7 text

Golden Testing void main() { testWidgets('Snapshot for MyApp', (tester) async { const app = MyApp(); // ΩϟϓνϟαΠζΛઃఆ await tester.binding.setSurfaceSize(const Size(414, 896)); await tester.pumpWidget(app); // Ϛελʔεφοϓγϣοτͱͷࠩ෼ൺֱ await expectLater( find.byWidget(app), matchesGoldenFile('MyApp.png'), ); }); }

Slide 8

Slide 8 text

Golden Testing $ flutter test 00:03 +1: All tests passed!

Slide 9

Slide 9 text

Golden Testing mainAxisAlignment: MainAxisAlignment.center, children: [ const Text( 'You have pushed the button this many times:', ), Text( '$_counter', style: Theme.of(context).textTheme.headline4, ), ], ※ϓϩδΣΫτ࡞੒࣌ʹ࡞ΒΕΔίʔυ͔Βൈਮ

Slide 10

Slide 10 text

Golden Testing mainAxisAlignment: MainAxisAlignment.start, children: [ const Text( 'You have pushed the button this many times:', ), Text( '$_counter', style: Theme.of(context).textTheme.headline4, ), ], ※ϓϩδΣΫτ࡞੒࣌ʹ࡞ΒΕΔίʔυ͔Βൈਮ DFOUFSΛTUBSUʹมߋͯ͠ςετͯ͠ΈΔ

Slide 11

Slide 11 text

Golden Testing $ flutter test 00:04 +0: Snapshot for MyApp 00:04 +0 -1: Snapshot for MyApp [E] Test failed. See exception logs above. The test description was: Snapshot for MyApp The test description was: Snapshot for MyApp 00:04 +0 -1: Some tests failed.

Slide 12

Slide 12 text

Golden Testing ςετ࣮ߦը૾ ࠩ෼ը૾ Ϛελʔը૾

Slide 13

Slide 13 text

Golden Testing $ flutter test --update-goldens

Slide 14

Slide 14 text

Visual Regression Testing • ը૾ճؼςετ • ը૾ͷࠩ෼Λݕग़͢Δεφοϓγϣοτςετͷ ͻͱͭ • UI ͕༧ظͤͣมߋ͞Ε͍ͯͳ͍͔Λ͔֬ΊΔͷ ʹඇৗʹ༗༻

Slide 15

Slide 15 text

reg-suit

Slide 16

Slide 16 text

reg-suit Blend Toggle Slide

Slide 17

Slide 17 text

• reg-keygen-git-hash-plugin • reg-notify-github-plugin • reg-notify-slack-plugin • reg-publish-s3-plugin • reg-publish-gcs-plugin reg-suit

Slide 18

Slide 18 text

reg-suit ࠩ෼ͷαϚϦʔΛ௨஌ͯ͘͠ΕΔ "QQSPWF͞ΕΔ·ͰϚʔδͰ͖ͳ͍

Slide 20

Slide 20 text

🤔

Slide 21

Slide 21 text

• ֓ཁ • UI ΧλϩάΞϓϦ • ը໘Ωϟϓνϟ • ςετ ΞδΣϯμ

Slide 22

Slide 22 text

UI ΧλϩάΞϓϦ

Slide 23

Slide 23 text

• playbook-ui (playbook- fl utter) • ݩʑฐࣾͷ OSS ͱͯ͠ଘࡏ͍ͯ͠Δ iOS ൛ͷ΋ͷΛ Flutter Խ UI ΧλϩάΞϓϦ

Slide 24

Slide 24 text

• PlaybookGallery • Playbook • Story • Scenario playbook-ui (playbook- fl utter)

Slide 25

Slide 25 text

playbook-ui (playbook- fl utter) @override Widget build(BuildContext context) { return MaterialApp( title: 'Playbook Demo', theme: ThemeData.light(), home: PlaybookGallery( title: 'Sample App', playbook: Playbook(stories: [ Story('Sample Widget', scenarios: [ Scenario( 'Sample Scenario', child: Center( child: Container( width: 200, height: 250, color: Colors.amber, alignment: Alignment.center, child: const Text('Hello World'), ), ), ), ]), ]), ), ); }

Slide 26

Slide 26 text

playbook-ui (playbook- fl utter) @override Widget build(BuildContext context) { return MaterialApp( title: 'Playbook Demo', theme: ThemeData.light(), home: PlaybookGallery( title: 'Sample App', playbook: Playbook(stories: [ Story('Sample Widget', scenarios: [ Scenario( 'Sample Scenario', child: Center( child: Container( width: 200, height: 250, color: Colors.amber, alignment: Alignment.center, child: const Text('Hello World'), ), ), ), ]), ]), ), ); } Playbook ΠϯελϯεΛड͚औΓΧλϩάΞϓϦશମΛߏங͢Δ

Slide 27

Slide 27 text

playbook-ui (playbook- fl utter) @override Widget build(BuildContext context) { return MaterialApp( title: 'Playbook Demo', theme: ThemeData.light(), home: PlaybookGallery( title: 'Sample App', playbook: Playbook(stories: [ Story('Sample Widget', scenarios: [ Scenario( 'Sample Scenario', child: Center( child: Container( width: 200, height: 250, color: Colors.amber, alignment: Alignment.center, child: const Text('Hello World'), ), ), ), ]), ]), ), ); } ड͚औͬͨ Story ෼ͷϦετ͕ PlaybookGallery ʹදࣔ͞ΕΔ

Slide 28

Slide 28 text

playbook-ui (playbook- fl utter) @override Widget build(BuildContext context) { return MaterialApp( title: 'Playbook Demo', theme: ThemeData.light(), home: PlaybookGallery( title: 'Sample App', playbook: Playbook(stories: [ Story('Sample Widget', scenarios: [ Scenario( 'Sample Scenario', child: Center( child: Container( width: 200, height: 250, color: Colors.amber, alignment: Alignment.center, child: const Text('Hello World'), ), ), ), ]), ]), ), ); } ड͚औͬͨ Scenario ෼ͷϦετ͕ Story ͷ Row ʹදࣔ͞ΕΔ

Slide 29

Slide 29 text

playbook-ui (playbook- fl utter) @override Widget build(BuildContext context) { return MaterialApp( title: 'Playbook Demo', theme: ThemeData.light(), home: PlaybookGallery( title: 'Sample App', playbook: Playbook(stories: [ Story('Sample Widget', scenarios: [ Scenario( 'Sample Scenario', child: Center( child: Container( width: 200, height: 250, color: Colors.amber, alignment: Alignment.center, child: const Text('Hello World'), ), ), ), ]), ]), ), ); } ड͚औͬͨ Widget ͷදࣔʹؔ͢Δ৘ใΛઃఆͰ͖Δ

Slide 30

Slide 30 text

playbook-ui (playbook- fl utter) @override Widget build(BuildContext context) { return MaterialApp( title: 'Playbook Demo', theme: ThemeData.light(), home: PlaybookGallery( title: 'Sample App', playbook: Playbook(stories: [ Story('Sample Widget', scenarios: [ Scenario( 'Sample Scenario', child: Center( child: Container( width: 200, height: 250, color: Colors.amber, alignment: Alignment.center, child: const Text('Hello World'), ), ), ), ]), ]), ), ); } ಠࣗʹ࣮૷ͨ͠ Widget ͳͲΛ౉͢͜ͱͰදࣔ͞ΕΔ

Slide 31

Slide 31 text

No content

Slide 32

Slide 32 text

playbook-ui (playbook- fl utter) name: simple_app dependencies: flutter: sdk: flutter ϓϩμΫτΞϓϦߏ੒ྫ

Slide 33

Slide 33 text

playbook-ui (playbook- fl utter) ΧλϩάΞϓϦΛ௥Ճ

Slide 34

Slide 34 text

playbook-ui (playbook- fl utter) name: simple_catalog_app dependencies: flutter: sdk: flutter simple_app: path: ../simple_app playbook_ui: playbook: UI ΧλϩάΞϓϦߏ੒ྫ ϓϩμΫτͷΞϓϦΛґଘʹ௥Ճ

Slide 35

Slide 35 text

ґଘΛݮΒ͢ํ๏ playbook-ui (playbook- fl utter)

Slide 36

Slide 36 text

playbook-ui (playbook- fl utter) ίϯϙʔωϯτΛ௥Ճ

Slide 37

Slide 37 text

playbook-ui (playbook- fl utter) name: component dependencies: flutter: sdk: flutter UI ίϯϙʔωϯτύοέʔδԽྫ දࣔʹඞཁͳॲཧ͚ͩͷύοέʔδΛ࡞੒

Slide 38

Slide 38 text

playbook-ui (playbook- fl utter) name: simple_app dependencies: flutter: sdk: flutter component: path: '../component' σόΠεઐ༻ͷґଘͳͲ΋ΞϓϦଆʹهड़͠ඞཁʹԠͯ͡ίϯϙʔωϯτʹ DI ࡞੒ͨ͠6*ίϯϙʔωϯτΛ௥Ճ

Slide 39

Slide 39 text

playbook-ui (playbook- fl utter) name: simple_catalog_app dependencies: flutter: sdk: flutter component: path: '../component' playbook_ui: playbook: ಉ͘͡6*ίϯϙʔωϯτΛ௥Ճ දࣔʹඞཁͳίϯϙʔωϯτ͚ͩΛ import ͢Δ͜ͱͰϏϧυ͕࣌ؒ୹ॖ

Slide 40

Slide 40 text

playbook-ui (playbook- fl utter) void main() { runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Playbook Demo', theme: ThemeData.light(), home: PlaybookGallery( title: 'Sample App', playbook: Playbook(stories: [ Story('Sample Widget', scenarios: [ Scenario( 'Sample Scenario', child: ..., ), ]) ]), ), ); } } UI ΧλϩάΞϓϦ͕׬੒

Slide 41

Slide 41 text

• ֓ཁ • UI ΧλϩάΞϓϦ • ը໘Ωϟϓνϟ • ςετ ΞδΣϯμ

Slide 42

Slide 42 text

• ൺֱݩͷίϯϙʔωϯτͷΩϟϓνϟ • ൺֱର৅ͷίϯϙʔωϯτͷΩϟϓνϟ ը໘Ωϟϓνϟ

Slide 43

Slide 43 text

• fl utter_test matchesGoldenFile function • golden_toolkit (ebay) • playbook-snapshot (playbook- fl utter) ը໘Ωϟϓνϟ

Slide 44

Slide 44 text

• playbook-snapshot (playbook- fl utter) • playbook-ui Ͱ࡞ͬͨίϯϙʔωϯτ ͷΩϟϓνϟΛαΫοͱࡱΕΔ ը໘Ωϟϓνϟ

Slide 45

Slide 45 text

• Playbook extension • Snapshot playbook-snapshot (playbook- fl utter)

Slide 46

Slide 46 text

Playbook(stories: [ Story('Sample Widget', scenarios: [ Scenario( 'Sample Scenario', child: Center( child: Container( width: 200, height: 250, color: Colors.amber, alignment: Alignment.center, child: const Text('Hello World'), ), ), ), ]) ]).run( Snapshot( directoryPath: 'screenshots', devices: [SnapshotDevice.iPhone8], ), (widget) { return MaterialApp( debugShowCheckedModeBanner: false, home: Material(child: widget), ); }, ); playbook-snapshot (playbook- fl utter)

Slide 47

Slide 47 text

Playbook(stories: [ Story('Sample Widget', scenarios: [ Scenario( 'Sample Scenario', child: Center( child: Container( width: 200, height: 250, color: Colors.amber, alignment: Alignment.center, child: const Text('Hello World'), ), ), ), ]) ]).run( Snapshot( directoryPath: 'screenshots', devices: [SnapshotDevice.iPhone8], ), (widget) { return MaterialApp( debugShowCheckedModeBanner: false, home: Material(child: widget), ); }, ); playbook-snapshot (playbook- fl utter) 1MBZCPPLʹSVOϝιου͕௥Ճ͞ΕΔ

Slide 48

Slide 48 text

Playbook(stories: [ Story('Sample Widget', scenarios: [ Scenario( 'Sample Scenario', child: Center( child: Container( width: 200, height: 250, color: Colors.amber, alignment: Alignment.center, child: const Text('Hello World'), ), ), ), ]) ]).run( Snapshot( directoryPath: 'screenshots', devices: [SnapshotDevice.iPhone8], ), (widget) { return MaterialApp( debugShowCheckedModeBanner: false, home: Material(child: widget), ); }, ); playbook-snapshot (playbook- fl utter) 4DFOBSJP͕͍࣋ͬͯΔXJEHFU͕౉͞ΕΔ ΩϟϓνϟΛࡱΓ͍ͨ8JEHFU

Slide 49

Slide 49 text

Playbook(stories: [ Story('Sample Widget', scenarios: [ Scenario( 'Sample Scenario', child: Center( child: Container( width: 200, height: 250, color: Colors.amber, alignment: Alignment.center, child: const Text('Hello World'), ), ), ), ]) ]).run( Snapshot( directoryPath: 'screenshots', devices: [SnapshotDevice.iPhone8], ), (widget) { return MaterialApp( debugShowCheckedModeBanner: false, home: Material(child: widget), ); }, ); playbook-snapshot (playbook- fl utter) Snapshot ઃఆྫ

Slide 50

Slide 50 text

Future main() async { await Playbook(stories: [ Story('Sample Widget', scenarios: [ Scenario( 'Sample Scenario', child: Center( child: Container( width: 200, height: 250, color: Colors.amber, alignment: Alignment.center, child: const Text('Hello World'), ), ), ), ]) ]).run( Snapshot( directoryPath: 'screenshots', devices: [SnapshotDevice.iPhone8], ), (widget) { return MaterialApp( debugShowCheckedModeBanner: false, home: Material(child: widget), ); }, ); } playbook-snapshot (playbook- fl utter)

Slide 51

Slide 51 text

Future main() async { await Playbook(stories: [ Story('Sample Widget', scenarios: [ Scenario( 'Sample Scenario', child: Center( child: Container( width: 200, height: 250, color: Colors.amber, alignment: Alignment.center, child: const Text('Hello World'), ), ), ), ]) ]).run( Snapshot( directoryPath: 'screenshots', devices: [SnapshotDevice.iPhone8], ), (widget) { return MaterialApp( debugShowCheckedModeBanner: false, home: Material(child: widget), ); }, ); } playbook-snapshot (playbook- fl utter) ͜ͷ෦෼͸ΞϓϦ΋ςετ΋ڞ௨

Slide 52

Slide 52 text

Playbook playbook() { return Playbook(stories: [ Story('Sample Widget', scenarios: [ Scenario( 'Sample Scenario', child: Center( child: Container( width: 200, height: 250, color: Colors.amber, alignment: Alignment.center, child: const Text('Hello World'), ), ), ), ]) ]); } playbook-snapshot (playbook- fl utter)

Slide 53

Slide 53 text

void main() { runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Playbook Demo', theme: ThemeData.light(), home: PlaybookGallery( title: 'Sample App', playbook: playbook(), ), ); } } playbook-snapshot (playbook- fl utter)

Slide 54

Slide 54 text

Future main() async { await playbook().run( Snapshot( directoryPath: 'screenshots', devices: [SnapshotDevice.iPhone8], ), (widget) { return MaterialApp( debugShowCheckedModeBanner: false, home: Material(child: widget), ); }, ); } playbook-snapshot (playbook- fl utter)

Slide 55

Slide 55 text

• தͰ͸ fl utter_test ͷ matchesGoldenFile Λ࣮ߦ͍ͯ͠Δ • ϑΥϯτ΍ΞΠίϯ͕ಡΈࠐΊͳ͍໰୊ͷαϙʔτ playbook-snapshot (playbook- fl utter) ৄ͘͠͸ 
 https://speakerdeck.com/tomokitakahashi/shi-jian- fl utter-visual-regression-testing

Slide 56

Slide 56 text

name: simple_catalog_app flutter: fonts: - family: Roboto fonts: - asset: fonts/Roboto-Regular.ttf playbook-snapshot (playbook- fl utter) Ωϟϓνϟ࣌ͷϑΥϯτΛ௥Ճ͢Δ

Slide 57

Slide 57 text

• ඇಉظॲཧ • ௨৴σʔλʢը૾ʣ • ࣌ؒ • Ξχϝʔγϣϯ • etc… DI

Slide 58

Slide 58 text

Future main() async { await playbook().run( Snapshot( directoryPath: 'screenshots', devices: [SnapshotDevice.iPhone8], ), (widget) { return ProviderScope( overrides: [], MaterialApp( debugShowCheckedModeBanner: false, home: Material(child: widget), ), ); }, ); } DI ͜ͷ͋ͨΓͰ%*Մೳ

Slide 59

Slide 59 text

• Story ࡞੒ͷ؆ུԽ • Scenario ࡞੒༻ͷΞϊςʔγϣϯ • Playbook ͷΠϯελϯεࣗಈੜ੒ • build_runner Ͱ࣮ߦՄೳ playbook-generator (playbook- fl utter)

Slide 60

Slide 60 text

Playbook playbook() { return Playbook(stories: [ Story('Sample Widget', scenarios: [ Scenario( 'Sample Scenario', child: Center( child: Container( width: 200, height: 250, color: Colors.amber, alignment: Alignment.center, child: const Text('Hello World'), ), ), ), ]) ]); } playbook-generator (playbook- fl utter) Before

Slide 61

Slide 61 text

const storyTitle = 'Sample Widget'; @GenerateScenario(title: 'Sample Scenario') Widget sampleScenario() => Center( child: Container( width: 200, height: 250, color: Colors.amber, alignment: Alignment.center, child: const Text('Hello World'), ), ); playbook-generator (playbook- fl utter) After

Slide 62

Slide 62 text

void main() { runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Playbook Demo', theme: ThemeData.light(), home: PlaybookGallery( title: 'Sample App', playbook: playbook, ), ); } } playbook-generator (playbook- fl utter) ม਺ HFUUFS ͕ࣗಈੜ੒͞Ε͍ͯΔ

Slide 63

Slide 63 text

Future main() async { await playbook.run( Snapshot( directoryPath: 'screenshots', devices: [SnapshotDevice.iPhone8], ), (widget) { return MaterialApp( debugShowCheckedModeBanner: false, home: Material(child: widget), ); }, ); } playbook-generator (playbook- fl utter) ม਺ HFUUFS ͕ࣗಈੜ੒͞Ε͍ͯΔ

Slide 64

Slide 64 text

• ֓ཁ • UI ΧλϩάΞϓϦ • ը໘Ωϟϓνϟ • ςετ ΞδΣϯμ

Slide 65

Slide 65 text

• PR ࣌ʹ VRT ͕࣮ߦ͞ΕΔΑ͏ʹ CI Ͱઃఆ͢Δ • ௨஌ܥͷϓϥάΠϯΛ࢖͑͹ςετ݁ՌΛ௨஌ͯ͘͠ΕΔ • ϨϏϡʔޙʹ Approve ͢Δ·ͰϚʔδͰ͖ͳͨ͘͠ΓͰ͖Δ ςετ

Slide 66

Slide 66 text

{ "dependencies": { "reg-suit": "^0.11.1" }, "devDependencies": { "reg-keygen-git-hash-plugin": "^0.11.1", "reg-notify-github-plugin": "^0.11.1", "reg-publish-gcs-plugin": "^0.11.1" }, "scripts": { "regression": "reg-suit run" } } CI SFHTVJUΛ࣮ߦ͢ΔͨΊͷઃఆΛ௥Ճ

Slide 67

Slide 67 text

version: 2.1 orbs: android: circleci/[email protected] flutter: circleci/[email protected] node: circleci/[email protected] jobs: vrt: executor: android/android steps: - checkout - node/install: install-yarn: true - flutter/install_sdk: - run: name: Install melos command: | dart pub global activate melos melos run pub:get - run: name: Run Golden Testing command: melos run test:snapshot - run: name: Install dependencies command: yarn - run: name: Run VRT command: yarn regression workflows: vrt: jobs: - vrt CI

Slide 68

Slide 68 text

@override Widget build(BuildContext context) { return Container( color: Colors.amberAccent, child: Row( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ Icon(Icons.star), SizedBox(width: 16), Text(text, style: Theme.of(context).textTheme.headline5) ], ), ); } ςετ

Slide 69

Slide 69 text

@override Widget build(BuildContext context) { return Container( color: Colors.amberAccent, child: Row( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [ Icon(Icons.star), SizedBox(width: 16), Text(text, style: Theme.of(context).textTheme.headline5) ], ), ); } ςετ ෆ۩߹͔൑அͮ͠Β͍

Slide 70

Slide 70 text

• ςετࣗମΛॻ࣌ؒ͘͸ͱʹ͔͘ݮΒ͍ͨ͠ • playbook_ui Λར༻ͯ͠ΩϟϓνϟͷࣗಈԽ • ࡞ͬͯյͯ͠Λ΋ͬͱؾܰʹߦ͑ΔΑ͏ʹͳΓ·͢ʂ ςετ

Slide 71

Slide 71 text

• https://github.com/playbook-ui/playbook- fl utter • https://github.com/Dropsource/monarch • https://github.com/widgetbook/widgetbook • https://github.com/ookami-kb/storybook_ fl utter • https://github.com/eBay/ fl utter_glove_box/tree/master/packages/ golden_toolkit ࢀߟ URL

Slide 72

Slide 72 text

Thanks !