# BLoC Pattern กับการทำ Testing

#Dev#Flutter#Testing

BLoC (Business Logic Component) เป็นรูปแบบหนึ่งในการจัดการส่วนของ Business Logic ของแอปที่เขียนบน Flutter ซึ่งทาง Google เริ่มแนะนำมาตั้งแต่ปี 2018
โดยรูปแบบนี้ง่ายต่อการจัดการโค้ด และสามารถทำให้เราทดสอบแอปหรือ ทำ Unit Testing ได้ง่ายขึ้น

Header

# ลองสร้างโปรเจคกันก่อน

  1. รันคำสั่งใน Command line เพื่อสร้างโปรเจค Flutter
flutter create bloc_provider # สร้างโปรเจคชื่อ bloc_provider
  1. รันคำสั่ง Command line เพื่อรันแอปบน Emulator (ต้องเปิด Emulator ก่อนนะ) หรือถ้าเขียนโดยใช้ Visual Studio Code ก็สามารถกด F5 แทนได้
flutter run

และนี่ก็คือหน้าจอเริ่มต้นที่ Flutter สร้างมาให้ เป็นโปรแกรมที่มีปุ่ม + เมื่อกดแล้วก็จะเพิ่มจำนวนนับบนหน้าจอ 😁

Title
ซึ่งในบทความนี้เราไม่ได้ใช้ Emulator หรอกรันเทสล้วนๆ 🤣

โดยโค้ดโปรแกรมของเราจะอยู่ใน 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 💡

# การเขียนในรูปแบบเริ่มต้น

# ข้อดี

  1. เขียนง่าย เขียนได้เรื่อยๆ ไม่ต้องคิดมาก

# ข้อเสีย

  1. ถ้าแอปใหญ่ขึ้นจะเริ่มมีปัญหาการส่งค่าไปๆมาๆในแอป
  2. มีปัญหาด้าน Performance (แอปเล็กไม่มีปัญหา)
  3. ทำ 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 ณ ตอนนี้

# ปัญหา

  1. ถ้า Widget ที่ต้องการค่า _counter หรือ _incrementCounter ของเรานั้นอยู่ลึก
    เราจะต้องทำการส่ง _counter หรือ _incrementCounter เข้าไปใน Widget ตัวถัดๆไปจนกว่าจะถึงตัวที่ใช้ เป็นความลำบากของชีวิตเป็นอย่างยิ่ง
  2. ถ้าผมลองใส่ 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) ของผมได้ครับ


อัปเดตเมื่อ: 1 ปีที่แล้ว