github icontwitter icon

Test Test Diye Nice Nice: Mocklama (3) - Timer

Author imageEnes Başpınar /18 Kas 2022
15 min read •––– views

Bölümler

İçerik

Evren yaratıldığı anda gizli bir zamanlayıcı saymaya başladı. Bu uçsuz bucaksız düzlemde her birimize bir şerit ayrıldı ve yaşadığımız tüm olaylar bu şeride yazılmaya başlandı. Bazen anılarımız kesişti ve şeritler birleşti, bazense ummadığımız kadar uzaklaştılar birbirlerinden... Bu ne alaka diyebilirsiniz aniden zihnimde belirince dümenden paylaştım. Garip blog dünyama hoşgeldiniz.

Günlük hayatımızda bir takım işlerimizi zamanlayıcılara göre yaparız. 40 dk sonra yemek yiyecek ya da 24 saatte bir ilaç içecek olabiliriz. Kodlarımızdaki eylemleri yapmak için de setTimeout() ve setInterval() gibi zamanlayıcı fonksiyonlardan faydalanırız. Bu makalede zamanlayıcı fonksiyonların mocklanış biçimlerine değineceğiz.

Makaleye güzel bir arkaplan müziğinin eşlik etmesini isteyenlere fazlaca dinlediğim keman virtüözü Farid Farjad'ı takdim ediyorum:

Yazının kodlarına Github üzerinden erişebilirsiniz.

Girizgah

Her zamanki gibi konunun temeline inmek için bir örneği baz alacağız. Parametre olarak aldığı fonksiyonu 3 saniye sonra çalıştıran bol konsollu bir fonksiyonumuz olduğunu varsayalım.

callAfterThreeSeconds.ts

_15
export default function callAfterThreeSeconds(callback: () => void) {
_15
console.log("setTimeout çalışmadan önce");
_15
_15
setTimeout(() => {
_15
console.log("callback çalışmadan önce");
_15
callback();
_15
console.log("callback çalıştıktan sonra");
_15
}, 3000);
_15
_15
console.log("setTimeout çalıştıktan sonra");
_15
}
_15
_15
callAfterThreeSeconds(() => {
_15
console.log("callback çalıştı");
_15
});

=== Output:

setTimeout çalışmadan önce setTimeout çalıştıktan sonra callback çalışmadan önce callback çalıştı callback çalıştıktan sonra

Fonksiyon çalıştırılır, callback fonksiyonu event loop'a eklenir ve 3 saniye tamamlanınca çalıştırılır. Bu akışı aklımızın bir köşesinde tutarak elimizdeki bilgilerle test yazalım. Hatırlayacağımız üzere jest.spyOn ile objeler üzerinden metodları mockluyorduk. Built-in metodlar için global objesini kullanabiliriz.

Testlerimizi bozmaması için 60-62. satırları silmeyi unutmayın. Aksi halde testte bir kez çağırılmasını bekleyip iki kez çağırıldığını görürsünüz. Event loop hakkındaki bilginizi tazelemek isterseniz de şu videoya göz atabilirsiniz: Olay döngüsü nedir? | Philip Roberts | JSConf AB

callAfterThreeSeconds.test.ts
callAfterThreeSeconds.ts

_11
import callAfterThreeSeconds from "./callAfterThreeSeconds";
_11
_11
jest.spyOn(global, "setTimeout");
_11
_11
test("should call callback after 3 second", () => {
_11
const mockCallback = jest.fn();
_11
callAfterThreeSeconds(mockCallback);
_11
_11
expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 3000);
_11
expect(mockCallback).toHaveBeenCalled();
_11
});

=== Output:

console.log setTimeout çalışmadan önce setTimeout çalıştıktan sonra

FAIL src/playground/index.test.ts ✕ should call callback after 3 second

expect(jest.fn()).toHaveBeenCalled()

Expected number of calls: >= 1 Received number of calls: 0

İlk expect başarıyla geçer ancak ikincisinde fonksiyonun tamamlanması beklenmediği için test başarısız olur. Fonksiyonun 3 saniye sonra tamamlanacağını belirtmemiz gerekir.


_14
import callAfterThreeSeconds from "./callAfterThreeSeconds";
_14
_14
jest.spyOn(global, "setTimeout");
_14
_14
test("should call callback after 3 second", (done) => {
_14
const mockCallback = jest.fn();
_14
callAfterThreeSeconds(mockCallback);
_14
_14
setTimeout(() => {
_14
expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 3000);
_14
expect(mockCallback).toHaveBeenCalled();
_14
done();
_14
}, 3000);
_14
});

=== Output:

PASS src/playground/index.test.ts ✓ should call callback after 3 second

Anlaşılacağı üzere async fonksiyonların temelinde basit bir matematik var: Kod bekliyorsa test de beklesin. Callback 3 saniye sonra çalışacağından ötürü expect'i de 3 saniye sonra çalıştırırsak çağrıldığından emin olabiliriz. Testin callback'ine iletilen done metodu çağrılmadığı takdirde testin sona erdiğini anlayamaz.

Ancaaak, burada bir problem var. Bekleyeceğimiz süre 3 saniye değil de 24 saat falan olsaydı ne olacaktı?

Bununla ancak günü kurtarırız. Gerçek bir çözüme geçelim.

Fake Timer

JS'de zamanlayıcı metodların baz aldığı bir timer bulunur. Testlerde bunu mocklamak için jest.useFakeTimers(fakeTimersConfig?) metodu kullanılır. Nereden çağrıldığı farketmeksizin tüm dosyayı etkiler ve her çağrıldığında önceki zamanlayıcılara ait bilgileri temizler. Kodun bir kısmında gerçek zamanlayıcıya geri dönmek istersek jest.useRealTimers() kullanabiliriz.

useTimers.test.ts

_13
test("playground", () => {
_13
jest.useFakeTimers();
_13
console.log("FAKES\n-------");
_13
console.log("setTimeout():", setTimeout.toString());
_13
console.log("setInterval():", setInterval.toString());
_13
console.log("Date.now():", Date.now.toString());
_13
_13
jest.useRealTimers();
_13
console.log("REALS\n-------");
_13
console.log("setTimeout():", setTimeout.toString());
_13
console.log("setInterval():", setInterval.toString());
_13
console.log("Date.now():", Date.now.toString());
_13
});

=== Output:

FAKES ------- setTimeout(): function () { return clock[method].apply(clock, arguments); } setInterval(): function () { return clock[method].apply(clock, arguments); } Date.now(): function now() { return target.clock.now; } REALS ------- setTimeout(): function (handler, timeout = 0, ...args) { if (typeof handler !== "function") { handler = webIDLConversions.DOMString(handler); } timeout = webIDLConversions.long(timeout); return timerInitializationSteps(handler, timeout, args, { methodContext: ``window, repeat: false }); } setInterval(): function (handler, timeout = 0, ...args) { if (typeof handler !== "function") { handler = webIDLConversions.DOMString(handler); } timeout = webIDLConversions.long(timeout); return timerInitializationSteps(handler, timeout, args, { methodContext: ``window, repeat: true }); } Date.now(): function now() { [native code] }

Sahte zamanlayıcıyı özelleştirmek istersek aşağıdaki değerleri alabilen bir obje sağlayabiliriz:

  • advanceTimers (boolean | number) - gerçek zamanlayıcıya göre ne kadar hızlı ilerleyeceğini belirtebiliriz. true ayarlanırsa gerçekte 1ms ilerleniyorsa fake timer'da 1ms ilerler. Bir sayı iletirsek gerçekteki 1ms'e karşılık o sayı kadar daha hızlı ilerler.
  • doNotFake (Array) - fake timer kullanmak istemediğimiz metodların listesi.
  • now (number | Date) - fake timerların kullanacağı sistem tarihi. Varsayılan olarak Date.now() değerini yani günümüz tarihini alır.
  • timerLimit - az sonra değineceğimiz jest.runAllTimers() ile çalıştırabileceğimiz maksimum timer sayısı.
customUseTimers.test.ts

_10
jest.useFakeTimers({
_10
now: new Date(1999, 2, 21),
_10
doNotFake: ["setInterval"],
_10
});
_10
_10
test("playground", () => {
_10
console.log("setTimeout():", setTimeout.toString());
_10
console.log("setInterval():", setInterval.toString());
_10
console.log("Date.now():", new Date(Date.now()));
_10
});

=== Output:

setTimeout(): function () { return clock[method].apply(clock, arguments); } setInterval(): function (handler, timeout = 0, ...args) { if (typeof handler !== "function") { handler = webIDLConversions.DOMString(handler); } timeout = webIDLConversions.long(timeout);

return timerInitializationSteps(handler, timeout, args, { methodContext: window, repeat: true }); } Date.now(): 1999-03-20T22:00:00.000Z

Callback'i Beklemeksizin Çalıştırmak

Zamanlayıcı metodlarının callback'leri event loop'da kuyruğa alınır ve süresi tamamlandığında kuyruktan çıkarılır ve çalıştırılır. Testlerde timeout'u es geçip callback'i anında çalıştırabiliriz. setTimeout() ile yazdığımız testi tekrardan görelim.

callAfterThreeSeconds.test.ts

_14
import callAfterThreeSeconds from "./callAfterThreeSeconds";
_14
_14
jest.spyOn(global, "setTimeout");
_14
_14
test("should call callback after 3 second", (done) => {
_14
const mockCallback = jest.fn();
_14
callAfterThreeSeconds(mockCallback);
_14
_14
setTimeout(() => {
_14
expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 3000);
_14
expect(mockCallback).toHaveBeenCalled();
_14
done();
_14
}, 3000);
_14
});

Süre az gözükse bile 3 saniye 3 saniye birike birike pipeline olur size 1.5 saat. Sonra deployment yolu gözlersiniz. Dolayısıyla hiç bu işlere girmeden callback'leri sıralarını baz alarak timeout'suz çalıştırmak için jest.runAllTimers() metodunu kullanabiliriz.

Test esnasında tamamlanmayı bekleyen timer sayısını görmek için jest.getTimerCount() metodunu kullanabiliriz.

callAfterThreeSeconds.test.ts

_21
import callAfterThreeSeconds from "./callAfterThreeSeconds";
_21
_21
jest.useFakeTimers();
_21
jest.spyOn(global, "setTimeout");
_21
_21
test("should wait 10 second before call callback", () => {
_21
const mockCallback = jest.fn();
_21
_21
console.log("kalan fake timer sayısı:", jest.getTimerCount());
_21
callAfterThreeSeconds(mockCallback);
_21
console.log("kalan fake timer sayısı:", jest.getTimerCount());
_21
_21
// runAllTimers öncesinde timer'lar sona ermediği
_21
// için callback'in çağrılmadığından emin olalım.
_21
expect(mockCallback).not.toHaveBeenCalled();
_21
_21
jest.runAllTimers();
_21
console.log("kalan fake timer sayısı:", jest.getTimerCount());
_21
_21
expect(mockCallback).toHaveBeenCalled();
_21
});

=== Output:

kalan fake timer sayısı: 0 setTimeout çalışmadan önce setTimeout çalıştıktan sonra kalan fake timer sayısı: 1 callback çalışmadan önce callback çalıştıktan sonra kalan fake timer sayısı: 0

PASS src/playground/index.test.ts ✓ should wait 10 second before call callback (29 ms)

Test başarıyla geçecektir. Zamanlayıcıların timeout'larını sıfırlamak için iki ana metodumuz bulunur:

  • jest.runAllTicks() - micro-task (process.nextTick) kuyruğunda bulunan ve bunlardan türeyen callback'leri çalıştırır.
  • jest.runAllTimers() - macro-task (setTimeout, setInterval, setImmediate) kuyruğunda bulunan ve bunlardan türeyen callback'leri çalıştırır.

Micro-macro görevler konusunda kafanızda soru işareti varsa sizi yine şu videoya alalım.

Yalnızca Halihazırda Kuyrukta Bulunan Callback'leri Çalıştırma

Önceki başlıkta bahsettiğimiz üzere jest.runAllTimers() metodu, fonksiyon kuyruktaki macro-task'ların callback'lerini çalıştırır. Türetilenler varsa onları da sırasıyla kuyruğa ekleyerek kuyruk boşalana kadar devam eder.


_29
function callbackRunner() {
_29
setTimeout(() => {
_29
console.log("Called callback 1.");
_29
_29
setTimeout(() => {
_29
console.log("Called child callback 1.");
_29
}, 5000);
_29
}, 3000);
_29
_29
setTimeout(() => {
_29
console.log("Called callback 2.");
_29
_29
setTimeout(() => {
_29
console.log("Called child callback 2.");
_29
_29
setTimeout(() => {
_29
console.log("Called childest callback 1.");
_29
}, 1000);
_29
}, 1000);
_29
}, 3000);
_29
}
_29
_29
test("playground", () => {
_29
jest.useFakeTimers();
_29
_29
callbackRunner();
_29
_29
jest.runAllTimers();
_29
});

=== Output:

Called callback 1. Called callback 2. Called child callback 2. Called childest callback 1. Called child callback 1.

Örnekteki event loop akışını görselleştirmek isterseniz JavaScript Visualizer 9000 sitesine eklediğim örneğe göz atabilirsiniz.

jest.runOnlyPendingTimers() metoduysa yalnızca o anda macro-task kuyruğunda bulunan callback'leri çalıştırır. Callback'lerden türetilmiş macro-task'lara dokunmaz.


_29
function callbackRunner() {
_29
setTimeout(() => {
_29
console.log("Called callback 1.");
_29
_29
setTimeout(() => {
_29
console.log("Called child callback 1.");
_29
}, 5000);
_29
}, 3000);
_29
_29
setTimeout(() => {
_29
console.log("Called callback 2.");
_29
_29
setTimeout(() => {
_29
console.log("Called child callback 2.");
_29
_29
setTimeout(() => {
_29
console.log("Called childest callback 1.");
_29
}, 1000);
_29
}, 1000);
_29
}, 3000);
_29
}
_29
_29
test("playground", () => {
_29
jest.useFakeTimers();
_29
_29
callbackRunner();
_29
_29
jest.runOnlyPendingTimers();
_29
});

=== Output:

Called callback 1. Called callback 2.

Zamanlayıcıyı Belirli Süre İlerleterek Callback'leri Çalıştırma

Tüm zamanlayıcıların süresini sıfırlamak istemiyorsak ikinci seçenek olarak zamanlayıcıyı belirli süre ilerletebiliriz. Örnekteki sahte zamanlayıcıyı 4000ms ilerleterek çıktıyı analiz edelim.


_33
function callbackRunner() {
_33
setTimeout(() => {
_33
console.log("Called callback 1."); // 3000ms ✓
_33
_33
setTimeout(() => {
_33
console.log("Called child callback 1."); // 3000ms + 5000ms = 8000ms ⨉
_33
}, 5000);
_33
}, 3000);
_33
_33
setTimeout(() => {
_33
console.log("Called callback 2."); // 3000ms ✓
_33
_33
setTimeout(() => {
_33
console.log("Called child callback 2."); // 3000ms + 1000ms = 4000ms ✓
_33
_33
setTimeout(() => {
_33
console.log("Called childest callback 1."); // 3000ms + 1000ms + 1000ms = 5000ms ⨉
_33
}, 1000);
_33
}, 1000);
_33
_33
setTimeout(() => {
_33
console.log("Called child callback 3."); // 3000ms + 1000ms = 4000ms ✓
_33
}, 1000);
_33
}, 3000);
_33
}
_33
_33
test("playground", () => {
_33
jest.useFakeTimers();
_33
_33
callbackRunner();
_33
_33
jest.advanceTimersByTime(4000);
_33
});

=== Output:

Called callback 1. Called callback 2. Called child callback 2. Called child callback 3.

Kümülatif olarak toplandığında verilen süre içerisinde çalıştırılan timerların callback'leri timeout beklemeksizin çalıştırılır.

Callback'leri Adım Adım Çalıştırma

Son yöntem ise callback'lerin adım adım çalıştırılmasını sağlayan jest.advanceTimersToNextTimer(step?) metodudur.


_38
function callbackRunner() {
_38
setTimeout(() => {
_38
console.log("Called callback 1.");
_38
_38
setTimeout(() => {
_38
console.log("Called child callback 1.");
_38
}, 5000);
_38
}, 3000);
_38
_38
setTimeout(() => {
_38
console.log("Called callback 2.");
_38
_38
setTimeout(() => {
_38
console.log("Called child callback 2.");
_38
_38
setTimeout(() => {
_38
console.log("Called childest callback 1.");
_38
}, 1000);
_38
}, 1000);
_38
_38
setTimeout(() => {
_38
console.log("Called child callback 3.");
_38
}, 1000);
_38
}, 3000);
_38
}
_38
_38
test("playground", () => {
_38
jest.useFakeTimers();
_38
_38
callbackRunner();
_38
_38
console.log("Step 1:");
_38
jest.advanceTimersToNextTimer();
_38
console.log("Step 2:");
_38
jest.advanceTimersToNextTimer();
_38
console.log("Step 3:");
_38
jest.advanceTimersToNextTimer();
_38
});

=== Output:

Step 1: Called callback 1. Called callback 2. Step 2: Called child callback 2. Called child callback 3. Step 3: Called childest callback 1.

Görebileceğimiz gibi önce bir setTimeout'un callback'ini ardından içindekini ve ardından onun içindeki çalıştırarak ilerleyebiliriz. Teker teker ilerlemek yerine birden fazla adımı tek seferde atmak istersek step parametresini kullanabiliriz.


_34
function callbackRunner() {
_34
setTimeout(() => {
_34
console.log("Called callback 1.");
_34
_34
setTimeout(() => {
_34
console.log("Called child callback 1.");
_34
}, 5000);
_34
}, 3000);
_34
_34
setTimeout(() => {
_34
console.log("Called callback 2.");
_34
_34
setTimeout(() => {
_34
console.log("Called child callback 2.");
_34
_34
setTimeout(() => {
_34
console.log("Called childest callback 1.");
_34
}, 1000);
_34
}, 1000);
_34
_34
setTimeout(() => {
_34
console.log("Called child callback 3.");
_34
}, 1000);
_34
}, 3000);
_34
}
_34
_34
test("playground", () => {
_34
jest.useFakeTimers();
_34
_34
callbackRunner();
_34
_34
console.log("Step 1-2: callbacks");
_34
jest.advanceTimersToNextTimer(2);
_34
});

=== Output:

Step 1-2: Called callback 1. Called callback 2. Called child callback 2. Called child callback 3.

Ekstra Metodlar

Son olarak üç minik metoddan bahsedelim:

  • jest.now() - o anki tarihi ms olarak döndürür. Kullanılıyor ise fake zamanlayıcı değerini, aksi halde gerçek zamanlayıcı değerini verir.
  • jest.setSystemTime(now?: number | Date) - test içerisinde sistem tarihini değiştirir.
  • jest.getRealSystemTime() - günümüz tarihini verir.

_8
test("playground", () => {
_8
jest.useFakeTimers();
_8
_8
jest.setSystemTime(new Date(1999, 2, 21));
_8
_8
console.log("gerçek tarih:", new Date(jest.getRealSystemTime()));
_8
console.log("sahte tarih:", new Date(jest.now()));
_8
});

=== Output:

gerçek tarih (orjinal timer'a göre): 2022-11-15T03:32:20.747Z sahte tarih (fake timer'a göre): 1999-03-20T22:00:00.000Z

Pratik

Sona gelirken testini yazmanızı beklediğim bir fonksiyon bırakıyorum. Çıktıdan case'leri çıkararak kendi kendinize yazmanızı tavsiye ederim çünkü çok fazle şey öğrenilen anlar genellikle en çok zorlanılan zamanlar oluyor. Takılırsanız Github'dan çözümüne göz atabilirsiniz.

breakReminder.ts

_17
function breakReminder(breakActivity: any) {
_17
console.log("Starting working...");
_17
let breakCount = 0;
_17
_17
const breakTimer = setInterval(() => {
_17
if (breakCount > 2) {
_17
clearInterval(breakTimer);
_17
console.log("Ending working.");
_17
} else {
_17
breakActivity();
_17
}
_17
_17
breakCount += 1;
_17
}, 3000);
_17
}
_17
_17
export default breakReminder;

=== Output:

PASS src/playground/utils.test.ts breakReminder() tests ✓ should start work on first iteration (3 ms) ✓ should call breakActivity on second, third and fourth iteration (1 ms) ✓ should end work on last iteration (6 ms)

Kapanış

Kısa ve öz bir bölüm oldu. Timer'ların nasıl tepki verdiğini özümsemek için kod üzerinde bol bol denemenizi tavsiye ederim.

Yazı burada biter. Sağlıcakla kalın.

Kaynaklar

2022 © No rights are reserved.Inspired by Lee Robinson's blog.