RxJS Gözlemlenebilirlerini Test Etmenin Sanatı: Marble Test Yöntemi
RxJS (Reactive Extensions for JavaScript) modern web uygulamalarında asenkron veri akışlarını yönetmek için güçlü bir paradigmadır. Gözlemlenebilirler (Observables) sayesinde, zamanla değişen veya olay tabanlı verileri bildirimli (declarative) bir şekilde işleyebiliriz. Ancak, asenkron ve zaman bağımlı doğası gereği, RxJS gözlemlenebilirlerini test etmek geleneksel birim test yöntemleriyle zorlayıcı olabilir. İşte bu noktada “Marble Testing” (Mermer Test Yöntemi) devreye girer ve bize asenkron RxJS akışlarını deterministik ve okunabilir bir şekilde test etme imkanı sunar.
RxJS ve Gözlemlenebilirlerin Test Edilmesindeki Zorluklar
RxJS, asenkron programlamayı basitleştirmeyi amaçlayan bir kütüphanedir. Temel yapı taşı olan Gözlemlenebilirler, bir dizi değeri zamanla yayan (emit eden) yapılar olarak düşünülebilir. Bu değerler senkron veya asenkron olabilir. Bir gözlemlenebilir, sıfır veya daha fazla değeri yayabilir, ardından bir tamamlama (completion) sinyali gönderebilir veya bir hata (error) ile sonlanabilir.
Gözlemlenebilirlerin bu doğası, onları test etmeyi karmaşık hale getirir:
1. Asenkronluk: Geleneksel testler genellikle senkron kod üzerine kuruludur. setTimeout, setInterval veya Promise tabanlı asenkron kodları test etmek için test çatılarının (frameworks) asenkron yardımcılarını (done callback, async/await) kullanırız. Ancak RxJS akışları birden fazla asenkron olayı ve zamanlamayı içerebilir.
2. Zaman Bağımlılığı: delay, debounceTime, throttleTime, interval gibi operatörler, gözlemlenebilirin yayma davranışını doğrudan zamanla ilişkilendirir. Gerçek zamanı bekleyen testler yavaş çalışır ve “flaky” (kararsız) olabilir. jest.useFakeTimers() gibi yöntemler bir nebze yardımcı olsa da, RxJS’in karmaşık operatör zincirlerini ve iç içe asenkronlukları yönetmek hala zordur.
3. Yan Etkiler ve Durum Yönetimi: Gözlemlenebilirler genellikle yan etkileri (side effects) tetikler veya dış durumları etkiler. Bu yan etkilerin doğru zamanda ve doğru değerlerle gerçekleştiğinden emin olmak, testlerin kapsamını genişletir.
4. Abonelik ve Abonelikten Çıkma (Subscription/Unsubscription) Yönetimi: Özellikle “hot” (sıcak) gözlemlenebilirler veya yüksek seviyeli operatörler (mergeMap, switchMap vb.) kullanılırken, aboneliklerin ne zaman başladığını ve bittiğini izlemek önemlidir. Bellek sızıntılarını veya istenmeyen davranışları önlemek için abonelik yaşam döngüsünün doğru çalıştığından emin olmak gerekir.
Bu zorluklar karşısında, RxJS ekibi ve topluluğu, bu tür karmaşık senaryoları deterministik ve öngörülebilir bir şekilde test etmek için özel bir yaklaşım geliştirmiştir: Marble Testing.
Marble Diyagramları: RxJS Akışlarının Görsel Dili
Marble diyagramları, RxJS gözlemlenebilirlerinin zaman içindeki davranışını görsel olarak temsil eden bir notasyon sistemidir. Bu diyagramlar, hem test yazarken hem de RxJS operatörlerinin nasıl çalıştığını anlamaya çalışırken son derece faydalıdır. Her bir diyagram bir zaman çizelgesini temsil eder ve üzerindeki semboller belirli olayları gösterir.
Temel Marble Diyagramı Sembolleri:
* - (Tire): Bir “frame” veya “time unit” (zaman birimi) temsil eder. Varsayılan olarak her bir tire 1 milisaniyeye karşılık gelir, ancak bu TestScheduler tarafından yapılandırılabilir. Örneğin, --- üç zaman birimi geçtiğini gösterir.
* a, b, c, … (Harfler): Gözlemlenebilir tarafından yayılan değerleri temsil eder. Örneğin, --a--b-- gözlemlenebilirin iki zaman birimi sonra ‘a’ değerini, ardından iki zaman birimi sonra ‘b’ değerini yaydığını gösterir.
* | (Dikey Çizgi): Gözlemlenebilirin başarıyla tamamlandığını (complete) gösterir. Bu, gözlemlenebilirin artık başka bir değer yaymayacağı anlamına gelir. Örneğin, --a--b| ‘a’ ve ‘b’ değerlerini yaydıktan sonra tamamlanır.
* # (Kare/Pound İşareti): Gözlemlenebilirin bir hata (error) ile sonlandığını gösterir. Hata durumunda gözlemlenebilir tamamlanmaz. Örneğin, --a--# ‘a’ değerini yaydıktan sonra bir hata ile sonlanır.
* () (Parantezler): Aynı zaman birimi içinde senkron olarak yayılan birden fazla değeri gruplamak için kullanılır. Örneğin, (ab)c--| ‘a’ ve ‘b’ değerlerinin sıfır zaman gecikmesiyle aynı anda yayıldığını, ardından ‘c’ değerinin yayıldığını gösterir.
* ^ (Şapka İşareti): “Hot” (sıcak) gözlemlenebilirlerde, aboneliğin (subscription) ne zaman başladığını gösterir. Bu, genellikle expectSubscriptions ile kullanılır.
* ! (Ünlem İşareti): “Hot” gözlemlenebilirlerde, aboneliğin ne zaman sonlandığını (unsubscribed) gösterir.
Örnek bir diyagram:
Input: --a--b--c|
Output: --A--B--C| (map(x => x.toUpperCase()))
Bu diyagram, giriş gözlemlenebilirinin ‘a’, ‘b’, ‘c’ değerlerini belirli aralıklarla yaydığını ve ardından tamamlandığını, çıkış gözlemlenebilirinin ise bu değerleri büyük harfe dönüştürerek aynı zamanlamayla yaydığını gösterir.
TestScheduler: Sanal Zamanın Yöneticisi
RxJS’in test paketi (rxjs/testing), gözlemlenebilirleri test etmek için özel bir yardımcı sınıf olan TestScheduler‘ı sağlar. TestScheduler, asenkron RxJS kodunu senkron bir ortamda çalıştırarak testlerin deterministik ve hızlı olmasını sağlar. Bunu, sanal bir zaman çizelgesi yöneterek yapar. delay, debounceTime, interval gibi zaman tabanlı operatörler gerçek zamanı beklemek yerine, TestScheduler‘ın sanal zamanını kullanır.
TestScheduler‘ın temel çalışma prensibi şöyledir:
1. Sanal Zaman Kontrolü: Tüm zaman tabanlı operatörler, TestScheduler tarafından sağlanan sanal zamanı kullanacak şekilde otomatik olarak yama (patch)lanır. Bu sayede, delay(100) gibi bir operatör gerçekte 100ms beklemez, bunun yerine TestScheduler‘ın sanal zamanında 100 birim ilerler.
2. run() Metodu: Test mantığınızın çoğunu scheduler.run() metodunun içinde yazarsınız. Bu metot, kendisine verilen bir callback fonksiyonunu çalıştırır ve bu fonksiyon içindeki tüm RxJS operatörlerinin TestScheduler‘ı kullanmasını sağlar. run() metodu tamamlandığında, TestScheduler tüm sanal zaman işlemlerini tamamlamış olur.
3. expectObservable(): Bu metot, bir gözlemlenebilirin beklenen değerleri ve zamanlamasıyla eşleşip eşleşmediğini kontrol etmek için kullanılır. Giriş olarak bir marble diyagramı alır.
4. expectSubscriptions(): Özellikle “hot” gözlemlenebilirler veya yüksek seviyeli operatörler ile çalışırken, aboneliklerin ne zaman başlayıp ne zaman bittiğini kontrol etmek için kullanılır.
Marble Test Ortamının Kurulumu
Marble testleri genellikle Jest, Mocha gibi test çatılarının yanı sıra RxJS’in kendi TestScheduler‘ı ile birlikte kullanılır.
1. Bağımlılıklar:
RxJS zaten projenizde yüklü olmalıdır. TestScheduler direkt olarak rxjs/testing paketinden gelir.
npm install rxjs # Zaten yüklü değilse
2. Temel Test Yapısı:
import { TestScheduler } from 'rxjs/testing';
import { of, map, delay } from 'rxjs';
describe('RxJS Operatörleri Marble Testleri', () => {
let testScheduler: TestScheduler;
// Her testten önce yeni bir TestScheduler örneği oluşturulur
beforeEach(() => {
testScheduler = new TestScheduler((actual, expected) => {
// Bu callback, expectObservable tarafından çağrılır
// ve beklenen ile gerçek değerleri karşılaştırır.
expect(actual).toEqual(expected);
});
});
it('should map values correctly', () => {
testScheduler.run(({ cold, expectObservable }) => {
// cold() metodu bir "cold" gözlemlenebilir oluşturur.
// Bu gözlemlenebilir, her abone olunduğunda baştan başlar.
const source = cold('-a--b--c|', { a: 1, b: 2, c: 3 });
const expected = '-A--B--C|'; // Beklenen çıktı marble diyagramı
const result = source.pipe(map(x => String.fromCharCode(x + 64))); // 1->A, 2->B, 3->C
// expectObservable, gözlemlenebilirin beklenen marble diyagramıyla eşleştiğini doğrular.
expectObservable(result).toBe(expected, { A: 'A', B: 'B', C: 'C' });
});
});
it('should delay values', () => {
testScheduler.run(({ cold, expectObservable }) => {
const source = cold('a|', { a: 1 });
const expected = '---a|'; // 2 birim gecikme
const result = source.pipe(delay(2, testScheduler)); // testScheduler'ı operatöre geçirmek önemli
expectObservable(result).toBe(expected, { a: 1 });
});
});
});
Yukarıdaki örnekte testScheduler.run() metoduna bir obje destructuring ile cold ve expectObservable gibi yardımcı fonksiyonlar geçildiğini görüyoruz. Bunlar TestScheduler‘ın run metodunun sağladığı kolaylaştırıcılardır:
* cold(marble, values, error): Soğuk (cold) gözlemlenebilir oluşturur.
* hot(marble, values, error): Sıcak (hot) gözlemlenebilir oluşturur.
* expectObservable(observable, subscriptionMarbles): Bir gözlemlenebilirin davranışını doğrular.
* expectSubscriptions(actualSubscriptionMarbles): Aboneliklerin yaşam döngüsünü doğrular.
* flush(): Tüm bekleyen sanal zaman işlemlerini hemen tamamlar.
* time(marble): Bir marble string’indeki zaman birimini hesaplar.
Pratik Marble Test Örnekleri
Şimdi çeşitli RxJS operatörlerini ve senaryolarını marble testlerle nasıl test edebileceğimize dair daha fazla örnek inceleyelim.
Cold Observables (Soğuk Gözlemlenebilirler)
Soğuk gözlemlenebilirler, her abone olunduğunda veri akışını baştan başlatan gözlemlenebilirlerdir (örn: of, from, ajax).
import { TestScheduler } from 'rxjs/testing';
import { of, map, filter, delay, mergeMap } from 'rxjs';
describe('Cold Observables Marble Tests', () => {
let testScheduler: TestScheduler;
beforeEach(() => {
testScheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
});
it('should emit values from "of"', () => {
testScheduler.run(({ expectObservable }) => {
const source = of(1, 2, 3);
// of operatörü senkron olduğu için değerler aynı anda yayılır.
const expected = '(abc)|';
expectObservable(source).toBe(expected, { a: 1, b: 2, c: 3 });
});
});
it('should filter values', () => {
testScheduler.run(({ cold, expectObservable }) => {
const source = cold('-a-b-c|', { a: 1, b: 2, c: 3 });
const expected = '---b--|'; // Sadece çift sayıları filtrele
const result = source.pipe(filter(x => x % 2 === 0));
expectObservable(result).toBe(expected, { b: 2 });
});
});
it('should map and delay values', () => {
testScheduler.run(({ cold, expectObservable }) => {
const source = cold('-a--b|', { a: 1, b: 2 });
// Her değer 3 birim gecikmeli ve maplenmiş olarak yayılır.
// 'a' (1) 1. birimde yayılır, 3 birim gecikme ile 4. birimde 'A' olarak çıkar.
// 'b' (2) 4. birimde yayılır, 3 birim gecikme ile 7. birimde 'B' olarak çıkar.
const expected = '----A--B|';
const result = source.pipe(
map(x => String.fromCharCode(x + 64)),
delay(3, testScheduler)
);
expectObservable(result).toBe(expected, { A: 'A', B: 'B' });
});
});
it('should handle mergeMap with inner observable delays', () => {
testScheduler.run(({ cold, expectObservable }) => {
const source = cold('-a--b|', { a: 1, b: 2 });
// mergeMap içindeki gözlemlenebilirler eş zamanlı çalışır.
// a (1. birim) -> inner observable (3 birim gecikme) -> A (4. birim)
// b (4. birim) -> inner observable (3 birim gecikme) -> B (7. birim)
const expected = '----A--B|';
const result = source.pipe(
mergeMap(x => of(String.fromCharCode(x + 64)).pipe(delay(3, testScheduler)))
);
expectObservable(result).toBe(expected, { A: 'A', B: 'B' });
});
});
});
Hot Observables (Sıcak Gözlemlenebilirler)
Sıcak gözlemlenebilirler, verilerini abone olup olmamasına bakılmaksızın yaymaya başlayan gözlemlenebilirlerdir (örn: Subject, shareReplay ile oluşturulan gözlemlenebilirler, DOM olayları). hot() ve expectSubscriptions() metodları sıcak gözlemlenebilirleri test etmek için kritik öneme sahiptir.
import { TestScheduler } from 'rxjs/testing';
import { Subject, shareReplay } from 'rxjs';
describe('Hot Observables Marble Tests', () => {
let testScheduler: TestScheduler;
beforeEach(() => {
testScheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
});
it('should test a Subject', () => {
testScheduler.run(({ hot, expectObservable, expectSubscriptions }) => {
const subject = new Subject();
// hot gözlemlenebilir, subject'in ne zaman değer yaydığını simüle eder.
// 10. birimde 'a', 20. birimde 'b' yayar.
const hotInput = '----------a---------b--|';
// Aboneliğin ne zaman başlayıp bittiğini belirtiyoruz.
// 5. birimde abone ol, 25. birimde abonelikten çık.
const sub = '-----^------------------!';
const expected = '----------a---------b--'; // Abonelik 25. birimde bittiği için complete sinyali alınmaz.
// hot gözlemlenebilirin subject'e abone olmasını simüle et.
// Bu, subject'in değerleri yaymasını sağlar.
expectObservable(hot(hotInput)).subscribe(subject);
// Subject'e abone ol ve çıktıyı doğrula.
expectObservable(subject, sub).toBe(expected, { a: 'a', b: 'b' });
// Subject'e olan aboneliklerin doğru şekilde yönetildiğini kontrol et.
expectSubscriptions(subject.subscriptions).toBe(sub);
});
});
it('should test shareReplay operator', () => {
testScheduler.run(({ cold, hot, expectObservable, expectSubscriptions }) => {
const source = cold('-a--b--c|', { a: 1, b: 2, c: 3 });
const shared = source.pipe(shareReplay(1, undefined, testScheduler)); // testScheduler'ı geçmeyi unutmayın
// Abonelik 5. birimde başlar, 15. birimde biter.
const sub1 = '-----^----------!';
// Abonelik 10. birimde başlar, 20. birimde biter.
const sub2 = '----------^---------!';
// İlk abone (sub1) 5. birimde abone olur, 'a' değerini 6. birimde alır, 'b' değerini 9. birimde alır.
// 15. birimde abonelikten çıkar. 'c' değerini görmez.
const expected1 = '------a--b----';
// İkinci abone (sub2) 10. birimde abone olur. sharedReplay buffer'ında 'b' değeri vardır.
// Bu yüzden 'b' değerini hemen 10. birimde alır. Ardından 'c' değerini 12. birimde alır.
const expected2 = '----------b-c----';
// Source'a olan aboneliğin yaşam döngüsü.
// İlk abone 5. birimde abone olduğunda source'a abone olunur.
// İkinci abone 10. birimde abone olsa da source zaten aktiftir.
// En son abone 20. birimde abonelikten çıktığında (veya buffer boşaldığında) source'dan abonelikten çıkılır.
// shareReplay(1) olduğu için, buffer'da bir değer olduğu sürece source'a olan abonelik devam eder.
// Burada 20. birimde sub2 abonelikten çıkınca, artık kimse dinlemediği için source'dan da çıkılır.
const sourceSubs = '-----^--------------!';
expectObservable(shared, sub1).toBe(expected1, { a: 1, b: 2 });
expectObservable(shared, sub2).toBe(expected2, { b: 2, c: 3 });
// shared gözlemlenebilirin içindeki source'a olan abonelikleri kontrol et.
expectSubscriptions(source.subscriptions).toBe(sourceSubs);
});
});
});
Hata ve Tamamlama Senaryoları
Hata (#) ve tamamlama (|) sembolleri, gözlemlenebilirlerin yaşam döngüsünün sonlanma durumlarını test etmek için kullanılır.
import { TestScheduler } from 'rxjs/testing';
import { of, throwError, catchError } from 'rxjs';
describe('Error and Completion Marble Tests', () => {
let testScheduler: TestScheduler;
beforeEach(() => {
testScheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
});
it('should complete successfully', () => {
testScheduler.run(({ cold, expectObservable }) => {
const source = cold('-a--b|');
const expected = '-a--b|';
expectObservable(source).toBe(expected);
});
});
it('should emit an error', () => {
testScheduler.run(({ cold, expectObservable }) => {
const source = cold('-a--#');
const expected = '-a--#';
expectObservable(source).toBe(expected);
});
});
it('should catch an error and resume', () => {
testScheduler.run(({ cold, expectObservable }) => {
const source = cold('-a--#', null, new Error('Test Error')); // Hata objesi de tanımlanabilir
// catchError, hata durumunda yeni bir gözlemlenebilir döndürür.
// Burada hata yakalandıktan sonra 'b' değeri yayılır ve tamamlanır.
const expected = '-a--(b|)'; // 'b' değeri hata yakalandıktan hemen sonra senkron yayılır.
const result = source.pipe(
catchError(err => of('b')) // Hata yakalandığında 'b' yay ve tamamla
);
expectObservable(result).toBe(expected, { a: undefined, b: 'b' }); // 'a' değeri undefined çünkü sadece marble string'de 'a' olarak geçiyor
});
});
});
Marble Testlerin Faydaları
Marble testler, RxJS gözlemlenebilirlerini test ederken birçok avantaj sunar:
1. Deterministik Testler: Sanal zaman kontrolü sayesinde, asenkron kodun davranışını her zaman aynı şekilde garanti eder. Gerçek zaman gecikmeleri olmadığı için testler “flaky” olmaz.
2. Okunabilirlik ve Anlaşılabilirlik: Marble diyagramları, karmaşık zamanlamaları ve olay akışlarını görsel bir dille ifade eder. Bu, test senaryolarını okumayı ve anlamayı kolaylaştırır. RxJS operatörlerinin nasıl çalıştığını anlamak için de harika bir araçtır.
3. Hızlı Test Yürütme: Gerçek zaman beklemeleri ortadan kalktığı için testler çok daha hızlı çalışır. Bu, büyük projelerde geliştirme döngüsünü hızlandırır.
4. Zaman Tabanlı Hataları Ayıklama: Gözlemlenebilirlerin zamanlamasıyla ilgili sorunları kolayca tespit etmeyi sağlar. Hangi değerin ne zaman yayılması gerektiğini açıkça görebilirsiniz.
5. Daha İyi Tasarım: Marble testleri yazarken, gözlemlenebilir akışlarınızı daha modüler ve test edilebilir bir şekilde tasarlamaya teşvik eder.
Sınırlamalar ve Dikkat Edilmesi Gerekenler
Marble testler güçlü olsa da, bazı sınırlamaları vardır:
* Öğrenme Eğrisi: Marble diyagramı sözdizimi ve TestScheduler API’si başlangıçta biraz öğrenme gerektirebilir.
* Karmaşık Diyagramlar: Çok karmaşık veya uzun gözlemlenebilir akışları için marble diyagramları da okunması zor hale gelebilir. Bu tür durumlarda, testleri daha küçük, yönetilebilir birimlere bölmek en iyisidir.
* Gerçek Dünya Asenkronluğu: Marble testler, gerçek dünya asenkronluğunu (örn: ağ istekleri, UI animasyonları) simüle etmek için tasarlanmamıştır. Bu tür senaryolar için genellikle mocklama (sahte nesneler kullanma) ve entegrasyon testleri daha uygundur. Marble testler daha çok saf RxJS mantığını test etmek için idealdir.
En İyi Uygulamalar
* Küçük ve Odaklı Testler: Her bir testin belirli bir operatörün veya küçük bir operatör zincirinin davranışını doğrulamasına odaklanın.
* Açıklayıcı Değerler: Marble diyagramlarında kullanılan değerler için açıklayıcı harfler veya sayılar kullanın ve values objesinde bu değerleri net bir şekilde tanımlayın.
* TestScheduler‘ı Operatörlere Geçirme: delay, debounceTime gibi zaman tabanlı operatörleri kullanırken, TestScheduler örneğini ikinci argüman olarak geçirmeyi unutmayın (delay(100, testScheduler)). Aksi takdirde, operatör gerçek zamanı kullanmaya çalışır.
* expectSubscriptions Kullanımı: Özellikle hot gözlemlenebilirler ve yüksek seviyeli operatörlerle çalışırken, aboneliklerin doğru şekilde yönetildiğini doğrulamak için expectSubscriptions kullanmaktan çekinmeyin.
* Diğer Test Yöntemleriyle Birleştirme: Marble testleri, RxJS akışlarının zamanlama ve veri dönüşüm mantığını test etmek için harikadır. Ancak, uygulamanızın diğer kısımlarını (örn: UI etkileşimleri, servis entegrasyonları) test etmek için geleneksel birim testlerini, entegrasyon testlerini ve uçtan uca (end-to-end) testleri kullanmaya devam edin.
Sonuç
RxJS gözlemlenebilirleri, modern reaktif uygulamalar geliştirmek için inanılmaz derecede güçlü bir araçtır. Ancak, asenkron ve zaman bağımlı doğaları nedeniyle onları test etmek zorlu olabilir. Marble Testing, bu zorluğun üstesinden gelmek için tasarlanmış, görsel, deterministik ve etkili bir yaklaşımdır. TestScheduler ve marble diyagramları sayesinde, RxJS akışlarınızın beklenen şekilde çalıştığından emin olabilir, hataları daha kolay tespit edebilir ve kodunuzun kalitesini önemli ölçüde artırabilirsiniz. RxJS projelerinizde Marble Testing’i benimsemek, daha sağlam, bakımı kolay ve güvenilir uygulamalar oluşturmanıza yardımcı olacaktır.
