# BLoC Pattern กับการทำ Testing
#Dev#Flutter#TestingBLoC (Business Logic Component) เป็นรูปแบบหนึ่งในการจัดการส่วนของ Business Logic ของแอปที่เขียนบน Flutter ซึ่งทาง Google เริ่มแนะนำมาตั้งแต่ปี 2018
โดยรูปแบบนี้ง่ายต่อการจัดการโค้ด และสามารถทำให้เราทดสอบแอปหรือ ทำ Unit Testing ได้ง่ายขึ้น
# ลองสร้างโปรเจคกันก่อน
- รันคำสั่งใน Command line เพื่อสร้างโปรเจค Flutter
flutter create bloc_provider # สร้างโปรเจคชื่อ bloc_provider
- รันคำสั่ง Command line เพื่อรันแอปบน Emulator (ต้องเปิด Emulator ก่อนนะ) หรือถ้าเขียนโดยใช้ Visual Studio Code ก็สามารถกด F5 แทนได้
flutter run
และนี่ก็คือหน้าจอเริ่มต้นที่ Flutter สร้างมาให้ เป็นโปรแกรมที่มีปุ่ม + เมื่อกดแล้วก็จะเพิ่มจำนวนนับบนหน้าจอ 😁
โดยโค้ดโปรแกรมของเราจะอยู่ใน lib/main.dart
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.display1,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
),
);
}
}
และโค้ดในส่วนของการทำ Testing จะอยู่ใน test/widget_test.dart
ซึ่งเป็นการจำลองการทำงานจริงของโปรแกรม เช่น การกดปุ่ม การพิพม์ สิ่งต่างๆ
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:bloc_provider/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// ทำการสร้าง MyApp แล้วใส่เข้าไปในตัวรันแอป
await tester.pumpWidget(MyApp());
// เป็นการหาว่ามี Text ที่แสดงค่า 0 อยู่ในแอปรึเปล่า
expect(find.text('0'), findsOneWidget);
// เป็นการหาว่าไม่มี Text ที่แสดงค่า 1 อยู่ในแอป
expect(find.text('1'), findsNothing);
// สั่งให้กดปุ่มที่มีไอค่อนรูป '+'
await tester.tap(find.byIcon(Icons.add));
// สั่งให้แอปรอการทำงาน
await tester.pump();
// เป็นการหาว่ามี Text ที่แสดงค่า 1 อยู่ในแอปรึเปล่า
expect(find.text('0'), findsNothing);
// เป็นการหาว่าไม่มี Text ที่แสดงค่า 0 อยู่ในแอป
expect(find.text('1'), findsOneWidget);
});
}
ถ้าใครคุ้นชินกับพวก Javascript, jQuery ก็จะมีแนวการทำงานคล้ายๆกัน
ทีนี้ถ้าผมอยากจะเขียน Unit Testing สำหรับแอปนี้ล่ะ?
ในที่นี้ผมจะลองสร้างไฟล์ test/unit_test.dart
ขึ้นมา
import 'package:flutter_test/flutter_test.dart';
import 'package:bloc_provider/main.dart';
void main() {
// เราสามารถสร้างกลุ่มของการ Test ครบแต่ละเรื่องที่มีเรื่องใกล้เคียงกันได้
group('My Awesome unit testing', () {
// test จะใช้กับ Unit Testing
test('App start with value 0', () {
// สร้าง Widget MyHomePage
var myHomePage = MyHomePage(title: 'Test');
// สร้าง State ของ MyHomePage
var state = myHomePage.createState();
expect(state._counter, 0);
});
});
}
ลองกด F5 เพื่อรัน Test ก็จะพบว่าพัง ซึ่งไม่ต้องรัน VSCode ก็บอกแล้วล่ะว่าพัง เนื่องจากหา state._counter ไม่เจอ (การเขียน '_' หรือ Underscore นำหน้าชื่อตัวแปรเป็นการกำหนด private ในภาษา Dart ทำให้ไม่สามารถเข้าถึงจาก class ภายนอกได้)
lib/main.dart
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
ซึ่งเราสามารถแก้จาก _counter
เป็น counter
ก็ได้ แต่จะกลายเป็นว่าไปแก้ความ private ของตัวแปรเราให้เป็น public ซะเฉยๆ เพราะงั้นผมจึงเลือกที่จะสร้าง Getter ของ counter ขึ้นมาแทน ดังนี้
lib/main.dart
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
int get counter => _counter;
แล้วจึงไปแก้ไฟล์ test/unit_test.dart
เป็น
expect(state.counter, 0);
รันดูก็จะพบว่าสำเร็จแล้ว เฮ
แล้วถ้าผมอยากทดสอบการบวกล่ะ ก็จะเจอปัญหาอีกเพราะว่า _incrementCounter
เป็น private (อีกแล้ว!! 😵)
ดังนั้นผมจะยังไม่แก้แล้วกัน เพราะก็เหมือนด้านบนเราต้องไปแก้การเข้าถึงของฟังก์ชัน
ในที่นี้ผมก็จะลบ unit_test.dart
ทิ้งไปก่อนแล้วกันครับ
Tips
เราสามารถรันทุกเทสในโปรเจคเราได้ด้วยการใช้คำสั่ง flutter test 💡
# การเขียนในรูปแบบเริ่มต้น
# ข้อดี
- เขียนง่าย เขียนได้เรื่อยๆ ไม่ต้องคิดมาก
# ข้อเสีย
- ถ้าแอปใหญ่ขึ้นจะเริ่มมีปัญหาการส่งค่าไปๆมาๆในแอป
- มีปัญหาด้าน Performance (แอปเล็กไม่มีปัญหา)
- ทำ Unit Testing ได้ลำบาก
จากข้างต้นแล้วถ้าเราเปลี่ยนโค้ดใหม่แยกส่วนประกอบต่างๆออกเป็น Widget ย่อยๆเพื่อให้โค้ดอ่านง่ายขึ้น
lib/main.dart
...
class DisplayCounter extends StatelessWidget {
final int _counter;
DisplayCounter(this._counter);
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.display1,
),
],
);
}
}
class FAButton extends StatelessWidget {
final Function _incrementCounter;
FAButton(this._incrementCounter);
Widget build(BuildContext context) {
return FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
);
}
}
แล้วจึงเปลี่ยนส่วนแสดงเป็น
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: DisplayCounter(_counter),
),
floatingActionButton: FAButton(_incrementCounter),
);
}
}
เมื่อลองรัน widget_test.dart ก็จะพบว่ายังผ่านอยู่เหมือนเดิม (ถ้าไม่ผ่านแสดงว่าทำอะไรผิดแน่ๆ)
คราวนี้ปัญหาเยอะกว่าเดิมอีก Unit Testing จะทำยังไงเนี่ย โค้ดกระจายไปหมดแล้ว!!
...ไม่ต้องตกใจครับ เราจะยังไม่สนใจ Unit Testing ณ ตอนนี้
# ปัญหา
- ถ้า Widget ที่ต้องการค่า
_counter
หรือ_incrementCounter
ของเรานั้นอยู่ลึก
เราจะต้องทำการส่ง_counter
หรือ_incrementCounter
เข้าไปใน Widget ตัวถัดๆไปจนกว่าจะถึงตัวที่ใช้ เป็นความลำบากของชีวิตเป็นอย่างยิ่ง - ถ้าผมลองใส่ debugPrint() ไปในแต่ละขั้นตอน Build ของ Widget เช่น
...
Widget build(BuildContext context) {
debugPrint('MyHomePage Built');
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: DisplayCounter(_counter),
),
floatingActionButton: FAButton(_incrementCounter),
);
}
...
แล้วรัน widget_test.dart
ก็จะพบว่า
MyApp Built
MyHomePage Built
DisplayCounter Built
FAButton Built
MyHomePage Built
DisplayCounter Built
FAButton Built
เมื่อ _counter
เปลี่ยน Flutter จะทำการ build widget MyHomePage
ใหม่รวมไปถึง Widget ที่เป็นลูกก็คือ DisplayCounter
และ FAButton
ด้วย
# BLoC ผู้ช่วยให้รอดของเรา
# ติดตั้ง Library ที่จำเป็น
แก้ไฟล์ pubspec.yaml
dependencies:
...
provider: ^3.0.0+1
rxdart: ^0.22.1+1
แล้วรันคำสั่ง flutter pub get
(ถ้าใช้ VSCode เมื่อกด Save จะรันให้อัตโนมัติ)
Tips
Provider (opens new window) เป็น Library ที่ถูกแนะนำในงาน Google I/O 2019 และ
RxDart (opens new window) เป็น Library ที่ทำให้เราสามารถใช้งาน ReactiveX กับ Dart ได้
# เริ่มเขียนไฟล์ BLoC
ก่อนอื่นผมจะทำการสร้างไฟล์ lib/blocs/counter_bloc.dart
ขึ้นมา
import 'package:rxdart/subjects.dart';
class CounterBloc {
// กำหนดค่าเริ่มต้นของ counter
static int initialCounter = 0;
// สร้าง BehaviorSubject เป็นตัวกลางในการส่งผ่านค่า counter เป็น Stream ชนิดหนึ่ง
BehaviorSubject<int> _counterController = BehaviorSubject<int>();
// Stream สำหรับคนที่จะมา Subscribe
get stream => _counterController.stream;
// ค่า counter ปัจจุบัน
get currentCounter => _counterController.value ?? initialCounter;
// ฟังก์ชันสำหรับเพิ่มค่า counter
increaseCounter() {
_counterController.sink.add(currentCounter + 1);
}
// Stream เมื่อใช้เสร็จก็ควรปิดด้วย
dispose() {
_counterController.close();
}
}
# เปลี่ยนโปรแกรมเดิมๆของเรา
จากนั้นก็ไปเปลี่ยนไฟล์ lib/main.dart
ให้ใช้ CounterBloc
ของเรา
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'blocs/counter_bloc.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
debugPrint('MyApp Built');
return Provider<CounterBloc>(
builder: (context) => CounterBloc(),
dispose: (context, _counterBloc) => _counterBloc.dispose(),
child: MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(title: 'Flutter Demo Home Page'),
),
);
}
}
...
จากโค้ดด้านบน ผมจะใช้ Provider ในการแจกจ่าย CounterBloc ที่ผมเขียนไว้
คิดภาพง่ายๆว่า Provider เปรียบเสมือน Storage กลางที่คอยเก็บ CounterBloc หรือ State ไว้ รอการเรียกใช้จาก Widget ต่างๆไม่ว่าจะที่ไหนในแอปของเรา
...
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
Widget build(BuildContext context) {
debugPrint('MyHomePage Built');
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: DisplayCounter(),
),
floatingActionButton: FAButton(),
);
}
}
...
ในที่นี้เราไม่ต้องส่ง _counter
และ _increaseCounter
จาก State ของ MyHomePage
ไปแล้ว เพราะเราสามารถดึงค่าจาก CounterBloc ของเราได้ในส่วนที่ต้องการใช้ค่า counter
...
class DisplayCounter extends StatelessWidget {
Widget build(BuildContext context) {
debugPrint('DisplayCounter Built');
CounterBloc _counterBloc = Provider.of<CounterBloc>(context);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
StreamBuilder<Object>(
stream: _counterBloc.stream,
initialData: CounterBloc.initialCounter,
builder: (context, snapshot) {
return Text(
'${snapshot.data}',
style: Theme.of(context).textTheme.display1,
);
},
),
],
);
}
}
...
เราจะใช้คำสั่งด้านล่างเพื่อเอาค่าของ CounterBloc จาก Provider
CounterBloc _counterBloc = Provider.of<CounterBloc>(context);
และ Widget StreamBuilder ใช้ในการ Subscribe หรือรอค่าที่ถูกส่งผ่าน Stream มาแล้วสร้างเป็น Widget ที่เราต้องการซึ่งในที่นี้คือ Widget Text
โดยทุกครั้งที่ counter มีการเปลี่ยนแปลง Text ของเราก็จะถูกอัปเดตค่าใหม่ด้วย
...
class FAButton extends StatelessWidget {
Widget build(BuildContext context) {
debugPrint('FAButton Built');
CounterBloc _counterBloc = Provider.of<CounterBloc>(context);
return FloatingActionButton(
onPressed: _counterBloc.increaseCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
);
}
}
เราสามารถเรียกใช้ _counterBloc.increaseCounter
ของ CounterBloc เพื่อเปลี่ยนแปลงค่าของ counter ได้ และทุกๆที่ ที่มีการ Subscribe ก็จะเปลี่ยนแปลงตามๆกันโดยอัตโนมัติ
# ลองรัน Widget Testing ใหม่อีกครั้ง
ก็จะพบว่าโปรแกรมยังทำงานได้อย่างถูกต้องเหมือนเดิม เย่ 🎉
แต่สิ่งที่มันต่างออกไปก็คือ
MyApp Built
MyHomePage Built
DisplayCounter Built
FAButton Built
จะมีการ build Widget ทั้งหมดแค่ครั้งเดียวเท่านั้นไม่มีการ build อย่างสูญเปล่าอีกแล้ว
โดยที่จะ build ใหม่มีแค่ Text ที่อยู่ใน StreamBuilder เท่านั้น
# เริ่มเขียน Unit Testing กันเถอะ
พอเราแยกส่วนของ Business Logic ออกจากส่วนของ UI เราก็สามารถทำการทดสอบเฉพาะส่วนได้(โดยง่าย)แล้ว
ก่อนอื่นผมจะสร้างไฟล์ test/unit_test.dart
ขึ้นมา
import 'package:bloc_provider/blocs/counter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('Test CounterBloc should work perfectly:', () {
test('Counter start with value of zero', () {
CounterBloc _counterBloc = CounterBloc();
expect(_counterBloc.currentCounter, 0);
});
test('Get correct counter value after call increaseCounter', () {
CounterBloc _counterBloc = CounterBloc();
_counterBloc.increaseCounter();
expect(_counterBloc.currentCounter, 1);
_counterBloc.increaseCounter();
expect(_counterBloc.currentCounter, 2);
_counterBloc.increaseCounter();
expect(_counterBloc.currentCounter, 3);
});
});
}
เมื่อเสร็จแล้วก็ลองรันดูก็พบว่ารันได้อย่างปกติสุข
ดาวน์โหลด Source Code ที่นี่ (opens new window)
# สรุป
การเปลี่ยนมาใช้ BLoC ช่วยให้เราสามารถเพิ่มประสิทธิภาพของโปรแกรมของเราให้ดีขึ้น แถมยังช่วยในเรื่องการทำ Testing อีกต่างหาก ถ้าหากมีข้อสงสัยอะไรสามารถติดต่อผ่าน Facebook Page (opens new window) ของผมได้ครับ