“Unit Test” yazı dizisinin bir önceki yazısında neden unit test yazarız sorusuna cevaplar arayıp unit testlerin faydalarına değinmiştik. İkna olduysak artık test yazmaya başlayabiliriz. Peki unit test nasıl yazılmalı? Unit test yazarken nelere dikkat etmeliyiz? Unit testlerimizin kaliteli, faydalı ve amaca yönelik olması için takip etmemiz gereken kurallar nelerdir? Bu yazıda da konunun bu boyutunu ele alıyor olacağız.

Küçük Testler Yazalım

Unit testler olabildiğince küçük kod parçaları olmalılar. Hata veren bir unit test çok hızlı bir şekilde okunabilmeli, testteki veri mi yoksa test edilen kod mu sıkıntılı çabucak anlaşılabilir olmalıdır.

Ayrıca uzun test fonksiyonları küçük değişikliklerden daha sık etkilenirler ve bu test kodlarında sonradan değişiklik yapmak da zorlaşır. Bu da kodun bakım maliyetinin yanında testlerin de bakım maliyetlerini arttırır.

Bağımlılıklardan İzole Edelim

Unit testi “unit” yapan şey sadece ve sadece test ettiği kodu test ediyor oluşudur. Test edilen kodun herhangi bir bağımlılığı varsa test sırasında bu bağımlılık izole edilir. Yani bağımlı olan sınıfın belirli bir davranışı sergilediği varsayılır, simüle edilir. Buna mocking denir ve birçok dilde bunu yapmamızı sağlayan kütüphaneler vardır.

Bağımlı olduğumuz fonksiyonda bir hata varsa bunu nasıl yakalarız diye soruyorsanız, onu da bağımlı olduğumuz sınıf/fonksiyon için yazdığımız unit testlerde yakalamamız gerekiyor.

Java’da bu mocking işlemini yapmamızı ve bağımlılıklar varmış gibi davranmamızı sağlayan kütüphanelerin başını Mockito çekiyor. Aşağıda Mockito kullanarak yazdığım bir örneği anlatacağım.

Aşağıdaki PersonServiceImpl isimli sınıfın createPerson isimli metodunu test etmek istediğimizi düşünelim. Bu sınıfın PersonRepository isimli bir interface bağımlılığı var. Bu repository muhtemelen bir veritabanına insert işlemi yapmakta olup, ne yaptığı bizi hiç ama hiç ilgilendirmiyor.

public class PersonServiceImpl implements PersonService {
    private final PersonRepository personRepository;

    public PersonServiceImpl(PersonRepository personRepository) {
        this.personRepository = personRepository;
    }

    @Override
    public Person createPerson(CreatePersonRequest createPersonRequest) {
        final var firstName = Objects.requireNonNull(createPersonRequest.getFirstName(), "firstName cannot be null");
        final var lastName = Objects.requireNonNull(createPersonRequest.getLastName(), "lastName cannot be null");
        final var phone = Objects.requireNonNull(createPersonRequest.getPhone(), "phone cannot be null");
        final var person = new Person(firstName, lastName, phone);
        return personRepository.savePerson(person);
    }
}

Hatta ben bu sınıf için test yazarken PersonRepository arayüzü için bir implementasyon sınıfı bile yazmadım. Mockito kullanarak PersonRepository arayüzünün sahte (mock) bir implementasyonunu hazırladım ve savePerson metodu çağırıldığında ne dönmesini istediğimi aşağıdaki test metodunda belirttim.

@Test
void createPerson_validCreatePersonRequest_returnsSamePersonReturnedFromRepository() {
    // Arrange
    final var personRepository = Mockito.mock(PersonRepository.class);
    final var personService = new PersonServiceImpl(personRepository);
    final var expectedResult = new Person("Serdar", "Kuzucu", "+905554443322");
    Mockito.doReturn(expectedResult)
        .when(personRepository)
        .savePerson(Mockito.any(Person.class));

    // Act
    final var actualResult = personService.createPerson(CreatePersonRequest.builder()
            .firstName("Serdar")
            .lastName("Kuzucu")
            .phone("+905554443322")
            .build());

    // Assert
    assertSame(expectedResult, actualResult);
}

Bu test kaynak kodda PersonRepository arayüzünün herhangi bir implementasyonu olmasa bile başarılı bir şekilde geçiyor. Eğer bu izolasyonu sağlamasaydık gerçek bir PersonRepository implementasyonuna ihtiyacımız olacaktı. Bu durumda da bu implementasyon bir veritabanı kullanıyorsa testleri çalıştırabilmek için bizim de bir veritabanı bağlantısı kurmamız gerekecekti. Böyle bir test de unit test değil entegrasyon testi olacaktır.

Birbirinden Bağımsız Testler Yazalım

Unit testler asla birbirine bağımlı olmamalıdır. Bir unit testin çalışması için öncesinde başka bir testin başarıyla çalışmış olması gerekmemelidir. Böyle bir bağımlılık testlerin sıralı çalıştırılmasını zorunlu kılar ve çoğu test suite yazılımı (junit, vb.) test senaryolarının sıralı çalışacağını garanti etmez. Lokalimizde bir test sınıfındaki tüm testler doğru sırayla çalışıp başarılı olurken, CI/CD ortamında farklı sırayla çalışıp hata alabilirler.

Testler arası state paylaşımından olabildiğince kaçınmamız gerekiyor. Her test kendi state’ini oluşturmalı ve test sonrasında gerekiyorsa temizlemelidir. Eğer bir test sınıfındaki tüm testlerde başlangıç ve bitiş durumları için aynı kodları yazmak gerekiyorsa bunu bir testte yapıp diğerlerinin sırayla çalışacağını varsaymak yerine, kullandığımız test framework’ünün Setup ve TearDown özelliklerini kullanmalıyız.

Java’da çoğunlukla kullandığımız junit test framework’ünde bu “Setup” ve “TearDown” özellikleri, kullandığımız junit versiyonuna göre, @Before, @After, @BeforeClass, @AfterClass, @BeforeEach, @AfterEach, @BeforeAll ve @AfterAll gibi anotasyonlar ile sağlanıyor.

AAA Tasarım Şablonuna Uyalım

Yazı dizisinin ilk yazısında da değindiğim Arrange-Act-Assert, kısaca AAA, tasarım şablonunu mümkün olduğunca uygulayalım. Bu şablon hem dünya çapında genel geçer bir tasarım şablonu hem de testlerin okunabilirliğini arttıran bir yöntem. Testin okunabilir olması testin ne amaçla yazıldığını anlamayı kolaylaştırır. Anlaşılabilir bir test hata verdiğinde testteki veya koddaki hatanın bulunması da daha kolay olur.

Testlerimizi bu 3 aşamayı düşünerek yazdığımızda “ben bu test koduyla neyi test ediyorum” sorusunu kendi kendimize cevaplamamızı da kolaylaştıracaktır.

Bir Testte Sadece Bir Şeyi Test Edelim

Son derece normal bir şekilde test ettiğimiz fonksiyonun birden fazla dalı olabilir. Farklı girdilerde farklı çıktılar üreten değişik iş akışlarına sahip olabilir. Hata veren testlerden koddaki hatayı daha hızlı anlayabilmemiz için bir testin sadece tek bir şeyi test ediyor olması gerekmekte.

Örnek olarak aşağıdaki servis sınıfımızı düşünelim:

public class FrequentlyAskedQuestionServiceImpl implements FrequentlyAskedQuestionService {
    private final FrequentlyAskedQuestionRepository frequentlyAskedQuestionRepository;
    private final EventPublisher eventPublisher;

    public FrequentlyAskedQuestionServiceImpl(FrequentlyAskedQuestionRepository frequentlyAskedQuestionRepository,
                                              EventPublisher eventPublisher) {
        this.frequentlyAskedQuestionRepository = frequentlyAskedQuestionRepository;
        this.eventPublisher = eventPublisher;
    }

    @Override
    public SaveFAQResult saveFaq(String question, String answer) {
        if (frequentlyAskedQuestionRepository.existsByQuestion(question)) {
            return SaveFAQResult.ALREADY_EXISTS;
        }
        final var faq = new FAQ(question, answer);
        final var persistedFaq = frequentlyAskedQuestionRepository.saveFAQ(faq);
        eventPublisher.fireEvent(new FAQSavedEvent(persistedFaq));
        return SaveFAQResult.SUCCESS;
    }
}

Burada aşağıdaki gibi 6 farklı senaryoyu test edebiliriz:

  • Başarılı işlemde SUCCESS cevabı dönmeli:
@Test
void saveFaq_validQuestionAndAnswer_returnsSuccess() {
    final var question = "How to deorbit a satellite?";
    final var answer = "Lorem ipsum dolor sit amet...";
    final var repository = Mockito.mock(FrequentlyAskedQuestionRepository.class);
    final var eventPublisher = Mockito.mock(EventPublisher.class);
    Mockito.doReturn(false)
            .when(repository)
            .existsByQuestion(question);
    Mockito.doReturn(new FAQ(question, answer))
            .when(repository)
            .saveFAQ(Mockito.any(FAQ.class));
    final var service = new FrequentlyAskedQuestionServiceImpl(repository, eventPublisher);

    final var result = service.saveFaq(question, answer);

    assertEquals(SaveFAQResult.SUCCESS, result);
}
  • Başarılı işlemlerde FrequentlyAskedQuestionsRepository üzerindeki saveFAQ metodu doğru argümanlar ile tetiklenmeli:
@Test
void saveFaq_validQuestionAndAnswer_callsRepositorySaveMethodWithTheSameQuestionAndAnswerValues() {
    final var question = "How to deorbit a satellite?";
    final var answer = "Lorem ipsum dolor sit amet...";
    final var repository = Mockito.mock(FrequentlyAskedQuestionRepository.class);
    final var eventPublisher = Mockito.mock(EventPublisher.class);
    Mockito.doReturn(false)
            .when(repository)
            .existsByQuestion(question);
    Mockito.doReturn(new FAQ(question, answer))
            .when(repository)
            .saveFAQ(Mockito.any(FAQ.class));
    final var service = new FrequentlyAskedQuestionServiceImpl(repository, eventPublisher);

    service.saveFaq(question, answer);

    final var faqArgumentCaptor = ArgumentCaptor.forClass(FAQ.class);
    Mockito.verify(repository, Mockito.times(1))
            .saveFAQ(faqArgumentCaptor.capture());

    assertEquals(question, faqArgumentCaptor.getValue().getQuestion());
    assertEquals(answer, faqArgumentCaptor.getValue().getAnswer());
}
  • Başarılı işlemlerde EventPublisher üzerindeki fireEvent metodu doğru argümanlar ile tetiklenmeli:
@Test
void createFaq_validQuestionAndAnswer_firesFAQSavedEvent() {
    final var question = "How to deorbit a satellite?";
    final var answer = "Lorem ipsum dolor sit amet...";
    final var repository = Mockito.mock(FrequentlyAskedQuestionRepository.class);
    final var eventPublisher = Mockito.mock(EventPublisher.class);
    Mockito.doReturn(false)
            .when(repository)
            .existsByQuestion(question);
    Mockito.doReturn(new FAQ(question, answer))
            .when(repository)
            .saveFAQ(Mockito.any(FAQ.class));
    final var service = new FrequentlyAskedQuestionServiceImpl(repository, eventPublisher);

    service.saveFaq(question, answer);

    final var eventArgumentCaptor = ArgumentCaptor.forClass(FAQSavedEvent.class);
    Mockito.verify(eventPublisher, Mockito.times(1))
            .fireEvent(eventArgumentCaptor.capture());

    assertEquals(question, eventArgumentCaptor.getValue().getQuestion());
    assertEquals(answer, eventArgumentCaptor.getValue().getAnswer());
}
  • FrequentlyAskedQuestionRepository sınıfındaki existsByQuestion metodu true dönerse servis metodumuz ALREADY_EXISTS değerini dönmeli.
@Test
void createFaq_alreadyExistingQuestion_returnsAlreadyExists() {
    final var question = "How to deorbit a satellite?";
    final var answer = "Lorem ipsum dolor sit amet...";
    final var repository = Mockito.mock(FrequentlyAskedQuestionRepository.class);
    final var eventPublisher = Mockito.mock(EventPublisher.class);
    Mockito.doReturn(true)
            .when(repository)
            .existsByQuestion(question);
    final var service = new FrequentlyAskedQuestionServiceImpl(repository, eventPublisher);

    final var result = service.saveFaq(question, answer);

    assertEquals(SaveFAQResult.ALREADY_EXISTS, result);
}
  • FrequentlyAskedQuestionRepository sınıfındaki existsByQuestion metodu true dönerse FrequentlyAskedQuestionRepository sınıfındaki saveFAQ metodu hiç çağırılmamalı.
@Test
void createFaq_alreadyExistingQuestion_doesNotCallSaveFaqMethodOfRepository() {
    final var question = "How to deorbit a satellite?";
    final var answer = "Lorem ipsum dolor sit amet...";
    final var repository = Mockito.mock(FrequentlyAskedQuestionRepository.class);
    final var eventPublisher = Mockito.mock(EventPublisher.class);
    Mockito.doReturn(true)
            .when(repository)
            .existsByQuestion(question);
    final var service = new FrequentlyAskedQuestionServiceImpl(repository, eventPublisher);

    service.saveFaq(question, answer);

    Mockito.verify(repository, Mockito.never()).saveFAQ(Mockito.any());
}
  • FrequentlyAskedQuestionRepository sınıfındaki existsByQuestion metodu true dönerse EventPublisher sınıfındaki fireEvent metodu hiç çağırılmamalı.
@Test
void createFaq_alreadyExistingQuestion_doesNotPublishAnyEvent() {
    final var question = "How to deorbit a satellite?";
    final var answer = "Lorem ipsum dolor sit amet...";
    final var repository = Mockito.mock(FrequentlyAskedQuestionRepository.class);
    final var eventPublisher = Mockito.mock(EventPublisher.class);
    Mockito.doReturn(true)
            .when(repository)
            .existsByQuestion(question);
    final var service = new FrequentlyAskedQuestionServiceImpl(repository, eventPublisher);

    service.saveFaq(question, answer);

    Mockito.verify(eventPublisher, Mockito.never()).fireEvent(Mockito.any());
}

Bu şekilde unit testleri sadece bir şeyi test eder şekilde kurguladığımızda ileride kodda yapılacak bir değişikliğin tam olarak hangi senaryoyu veya senaryoları etkilediğini aldığımız hatadan nokta atışı bulabiliriz.

Bu arada bu örnek testleri yazarken yine daha önce yaptığım gibi FrequentlyAskedQuestionServiceImpl dışındaki hiçbir sınıfı doğru düzgün geliştirmedim. Testlerdeki izolasyon sayesinde buna ihtiyacım kalmadan yazdığım tüm testler başarılı bir şekilde çalıştı.

Hızlı Çalışan Testler Yazalım

Unit testleri hızlıca çalıştırılıp sonuç alabileceğimiz şekilde yazmalıyız. Uzun test çalışma süreleri genellikle geliştiricilerin testleri çalıştırmadan build almasıyla sonuçlanıyor.

Bir zamanlar üzerinde çalıştığım bu şekilde bir proje vardı. Çalışan testler çok uzun sürüyordu, birçoğu da çalışmıyordu. Nasıl olsa testler çalışmıyor diye kimse testleri çalıştırarak build almıyordu, kimse hataları düzeltmeye bile çalışmıyordu. Problem projedeki tüm testlerin silinmesiyle sonuçlandı.

Yavaş testler, test edilen sınıftaki bağımlılıkların testlerde yeterince izole edilmediğinin veya test edilen kodun aslında test edilebilir bir kod olmadığının göstergesi olabilir.

İlk Olarak En Basit Başarılı Senaryoyu Test Edelim

Test edeceğimiz kodun ne yapması gerekiyor? Asıl varoluş amacı ne? Bu soruya vereceğimiz en basit ve hızlı cevap için hemen bir test yazalım.

Örneğin bir String’i ters çeviren bir kodu test ediyorsak önce ters çevirilebilir kısa bir metin ile test edelim.

@Test
void reverse_threeLetterString_returnsReversedString() {
    final var text = "abc";
    final var result = StringUtils.reverse(text);
    assertEquals("cba", result);
}

Daha sonraki testlerde daha kompleks akışlara, uç senaryolara ve istisna durumlarına geçebiliriz.

@Test
void reverse_nullArgument_returnsNull() {
    final String text = null;
    final var result = StringUtils.reverse(text);
    assertNull(result);
}

Uç Senaryoların Testlerini Yazalım

Test edilen kodun uç senaryolarını da düşünelim ve mutlaka bu senaryolar için test yazalım.

  • Parametre null gelirse ne olur?
  • DB’den bir sonuç beklerken iki sonuç gelirse ne olmalı? Hiç sonuç gelmezse?
  • Bölme işlemi içeren bir fonksiyonda paydaya 0 (sıfır) gelme ihtimali var mı?
  • Beklediğimden büyük bir sayı gelirse integer overflow olur mu?
  • Kullanıcı adı boş String ("") geldiğinde fonksiyon ne yapmalı?
  • Javascript kodum null, undefined, 0, false ve "" arasındaki farkı ayırt edebiliyor mu?

Daha aklımıza gelebilecek bir çok senaryoyu buraya ekleyebiliriz ve çoğu kodda da geçerlidir bu durumlar.

Eğer kodumuz sınırlı bir veri aralığı ile ilgiliyse bu sınırların çizgilerini, sınırın bir miktar dışını, bir miktar içini de testlerimize eklemeliyiz.

Örneğin bir kullanıcının 3 defa şifresini yanlış girme hakkı varsa bu kontrolü yapan kodu şifrenin doğru girilmesi için test ettiğimiz gibi mutlaka 2, 3 ve 4 gibi sayılar ile test etmeliyiz.

Tüm Yol Ayrımlarını Test Edelim

Test edilen kodda yol ayrımları (if/else, loop, vb.) varsa mutlaka o yol ayrımlarına giren ve girmeyen testleri ayrı ayrı yazmaya özen gösterelim.

Bu hem coverage denen testlerin kodun ne kadarını kapsadığı metriğini arttırır hem de bu tür durumlarda olabilecek hataların erken fark edilmesini sağlar.

Kullanılan dile ait enum gibi bazı yapılar da gizli yol ayrımlarına neden olabilirler. enum tipinde bir veri üzerinde çalışan kodların testlerini yazarken enum üzerinde tanımlanmış tüm olası değerler için birer test yazmalıyız.

Hata Bulmak İçin Test Yazalım

Eğer kodda belirli bir durumda bir hata oluştuğu iddia ediliyorsa, kodu okuyup bug-fix geliştirmeden önce bu hatayı oluşturan bir unit test yazalım.

Testi çalıştırdığımızda hata alıyorsa doğru yoldayız demektir. Sonrasında istersek doğrudan koda dalabiliriz veya kod okumak için çok karışıksa testi debug modda çalıştırarak debug edebiliriz.

Koddaki düzeltmeyi yaptığımızda yazmış olduğumuz testin başarılı olduğunu görürsek tebrikler! Hem bir hatayı çözmüş oluruz hem de bu hatanın bir daha ortaya çıkmayacağını garanti altına alan nur topu gibi bir unit testimiz olur.

İsimlendirmelere Dikkat Edelim

Test isimlendirme konusunda çok farklı yazım standartları var ve birçok farklı standart farklı farklı geliştiriciler tarafından benimsenmiş durumda. Burada testlerimizi kesin şu şekilde isimlendirmeliyiz diyemiyoruz.

Şu makalede 7 farklı popüler isimlendirme örneği gösterilmiş. Hepsinin kendi avantajları ve dezavantajları var. Şu makalede ise test kodundaki isimlendirmelerin öneminden bahsedilmiş. Böyle konular çok tartışmaya açık konular olduğundan bu tip konularda fikir alışverişi de fazla oluyor.

Burada önerebileceğim en önemli şey şu, test ismine bakıldığında neyin test edildiği anlaşılmalı. test01, test02 gibi isimlendirmelerden veya fonksiyon ismi + sayı gibi hangi fonksiyonun test edildiği dışında bir bilgi vermeyen isimlendirmelerden kaçınmalıyız.

Bir diğer önerim de ekip içerisinde popüler bir isimlendirme standartına karar verilip onun uygulanması. Bir projede farklı diğerinde farklı isimlendirmeler yapılması bir süre sonra kafa karışıklığına yol açıyor ve devamında aynı projede farklı isimlendirme standartları ortaya çıkmaya başlıyor.

Ben şahsi olarak Java dilinde geliştirme yaparken Java metod isimlendirme kurallarına mümkün olduğunca uyulmasından yanayım. Sonuçta test metodu da bir Java kodu. Bu sebeple metod isimlerine büyük harf ile başlamayı kesinlikle sevmem. Fakat test metod isminin uzunca bir cümle olabileceğinden dolayı cümlenin farklı parçalarını _ ile ayırmayı da severim. Bu okunabilirliği arttırır. Unutmayalım bir test hata verdiğinde test isminin ne kadar okunabilir olduğu önemlidir.

Bu sebeple favori test metod ismi yazım stilim olan [testEdilenMetodİsmi]_[verilenGirdi|testEdilenDurum]_[beklenenÇıktı|beklenenDavranış] stilini kullandım bu yazıdaki örneklerde de.

Bahsettiğim standart ile yazılmış bazı örnekler:

  • registerUser_nullUsername_throwsException
  • reverseString_emptyString_returnsEmptyString
  • fireEvent_subscriptionCreatedEvent_writesEventToKafka

Bazen test edilecek metod herhangi bir state veya input bağımlılığı olmadan çok basit bir işlem yapıyor olabilir. Bu durumda test ismi yazacağız diye bir şeyler uydurmaya çalışmaya gerek yok. Bazı kısımları atlayabiliriz.

  • getUsername_returnsUsername
  • getCurrentTime_returnsCurrentTime

vb. örnekler çoğaltılabilir.

İstisnaları (Exceptions) Test Edelim

Test edilen kodun istisna durumları varsa, Java’da buna Exception deniyor, bunlar için de test yazmalıyız.

Çehov'un silahı

“Eğer birinci perde açıldığında duvarda bir tüfek asılıysa takip eden sahnede tüfek mutlaka patlamalı. Aksi takdirde oraya koymayın.”

Test edilen kodda bir yerde bir Exception fırlatıldığını görüyorsak, bu durumun doğru bir şekilde gerçekleştiğinden de emin olmalıyız.

Eğer unit test ile bile o istisna durumunu sağlayamıyorsak o koşulu kodumuzdan silebiliriz, büyük ihtimalle gereksizdir ve canlıda da karşımıza çıkmayacaktır.

Java 8 öncesi biraz daha zahmetli olan Exception testi yazma konusu Java 8 ve Junit 5 kütüphanesinin gelmesiyle aşağıdaki gibi oldukça kolaylaşmış ve yazması keyifli hale gelmiş durumda.

// Test edilen kod

public void throwIfUserDoesNotHavePrivilege(UserDetails user, String privilege) {
    user.getAuthorities()
            .stream()
            .map(GrantedAuthority::getAuthority)
            .filter(privilege::equals)
            .findFirst()
            .orElseThrow(() -> new AccessDeniedException("Privilege: " + privilege));
}

// Test kodu

@Test
void throwIfUserDoesNotHavePrivilege_userDoesNotHavePrivilege_throwsAccessDeniedException() {
    final var privileges = Set.of(
            new SimpleGrantedAuthority("PRIV_CHANGE_PASSWORD"),
            new SimpleGrantedAuthority("PRIV_VIEW_PROFILE"));
    final var user = new User("username", "password", privileges);

    final var exception = assertThrows(AccessDeniedException.class,
            () -> throwIfUserDoesNotHavePrivilege(user, "PRIV_UPDATE_PROFILE"));

    assertEquals("Privilege: PRIV_UPDATE_PROFILE", exception.getMessage());
}

Testlerimiz Başarısız Olduğunda Sebebi Belli Olsun

Bir test hata vermeye başladığında test kodunu okumadan önce hatanın hangi senaryoda ve hangi durumda olduğunu anlayabiliyor olmalıyız.

Bunun için anlamlı test metodu isimleri kullanma başlığında anlattıklarımın dışında bir de doğru assert kullanımı konusu var.

Eğer elimizde karşılaştırabileceğimiz iki değer varsa assertEquals veya assertSame kullanmalıyız. Böyle durumlarda karşılaştırmayı kendimiz yapıp assertTrue kullanırsak patlayan testin verdiği hata mesajından test ettiğimiz kodun ürettiği sonucu göremeyiz.

Örneğin aşağıda aynı hatalı kontrollerin assertEquals ve assertTrue ile verdiği hata mesajlarını okuyalım:

final int actualResult = 5;

assertTrue(4 == actualResult);
// Hata mesajı: expected: <true> but was: <false>

assertEquals(4, actualResult);
// Hata mesajı: expected: <4> but was: <5>


final String actualResult = "Lorem ipsam";

assertTrue("Lorem ipsum".equals(actualResult));
// Hata mesajı: expected: <true> but was: <false>

assertEquals("Lorem ipsum", actualResult);
// Hata mesajı: expected: <Lorem ipsum> but was: <Lorem ipsam>

Tabi ki boolean dönen bir metodun testini yazarken mecburen assertTrue kullanmamız gerekiyor. İki elimiz kanda da olsa assertTrue kullanmayalım gibi bir durum yok. O assertion’ın da bir varoluş sebebi var kütüphanede.

Ek olarak test yazarken kendimize sık sık bu soruyu da soralım: “Bu test bu assert sebebiyle patladığında hata mesajında ne yazar?” Hata mesajındaki bilginin yetersiz olacağını düşündüğümüz noktada, kullandığımız test kütüphanesi izin veriyorsa assert mesajına özel bir de bilgi de ekleyebiliriz.

Örneğin aşağıdaki kodda assertTrue kullandık fakat hata mesajını özelleştirdik:

final boolean startsWithPriv = privilegeName.startsWith("PRIV_");
assertTrue(startsWithPriv, "Privilege " + privilegeName + " does not start with PRIV_ prefix!");

// Hata mesajı: Privilege PR_AAA does not start with PRIV_ prefix! ==> expected: <true> but was: <false>

Ortam Bağımsız Çalışsın

Unit testler üzerinde çalıştığı ortamdan bağımsız şekilde her yerde çalışabilecek kadar izole geliştirilmeli. Yabancı bir blogda unit testler annenizin bilgisayarında bile çalışabilmeli yazmıştı bilgili bir abimiz.

Özellikle platform bağımsız dillerde dil farklı makineleri ve işletim sistemlerini destekliyor olsa bile yazılımcılar spesifik ortamlara bağımlı kodlar yazabilmektedir. Test edilen kodun ortam bağımsız olması ile ilgili konuları yazı dizimizin bir sonraki yazısı olan test edilebilir kod yazmak ile ilgili başlıkta da inceleyeceğiz.

Koddaki veritabanı, disk veya network gibi dış bileşenlerin kullanıldığı kısımları testlerde mock implementasyonlar kullanarak izole ederek ortam bağımsız hale getirebiliriz.

Linux/Windows arası dosya sistemindeki klasör ayracı (/, \) değişiyor. Bu tür şeylere dikkat ederek işletim sistemi ile ilgili problemlerden kurtulabiliriz.

Tarih/Zaman ile çalışan kodlarda tarih/zaman’ın sistemden okunması ile ilgili kısımları koddan izole ederek farklı zaman + timezone problemlerinden kurtulabiliriz.

Benim en çok karşılaştığım problem timezone oldu bugüne kadar. CI/CD sunucusu GMT+0 zamanında çalışırken geliştirici ekip olarak biz Türkiye’de GMT+3 zamanında çalışıyoruz. Test kodunda bir tarihi elle String olarak yazıp formatladığımızda lokalde çalışan testler CI/CD sunucusunda hata veriyor. Bununla ilgili kullandığımız çözümü de bir sonraki yazıda paylaşacağım.

Sonuç

Unit test yazı dizimizin bu yazısında, unit testlerin nasıl daha kaliteli, okunabilir ve işe yarar şekilde geliştirilebileceği konusunu inceledik.

Bir test metodu ile sadece bir şeyi test etmemiz gerekirken, test edilen koddaki her bir farklı senaryo için de ayrı bir test yazmamız gerekiyor.

Unit testi diğer test çeşitlerinden ayıran özelliklere gerçekten sahip olabilmemiz için bu yazıdaki her bir maddeye özen göstererek testlerimizi geliştirmemiz gerekiyor.

Bu maddelere uyabilmemiz için de test edilebilir, kaliteli bir production koduna ihtiyacımız var. Yazı dizimizin bir sonraki yazısında da test edilebilir kod nasıl yazılır konusunu inceleyeceğiz.

Kaynakça

  1. Unit Testing and the Arrange, Act and Assert (AAA) Pattern - Paulo Gomes
  2. 13 Tips for Writing Useful Unit Tests - Nick Hodges
  3. 10 Tips to Writing Good Unit Tests - Grzegorz Ziemoński
  4. You Still Don’t Know How to Do Unit Testing (and Your Secret is Safe with Me) - Erik Dietrich
  5. Writing Clean Tests – Naming Matters - Petri Kainulainen
  6. 7 Popular Unit Test Naming Conventions - Ajitesh Kumar
  7. TestDouble - Martin Fowler