Pengujian Unit di Flutter: Dari Esensi Alur Kerja hingga Skenario Kompleks

Diterbitkan: 2022-09-21

Minat pada Flutter berada pada titik tertinggi sepanjang masa—dan sudah lama tertunda. SDK sumber terbuka Google kompatibel dengan Android, iOS, macOS, web, Windows, dan Linux. Satu basis kode Flutter mendukung semuanya. Dan pengujian unit berperan penting dalam menghadirkan aplikasi Flutter yang konsisten dan andal, memastikan kesalahan, kekurangan, dan cacat dengan meningkatkan kualitas kode sebelum dirakit.

Dalam tutorial ini, kami membagikan pengoptimalan alur kerja untuk pengujian unit Flutter, mendemonstrasikan pengujian unit Flutter dasar, lalu beralih ke kasus dan pustaka pengujian Flutter yang lebih kompleks.

Alur Pengujian Unit di Flutter

Kami menerapkan pengujian unit di Flutter dengan cara yang hampir sama seperti yang kami lakukan di tumpukan teknologi lainnya:

  1. Evaluasi kodenya.
  2. Siapkan ejekan data.
  3. Tentukan grup uji.
  4. Tentukan tanda tangan fungsi pengujian untuk setiap grup pengujian.
  5. Tulis tesnya.

Untuk mendemonstrasikan pengujian unit, saya telah menyiapkan contoh proyek Flutter dan mendorong Anda untuk menggunakan dan menguji kode di waktu luang Anda. Proyek menggunakan API eksternal untuk mengambil dan menampilkan daftar universitas yang dapat kami filter berdasarkan negara.

Beberapa catatan tentang cara kerja Flutter: Kerangka kerja ini memfasilitasi pengujian dengan memuat pustaka flutter_test secara otomatis saat proyek dibuat. Pustaka memungkinkan Flutter membaca, menjalankan, dan menganalisis pengujian unit. Flutter juga membuat folder test secara otomatis untuk menyimpan pengujian. Sangat penting untuk menghindari mengganti nama dan/atau memindahkan folder test , karena ini merusak fungsinya dan, karenanya, kemampuan kita untuk menjalankan pengujian. Penting juga untuk menyertakan _test.dart dalam nama file pengujian kami, karena sufiks ini adalah cara Flutter mengenali file pengujian.

Struktur Direktori Uji

Untuk mempromosikan pengujian unit dalam proyek kami, kami menerapkan MVVM dengan arsitektur bersih dan injeksi ketergantungan (DI), sebagaimana dibuktikan dalam nama yang dipilih untuk subfolder kode sumber. Kombinasi prinsip MVVM dan DI memastikan pemisahan masalah:

  1. Setiap kelas proyek mendukung satu tujuan.
  2. Setiap fungsi dalam kelas hanya memenuhi ruang lingkupnya sendiri.

Kami akan membuat ruang penyimpanan terorganisir untuk file pengujian yang akan kami tulis, sebuah sistem di mana kelompok pengujian akan memiliki "rumah" yang mudah diidentifikasi. Mengingat persyaratan Flutter untuk menemukan pengujian di dalam folder test , mari kita cerminkan struktur folder kode sumber kita yang sedang test . Kemudian, ketika kami menulis tes, kami akan menyimpannya di subfolder yang sesuai: Sama seperti kaus kaki bersih masuk ke laci kaus kaki lemari Anda dan kemeja terlipat masuk ke laci kemeja, tes unit kelas Model masuk ke folder bernama model , Misalnya.

Struktur folder file dengan dua folder tingkat pertama: lib dan test. Bersarang di bawah lib kami memiliki folder fitur, bersarang lebih lanjut adalah university_feed, dan bersarang lebih lanjut adalah data. Folder data berisi folder repositori dan sumber. Bersarang di bawah folder sumber adalah folder jaringan. Bersarang di bawah jaringan adalah folder titik akhir dan model, ditambah file university_remote_data_source.dart. Dalam folder model adalah file api_university_model.dart. Pada level yang sama dengan folder university_feed yang disebutkan sebelumnya adalah folder domain dan presentasi. Bersarang di bawah domain adalah folder usecase. Bersarang di bawah presentasi adalah model dan folder layar. Struktur folder pengujian yang disebutkan sebelumnya meniru struktur lib. Bersarang di bawah folder tes adalah folder unit_test yang berisi folder university_feed. Struktur foldernya sama dengan folder university_feed di atas, dengan nama file dart yang ditambahkan "_test".
Struktur Folder Uji Proyek yang Mencerminkan Struktur Kode Sumber

Mengadopsi sistem file ini membangun transparansi ke dalam proyek dan memberi tim cara mudah untuk melihat bagian mana dari kode kami yang memiliki pengujian terkait.

Kami sekarang siap untuk menerapkan pengujian unit ke dalam tindakan.

Tes Unit Flutter Sederhana

Kita akan mulai dengan kelas model (di lapisan data kode sumber) dan akan membatasi contoh kita untuk menyertakan hanya satu kelas model , ApiUniversityModel . Kelas ini memiliki dua fungsi:

  • Inisialisasi model kita dengan mengejek objek JSON dengan Map .
  • Membangun model data University .

Untuk menguji setiap fungsi model, kami akan menyesuaikan langkah-langkah universal yang dijelaskan sebelumnya:

  1. Evaluasi kodenya.
  2. Siapkan data mocking: Kami akan menentukan respons server untuk panggilan API kami.
  3. Tentukan grup uji: Kami akan memiliki dua grup uji, satu untuk setiap fungsi.
  4. Tentukan tanda tangan fungsi pengujian untuk setiap grup pengujian.
  5. Tulis tesnya.

Setelah mengevaluasi kode kami, kami siap untuk mencapai tujuan kedua kami: untuk menyiapkan data mocking khusus untuk dua fungsi dalam kelas ApiUniversityModel .

Untuk mengejek fungsi pertama (menginisialisasi model kita dengan mengejek JSON dengan Map ), fromJson , kita akan membuat dua objek Map untuk mensimulasikan data input untuk fungsi tersebut. Kami juga akan membuat dua objek ApiUniversityModel yang setara untuk mewakili hasil yang diharapkan dari fungsi dengan input yang disediakan.

Untuk mengejek fungsi kedua (membangun model data University ), toDomain , kita akan membuat dua objek University , yang merupakan hasil yang diharapkan setelah menjalankan fungsi ini di objek ApiUniversityModel yang digunakan sebelumnya:

 void main() { Map<String, dynamic> apiUniversityOneAsJson = { "alpha_two_code": "US", "domains": ["marywood.edu"], "country": "United States", "state-province": null, "web_pages": ["http://www.marywood.edu"], "name": "Marywood University" }; ApiUniversityModel expectedApiUniversityOne = ApiUniversityModel( alphaCode: "US", country: "United States", state: null, name: "Marywood University", websites: ["http://www.marywood.edu"], domains: ["marywood.edu"], ); University expectedUniversityOne = University( alphaCode: "US", country: "United States", state: "", name: "Marywood University", websites: ["http://www.marywood.edu"], domains: ["marywood.edu"], ); Map<String, dynamic> apiUniversityTwoAsJson = { "alpha_two_code": "US", "domains": ["lindenwood.edu"], "country": "United States", "state-province":"MJ", "web_pages": null, "name": "Lindenwood University" }; ApiUniversityModel expectedApiUniversityTwo = ApiUniversityModel( alphaCode: "US", country: "United States", state:"MJ", name: "Lindenwood University", websites: null, domains: ["lindenwood.edu"], ); University expectedUniversityTwo = University( alphaCode: "US", country: "United States", state: "MJ", name: "Lindenwood University", websites: [], domains: ["lindenwood.edu"], ); }

Selanjutnya, untuk tujuan ketiga dan keempat kami, kami akan menambahkan bahasa deskriptif untuk menentukan grup pengujian dan tanda tangan fungsi pengujian kami:

 void main() { // Previous declarations group("Test ApiUniversityModel initialization from JSON", () { test('Test using json one', () {}); test('Test using json two', () {}); }); group("Test ApiUniversityModel toDomain", () { test('Test toDomain using json one', () {}); test('Test toDomain using json two', () {}); }); }

Kami telah mendefinisikan tanda tangan dari dua tes untuk memeriksa fungsi fromJson , dan dua untuk memeriksa fungsi toDomain .

Untuk memenuhi tujuan kelima kita dan menulis tes, mari gunakan metode ekspektasi perpustakaan expect untuk membandingkan hasil fungsi dengan ekspektasi kita:

 void main() { // Previous declarations group("Test ApiUniversityModel initialization from json", () { test('Test using json one', () { expect(ApiUniversityModel.fromJson(apiUniversityOneAsJson), expectedApiUniversityOne); }); test('Test using json two', () { expect(ApiUniversityModel.fromJson(apiUniversityTwoAsJson), expectedApiUniversityTwo); }); }); group("Test ApiUniversityModel toDomain", () { test('Test toDomain using json one', () { expect(ApiUniversityModel.fromJson(apiUniversityOneAsJson).toDomain(), expectedUniversityOne); }); test('Test toDomain using json two', () { expect(ApiUniversityModel.fromJson(apiUniversityTwoAsJson).toDomain(), expectedUniversityTwo); }); }); }

Setelah mencapai lima tujuan kami, kami sekarang dapat menjalankan tes, baik dari IDE atau dari baris perintah.

Tangkapan layar yang menunjukkan bahwa lima dari lima tes lulus. Header berbunyi: Jalankan: tes di api_university_model_test.dart. Panel kiri layar berbunyi: Hasil pengujian---memuat api_university_model_test.dart----api_university_model_test.dart---Menguji inisialisasi ApiUniversityModel dari json---Menguji menggunakan json satu---Menguji menggunakan json dua---Menguji ApiUniversityModel toDomain ---Uji toDomain menggunakan json satu---Uji toDomain menggunakan json dua. Panel kanan layar berbunyi: Tes lulus: lima dari lima tes---flutter test test/unit_test/universities_feed/data/source/network/model/api_university_model_test.dart

Di terminal, kita dapat menjalankan semua tes yang ada di dalam folder test dengan memasukkan perintah flutter test , dan melihat bahwa tes kita lulus.

Atau, kita dapat menjalankan satu tes atau grup pengujian dengan memasukkan perintah flutter test --plain-name "ReplaceWithName" , dengan mengganti nama grup pengujian atau pengujian kita dengan ReplaceWithName .

Unit Menguji Titik Akhir di Flutter

Setelah menyelesaikan pengujian sederhana tanpa ketergantungan, mari jelajahi contoh yang lebih menarik: Kami akan menguji kelas endpoint , yang cakupannya meliputi:

  • Menjalankan panggilan API ke server.
  • Mengubah respons API JSON ke dalam format yang berbeda.

Setelah mengevaluasi kode kami, kami akan menggunakan metode setUp perpustakaan flutter_test untuk menginisialisasi kelas dalam grup pengujian kami:

 group("Test University Endpoint API calls", () { setUp(() { baseUrl = "https://test.url"; dioClient = Dio(BaseOptions()); endpoint = UniversityEndpoint(dioClient, baseUrl: baseUrl); }); }

Untuk membuat permintaan jaringan ke API, saya lebih suka menggunakan perpustakaan retrofit, yang menghasilkan sebagian besar kode yang diperlukan. Untuk menguji kelas UniversityEndpoint dengan benar, kami akan memaksa pustaka dio—yang digunakan Retrofit untuk menjalankan panggilan API—untuk mengembalikan hasil yang diinginkan dengan mengejek perilaku kelas Dio melalui adaptor respons khusus.

Mock Interceptor Jaringan Kustom

Mengejek dimungkinkan karena kami telah membangun kelas UniversityEndpoint melalui DI. (Jika kelas UniversityEndpoint menginisialisasi kelas Dio dengan sendirinya, tidak akan ada cara bagi kita untuk mengejek perilaku kelas.)

Untuk mengejek perilaku kelas Dio , kita perlu mengetahui metode Dio yang digunakan dalam perpustakaan Retrofit —tetapi kita tidak memiliki akses langsung ke Dio . Oleh karena itu, kami akan mengejek Dio menggunakan interseptor respons jaringan khusus:

 class DioMockResponsesAdapter extends HttpClientAdapter { final MockAdapterInterceptor interceptor; DioMockResponsesAdapter(this.interceptor); @override void close({bool force = false}) {} @override Future<ResponseBody> fetch(RequestOptions options, Stream<Uint8List>? requestStream, Future? cancelFuture) { if (options.method == interceptor.type.name.toUpperCase() && options.baseUrl == interceptor.uri && options.queryParameters.hasSameElementsAs(interceptor.query) && options.path == interceptor.path) { return Future.value(ResponseBody.fromString( jsonEncode(interceptor.serializableResponse), interceptor.responseCode, headers: { "content-type": ["application/json"] }, )); } return Future.value(ResponseBody.fromString( jsonEncode( {"error": "Request doesn't match the mock interceptor details!"}), -1, statusMessage: "Request doesn't match the mock interceptor details!")); } } enum RequestType { GET, POST, PUT, PATCH, DELETE } class MockAdapterInterceptor { final RequestType type; final String uri; final String path; final Map<String, dynamic> query; final Object serializableResponse; final int responseCode; MockAdapterInterceptor(this.type, this.uri, this.path, this.query, this.serializableResponse, this.responseCode); }

Sekarang setelah kami membuat pencegat untuk mengejek respons jaringan kami, kami dapat menentukan grup pengujian dan tanda tangan fungsi pengujian kami.

Dalam kasus kami, kami hanya memiliki satu fungsi untuk diuji ( getUniversitiesByCountry ), jadi kami hanya akan membuat satu grup pengujian. Kami akan menguji respons fungsi kami untuk tiga situasi:

  1. Apakah fungsi kelas Dio sebenarnya dipanggil oleh getUniversitiesByCountry ?
  2. Jika permintaan API kami mengembalikan kesalahan, apa yang terjadi?
  3. Jika permintaan API kami mengembalikan hasil yang diharapkan, apa yang terjadi?

Inilah tanda tangan grup pengujian dan fungsi pengujian kami:

 group("Test University Endpoint API calls", () { test('Test endpoint calls dio', () async {}); test('Test endpoint returns error', () async {}); test('Test endpoint calls and returns 2 valid universities', () async {}); });

Kami siap untuk menulis tes kami. Untuk setiap kasus pengujian, kami akan membuat instance DioMockResponsesAdapter dengan konfigurasi yang sesuai:

 group("Test University Endpoint API calls", () { setUp(() { baseUrl = "https://test.url"; dioClient = Dio(BaseOptions()); endpoint = UniversityEndpoint(dioClient, baseUrl: baseUrl); }); test('Test endpoint calls dio', () async { dioClient.httpClientAdapter = _createMockAdapterForSearchRequest( 200, [], ); var result = await endpoint.getUniversitiesByCountry("us"); expect(result, <ApiUniversityModel>[]); }); test('Test endpoint returns error', () async { dioClient.httpClientAdapter = _createMockAdapterForSearchRequest( 404, {"error": "Not found!"}, ); List<ApiUniversityModel>? response; DioError? error; try { response = await endpoint.getUniversitiesByCountry("us"); } on DioError catch (dioError, _) { error = dioError; } expect(response, null); expect(error?.error, "Http status error [404]"); }); test('Test endpoint calls and returns 2 valid universities', () async { dioClient.httpClientAdapter = _createMockAdapterForSearchRequest( 200, generateTwoValidUniversities(), ); var result = await endpoint.getUniversitiesByCountry("us"); expect(result, expectedTwoValidUniversities()); }); });

Sekarang setelah pengujian titik akhir kita selesai, mari kita uji kelas sumber data kita, UniversityRemoteDataSource . Sebelumnya, kami mengamati bahwa kelas UniversityEndpoint adalah bagian dari konstruktor UniversityRemoteDataSource({UniversityEndpoint? universityEndpoint}) , yang menunjukkan bahwa UniversityRemoteDataSource menggunakan kelas UniversityEndpoint untuk memenuhi cakupannya, jadi ini adalah kelas yang akan kita tiru.

Mengejek Dengan Mockito

Dalam contoh kami sebelumnya, kami secara manual mengejek adaptor permintaan klien Dio kami menggunakan NetworkInterceptor kustom. Di sini kita mengejek seluruh kelas. Melakukannya secara manual—mengejek kelas dan fungsinya—akan memakan waktu. Untungnya, perpustakaan tiruan dirancang untuk menangani situasi seperti itu dan dapat menghasilkan kelas tiruan dengan sedikit usaha. Mari gunakan perpustakaan mockito, perpustakaan standar industri untuk mengejek di Flutter.

Untuk mengejek Mockito , pertama-tama kita tambahkan anotasi “ @GenerateMocks([class_1,class_2,…]) ” sebelum kode pengujian—tepat di atas fungsi void main() {} . Dalam anotasi, kami akan menyertakan daftar nama kelas sebagai parameter (sebagai pengganti class_1,class_2… ).

Selanjutnya, kami menjalankan perintah Flutter's flutter pub run build_runner build yang menghasilkan kode untuk kelas tiruan kami di direktori yang sama dengan pengujian. Nama file tiruan yang dihasilkan akan menjadi kombinasi dari nama file pengujian ditambah .mocks.dart , menggantikan akhiran .dart pengujian. Konten file akan menyertakan kelas tiruan yang namanya dimulai dengan awalan Mock . Misalnya, UniversityEndpoint menjadi MockUniversityEndpoint .

Sekarang, kita mengimpor university_remote_data_source_test.dart.mocks.dart (file tiruan kita) ke university_remote_data_source_test.dart (file tes).

Kemudian, dalam fungsi setUp , kita akan mengejek UniversityEndpoint dengan menggunakan MockUniversityEndpoint dan menginisialisasi kelas UniversityRemoteDataSource :

 import 'university_remote_data_source_test.mocks.dart'; @GenerateMocks([UniversityEndpoint]) void main() { late UniversityEndpoint endpoint; late UniversityRemoteDataSource dataSource; group("Test function calls", () { setUp(() { endpoint = MockUniversityEndpoint(); dataSource = UniversityRemoteDataSource(universityEndpoint: endpoint); }); }

Kami berhasil mengejek UniversityEndpoint dan kemudian menginisialisasi kelas UniversityRemoteDataSource kami. Sekarang kami siap untuk menentukan grup pengujian dan tanda tangan fungsi pengujian kami:

 group("Test function calls", () { test('Test dataSource calls getUniversitiesByCountry from endpoint', () {}); test('Test dataSource maps getUniversitiesByCountry response to Stream', () {}); test('Test dataSource maps getUniversitiesByCountry response to Stream with error', () {}); });

Dengan ini, ejekan, grup uji, dan tanda tangan fungsi uji kami disiapkan. Kami siap untuk menulis tes yang sebenarnya.

Pengujian pertama kami memeriksa apakah fungsi UniversityEndpoint dipanggil saat sumber data memulai pengambilan informasi negara. Kita mulai dengan mendefinisikan bagaimana setiap kelas akan bereaksi ketika fungsinya dipanggil. Karena kita mengejek kelas UniversityEndpoint , itulah kelas yang akan kita kerjakan, menggunakan struktur kode when( function_that_will_be_called ).then( what_will_be_returned ) .

Fungsi yang kami uji adalah asinkron (fungsi yang mengembalikan objek Future ), jadi kami akan menggunakan struktur kode when(function name).thenanswer( (_) {modified function result} ) untuk mengubah hasil kami.

Untuk memeriksa apakah fungsi getUniversitiesByCountry memanggil fungsi getUniversitiesByCountry dalam kelas UniversityEndpoint , kita akan menggunakan when(...).thenAnswer( (_) {...} ) untuk mengejek fungsi getUniversitiesByCountry dalam kelas UniversityEndpoint :

 when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[]));

Sekarang setelah kita mengejek respons kita, kita memanggil fungsi sumber data dan memeriksa—menggunakan fungsi verify —apakah fungsi UniversityEndpoint dipanggil:

 test('Test dataSource calls getUniversitiesByCountry from endpoint', () { when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[])); dataSource.getUniversitiesByCountry("test"); verify(endpoint.getUniversitiesByCountry("test")); });

Kami dapat menggunakan prinsip yang sama untuk menulis pengujian tambahan yang memeriksa apakah fungsi kami dengan benar mengubah hasil titik akhir kami menjadi aliran data yang relevan:

 import 'university_remote_data_source_test.mocks.dart'; @GenerateMocks([UniversityEndpoint]) void main() { late UniversityEndpoint endpoint; late UniversityRemoteDataSource dataSource; group("Test function calls", () { setUp(() { endpoint = MockUniversityEndpoint(); dataSource = UniversityRemoteDataSource(universityEndpoint: endpoint); }); test('Test dataSource calls getUniversitiesByCountry from endpoint', () { when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[])); dataSource.getUniversitiesByCountry("test"); verify(endpoint.getUniversitiesByCountry("test")); }); test('Test dataSource maps getUniversitiesByCountry response to Stream', () { when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[])); expect( dataSource.getUniversitiesByCountry("test"), emitsInOrder([ const AppResult<List<University>>.loading(), const AppResult<List<University>>.data([]) ]), ); }); test( 'Test dataSource maps getUniversitiesByCountry response to Stream with error', () { ApiError mockApiError = ApiError( statusCode: 400, message: "error", errors: null, ); when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.error(mockApiError)); expect( dataSource.getUniversitiesByCountry("test"), emitsInOrder([ const AppResult<List<University>>.loading(), AppResult<List<University>>.apiError(mockApiError) ]), ); }); }); }

Kami telah menjalankan sejumlah pengujian unit Flutter dan mendemonstrasikan berbagai pendekatan untuk mengejek. Saya mengundang Anda untuk terus menggunakan contoh proyek Flutter saya untuk menjalankan pengujian tambahan.

Tes Unit Flutter: Kunci Anda untuk UX Unggul

Jika Anda sudah memasukkan pengujian unit ke dalam proyek Flutter Anda, artikel ini mungkin telah memperkenalkan beberapa opsi baru yang dapat Anda masukkan ke dalam alur kerja Anda. Dalam tutorial ini, kami menunjukkan betapa mudahnya menggabungkan pengujian unit ke dalam proyek Flutter Anda berikutnya dan cara mengatasi tantangan skenario pengujian yang lebih bernuansa. Anda mungkin tidak ingin melewatkan pengujian unit di Flutter lagi.

Tim editorial Blog Toptal Engineering mengucapkan terima kasih kepada Matija Becirevic dan Paul Hoskins karena telah meninjau contoh kode dan konten teknis lainnya yang disajikan dalam artikel ini.