Flutter'da Birim Testi: İş Akışı Temellerinden Karmaşık Senaryolara

Yayınlanan: 2022-09-21

Flutter'a olan ilgi tüm zamanların en yüksek seviyesinde ve çok geç kalınmış durumda. Google'ın açık kaynaklı SDK'sı Android, iOS, macOS, web, Windows ve Linux ile uyumludur. Tek bir Flutter kod tabanı hepsini destekler. Ve birim testi, tutarlı ve güvenilir bir Flutter uygulaması sunmada, birleştirilmeden önce kodun kalitesini önceden iyileştirerek hatalara, kusurlara ve kusurlara karşı koruma sağlamada etkilidir.

Bu eğitimde, Flutter birim testi için iş akışı optimizasyonlarını paylaşıyor, temel bir Flutter birim testi gösteriyor ve ardından daha karmaşık Flutter test senaryoları ve kitaplıklarına geçiyoruz.

Flutter'da Birim Testinin Akışı

Flutter'da birim testini diğer teknoloji yığınlarında yaptığımızla aynı şekilde uygularız:

  1. Kodu değerlendirin.
  2. Veri alayını ayarlayın.
  3. Test gruplarını tanımlayın.
  4. Her test grubu için test işlevi imzasını/imzalarını tanımlayın.
  5. Testleri yazın.

Birim testini göstermek için örnek bir Flutter projesi hazırladım ve sizi boş zamanlarınızda kodu kullanıp test etmeye teşvik ediyorum. Proje, ülkeye göre filtreleyebileceğimiz üniversitelerin listesini getirmek ve görüntülemek için harici bir API kullanıyor.

Flutter'ın nasıl çalıştığı hakkında birkaç not: Çerçeve, bir proje oluşturulduğunda flutter_test kitaplığını otomatik olarak yükleyerek testi kolaylaştırır. Kitaplık, Flutter'ın birim testlerini okumasını, çalıştırmasını ve analiz etmesini sağlar. Flutter ayrıca testlerin depolanacağı test klasörünü otomatik olarak oluşturur. test klasörünü yeniden adlandırmaktan ve/veya taşımaktan kaçınmak önemlidir, çünkü bu, işlevselliğini ve dolayısıyla testleri çalıştırma yeteneğimizi bozar. Flutter'ın test dosyalarını tanıma şekli bu sonek olduğundan, test dosya adlarımıza _test.dart dahil etmek de önemlidir.

Dizin Yapısını Test Et

Projemizde birim testini teşvik etmek için, kaynak kodu alt klasörleri için seçilen adlarda gösterildiği gibi, temiz mimari ve bağımlılık ekleme (DI) ile MVVM'yi uyguladık. MVVM ve DI ilkelerinin birleşimi, endişelerin ayrılmasını sağlar:

  1. Her proje sınıfı tek bir hedefi destekler.
  2. Bir sınıf içindeki her işlev yalnızca kendi kapsamını yerine getirir.

Yazacağımız test dosyaları için organize bir depolama alanı oluşturacağız, test gruplarının kolayca tanımlanabilecek “evlere” sahip olacağı bir sistem. Flutter'ın testleri test klasörü içinde bulma gereksinimi ışığında, kaynak kodumuzun test altındaki klasör yapısını yansıtalım. Daha sonra bir test yazdığımızda uygun alt klasöre kaydedeceğiz: Nasıl temiz çoraplar şifonyerinizin çorap çekmecesine, katlanmış gömlekler gömlek çekmecesine gidiyorsa, Model sınıflarının birim testleri model adlı bir klasöre gider. , örneğin.

İki birinci düzey klasör içeren dosya klasörü yapısı: lib ve test. Lib'in altında iç içe geçmiş özellikler klasörüne sahibiz, daha fazla iç içe üniversiteler_feed ve iç içe geçmiş verilerdir. Veri klasörü, depo ve kaynak klasörlerini içerir. Kaynak klasörün altında iç içe ağ klasörü bulunur. Ağın altında iç içe geçmiş, uç nokta ve model klasörleri ile üniversite_remote_data_source.dart dosyasıdır. Model klasöründe api_university_model.dart dosyası bulunur. Daha önce bahsedilen üniversiteler_besleme klasörü ile aynı düzeyde, etki alanı ve sunum klasörleridir. Etki alanının altında yuvalanmış, kullanım dosyası klasörüdür. Sunumun altında iç içe modeller ve ekran klasörleri bulunur. Daha önce bahsedilen test klasörünün yapısı, lib'in yapısını taklit eder. Test klasörünün altında, üniversiteler_besleme klasörünü içeren unit_test klasörü bulunur. Klasör yapısı, adlarına "_test" eklenmiş dart dosyaları ile yukarıdaki üniversiteler_feed klasörü ile aynıdır.
Kaynak Kodu Yapısını Yansıtan Projenin Test Klasörü Yapısı

Bu dosya sistemini benimsemek, projede şeffaflık sağlar ve ekibe, kodumuzun hangi bölümlerinin ilişkili testlere sahip olduğunu görmenin kolay bir yolunu sunar.

Artık birim testini eyleme geçirmeye hazırız.

Basit Bir Çarpıntı Birimi Testi

model sınıflarıyla (kaynak kodun data katmanında) başlayacağız ve örneğimizi yalnızca bir model sınıfı, ApiUniversityModel içerecek şekilde sınırlayacağız. Bu sınıf iki işleve sahiptir:

  • JSON nesnesini Map ile taklit ederek modelimizi başlatın.
  • University veri modelini oluşturun.

Modelin işlevlerinin her birini test etmek için daha önce açıklanan evrensel adımları özelleştireceğiz:

  1. Kodu değerlendirin.
  2. Veri alayını ayarlayın: API çağrımıza sunucu yanıtını tanımlayacağız.
  3. Test gruplarını tanımlayın: Her fonksiyon için bir tane olmak üzere iki test grubumuz olacak.
  4. Her test grubu için test işlevi imzalarını tanımlayın.
  5. Testleri yazın.

Kodumuzu değerlendirdikten sonra, ikinci hedefimizi gerçekleştirmeye hazırız: ApiUniversityModel sınıfındaki iki işleve özel veri alayı kurmak.

İlk işlevi alay etmek için ( fromJson bir Map ile alay ederek modelimizi başlatmak), Json öğesinden, işlev için giriş verilerini simüle etmek üzere iki Map nesnesi oluşturacağız. Sağlanan girdiyle işlevin beklenen sonucunu temsil etmek için iki eşdeğer ApiUniversityModel nesnesi de oluşturacağız.

İkinci işlevi ( University veri modelini oluşturma) taklit etmek için, toDomain , bu işlevi daha önce başlatılmış ApiUniversityModel nesnelerinde çalıştırdıktan sonra beklenen sonuç olan iki University nesnesi oluşturacağız:

 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"], ); }

Ardından, üçüncü ve dördüncü hedeflerimiz için test gruplarımızı ve test fonksiyon imzalarımızı tanımlamak için tanımlayıcı bir dil ekleyeceğiz:

 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', () {}); }); }

fromJson işlevini kontrol etmek için iki testin ve fromJson işlevini kontrol etmek için iki testin imzasını toDomain .

Beşinci hedefimizi gerçekleştirmek ve testleri yazmak için, fonksiyonların sonuçlarını beklentilerimizle karşılaştırmak için flutter_test kitaplığının expect yöntemini kullanalım:

 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); }); }); }

Beş hedefimizi gerçekleştirdikten sonra, testleri artık IDE'den veya komut satırından çalıştırabiliriz.

Beş testten beşinin geçtiğini gösteren ekran görüntüsü. Başlık okur: Çalıştır: api_university_model_test.dart'ta testler. Ekranın sol panelinde şunu okur: Test sonuçları --- yükleniyor api_university_model_test.dart----api_university_model_test.dart---Test json'dan ApiUniversityModel başlatma --- json one kullanarak test edin --- json iki kullanarak test edin --- ApiDomainiversityModel'e Testler ---json one kullanarak Domain toDomain'i test edin ---json iki kullanarak Domain toDomain'i test edin. Ekranın sağ panelinde şunlar bulunur: Geçilen testler: beş testten beşi --- çarpıntı testi testi/unit_test/universities_feed/data/source/network/model/api_university_model_test.dart

Bir terminalde, flutter test komutunu girerek test klasöründeki tüm testleri çalıştırabilir ve testlerimizin geçtiğini görebiliriz.

Alternatif olarak, flutter test --plain-name "ReplaceWithName" komutunu girerek, test veya test grubumuzun adını ReplaceWithName ile değiştirerek tek bir test veya test grubu çalıştırabiliriz.

Flutter'da Bir Uç Noktayı Test Eden Birim

Bağımlılık içermeyen basit bir testi tamamladıktan sonra, daha ilginç bir örneği keşfedelim: Kapsamı aşağıdakileri içeren endpoint sınıfını test edeceğiz:

  • Sunucuya bir API çağrısı yürütme.
  • API JSON yanıtını farklı bir biçime dönüştürme.

Kodumuzu değerlendirdikten sonra, test grubumuzdaki sınıfları başlatmak için flutter_test kitaplığının setUp yöntemini kullanacağız:

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

API'lere ağ istekleri yapmak için gerekli kodun çoğunu üreten güçlendirme kitaplığını kullanmayı tercih ederim. UniversityEndpoint sınıfını düzgün bir şekilde test etmek için, Retrofit API çağrılarını yürütmek için kullandığı dio kitaplığını, özel bir yanıt adaptörü aracılığıyla Dio sınıfının davranışını taklit ederek istenen sonucu döndürmeye zorlayacağız.

Özel Ağ Engelleyici Mock

UniversityEndpoint sınıfını DI aracılığıyla oluşturmamız nedeniyle alay etmek mümkündür. ( UniversityEndpoint sınıfı kendi başına bir Dio sınıfını başlatacak olsaydı, sınıfın davranışıyla alay etmemizin hiçbir yolu olmazdı.)

Dio sınıfının davranışıyla alay etmek için, Retrofit kitaplığında kullanılan Dio yöntemlerini bilmemiz gerekir, ancak Dio doğrudan erişimimiz yoktur. Bu nedenle, özel bir ağ yanıtı önleyici kullanarak Dio ile alay edeceğiz:

 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); }

Artık ağ yanıtlarımızla alay etmek için önleyiciyi oluşturduğumuza göre, test gruplarımızı ve test işlevi imzalarımızı tanımlayabiliriz.

Bizim durumumuzda test edilecek tek bir fonksiyonumuz var ( getUniversitiesByCountry ), bu yüzden sadece bir test grubu oluşturacağız. Fonksiyonumuzun üç duruma tepkisini test edeceğiz:

  1. Dio sınıfının işlevi gerçekten getUniversitiesByCountry tarafından mı çağrılıyor?
  2. API isteğimiz bir hata döndürürse ne olur?
  3. API isteğimiz beklenen sonucu verirse ne olur?

İşte test grubumuz ve test fonksiyonu imzalarımız:

 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 {}); });

Testlerimizi yazmaya hazırız. Her bir test durumu için, ilgili konfigürasyona sahip bir DioMockResponsesAdapter örneği oluşturacağız:

 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()); }); });

Artık uç nokta testimiz tamamlandığına göre, veri kaynağı sınıfımızı UniversityRemoteDataSource test edelim. Daha önce, UniversityEndpoint sınıfının UniversityRemoteDataSource({UniversityEndpoint? universityEndpoint}) yapıcısının bir parçası olduğunu gözlemledik; bu, UniversityEndpoint UniversityRemoteDataSource kullandığını gösterir, bu nedenle alay edeceğimiz sınıf budur.

Mockito ile alay etmek

Önceki örneğimizde, özel bir NetworkInterceptor kullanarak Dio istemcimizin istek adaptörüyle manuel olarak alay ettik. Burada bütün bir sınıfla alay ediyoruz. Bunu manuel olarak yapmak (bir sınıfla ve işlevleriyle alay etmek) zaman alıcı olacaktır. Neyse ki, sahte kitaplıklar bu tür durumlarla başa çıkmak için tasarlanmıştır ve minimum çabayla sahte sınıflar oluşturabilir. Flutter'da alay etmek için endüstri standardı kitaplık olan mockito kitaplığını kullanalım.

Mockito ile alay etmek için, önce test kodunun önüne “ @GenerateMocks([class_1,class_2,…]) ” ek açıklamasını ekliyoruz— void main() {} işlevinin hemen üstüne. Açıklamada, parametre olarak sınıf adlarının bir listesini ekleyeceğiz ( class_1,class_2… yerine).

Ardından, testle aynı dizinde sahte sınıflarımız için kod üreten Flutter'ın flutter pub run build_runner build komutunu çalıştırıyoruz. Ortaya çıkan sahte dosyanın adı, testin .dart son ekinin yerini alacak şekilde test dosyası adı ile .mocks.dart birleşimi olacaktır. Dosyanın içeriği, adları Mock önekiyle başlayan sahte sınıfları içerecektir. Örneğin, UniversityEndpoint MockUniversityEndpoint olur.

Şimdi, university_remote_data_source_test.dart.mocks.dart (sahte dosyamız) university_remote_data_source_test.dart (test dosyası) aktarıyoruz.

Ardından, setUp işlevinde MockUniversityEndpoint kullanarak ve UniversityRemoteDataSource sınıfını başlatarak UniversityEndpoint ile alay edeceğiz:

 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); }); }

UniversityEndpoint ile başarıyla alay ettik ve ardından UniversityRemoteDataSource sınıfımızı başlattık. Artık test gruplarımızı tanımlamaya ve fonksiyon imzalarını test etmeye hazırız:

 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', () {}); });

Bununla alay, test gruplarımız ve test fonksiyon imzalarımız kurulur. Gerçek testleri yazmaya hazırız.

İlk testimiz, veri kaynağı ülke bilgilerini getirmeyi başlattığında UniversityEndpoint işlevinin çağrılıp çağrılmadığını kontrol eder. Her sınıfın işlevleri çağrıldığında nasıl tepki vereceğini tanımlayarak başlıyoruz. UniversityEndpoint sınıfıyla alay ettiğimiz için, when( function_that_will_be_called ).then( what_will_be_returned ) kod yapısını kullanarak bu sınıfla çalışacağız.

Test ettiğimiz işlevler eşzamansızdır (bir Future nesnesi döndüren işlevler), bu nedenle sonuçlarımızı değiştirmek için when(function name).thenanswer( (_) {modified function result} ) kod yapısını kullanacağız.

getUniversitiesByCountry işlevinin UniversityEndpoint sınıfındaki getUniversitiesByCountry işlevini çağırıp çağırmadığını kontrol etmek için, when(...).thenAnswer( (_) {...} ) kullanarak UniversityEndpoint sınıfındaki getUniversitiesByCountry işleviyle alay edeceğiz:

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

Yanıtımızla alay ettiğimize göre, veri kaynağı işlevini çağırır ve verify işlevini kullanarak UniversityEndpoint işlevinin çağrılıp çağrılmadığını kontrol ederiz:

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

Aynı ilkeleri, fonksiyonumuzun uç nokta sonuçlarımızı ilgili veri akışlarına doğru bir şekilde dönüştürüp dönüştürmediğini kontrol eden ek testler yazmak için kullanabiliriz:

 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) ]), ); }); }); }

Bir dizi Flutter birim testi gerçekleştirdik ve alay etmeye farklı yaklaşımlar gösterdik. Sizi ek testler yapmak için örnek Flutter projemi kullanmaya devam etmeye davet ediyorum.

Çarpıntı Birimi Testleri: Üstün UX İçin Anahtarınız

Flutter projelerinize zaten birim testini dahil ettiyseniz, bu makale iş akışınıza ekleyebileceğiniz bazı yeni seçenekler sunmuş olabilir. Bu eğitimde, birim testini bir sonraki Flutter projenize dahil etmenin ne kadar basit olacağını ve daha incelikli test senaryolarının zorluklarının nasıl üstesinden gelineceğini gösterdik. Flutter'da bir daha asla birim testlerini atlamak istemeyebilirsiniz.

Toptal Engineering Blog'un editör ekibi, bu makalede sunulan kod örneklerini ve diğer teknik içeriği gözden geçirdikleri için Matija Becirevic ve Paul Hoskins'e şükranlarını sunar.