Pengujian Unit di Flutter: Dari Esensi Alur Kerja hingga Skenario Kompleks
Diterbitkan: 2022-09-21Minat 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:
- Evaluasi kodenya.
- Siapkan ejekan data.
- Tentukan grup uji.
- Tentukan tanda tangan fungsi pengujian untuk setiap grup pengujian.
- 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:
- Setiap kelas proyek mendukung satu tujuan.
- 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.
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:
- Evaluasi kodenya.
- Siapkan data mocking: Kami akan menentukan respons server untuk panggilan API kami.
- Tentukan grup uji: Kami akan memiliki dua grup uji, satu untuk setiap fungsi.
- Tentukan tanda tangan fungsi pengujian untuk setiap grup pengujian.
- 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.
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:
- Apakah fungsi kelas
Dio
sebenarnya dipanggil olehgetUniversitiesByCountry
? - Jika permintaan API kami mengembalikan kesalahan, apa yang terjadi?
- 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.