Örnek olarak test etme

Yayınlanan: 2022-02-15

Testle ilgili her şey hakkında blog serimize devam ediyoruz. Bu blogda, gerçek örneklere odaklanıyoruz.

Bu gönderideki örnekler JUnit 5 ve AssertJ kullanılarak yazılmış olsa da, dersler diğer herhangi bir birim test çerçevesine uygulanabilir.

JUnit, Java için en popüler test çerçevesidir. AssertJ, geliştiricilerin daha anlamlı testler yazmasına yardımcı olan bir Java kitaplığıdır.

Temel test yapısı

Bakacağımız ilk test örneği, 2 sayı eklemek için basit bir hesap makinesidir.

 sınıf Hesap MakinesiShould {

     @Test // 1
     geçersiz toplam() {
         Hesap makinesi hesap makinesi = yeni Hesap Makinesi(); // 2
         int sonuç = hesap makinesi.sum(1, 2); // 3
         assertThat(sonuç).isEqualTo(3); // 4
     }
}

Her yöntem adında should veya test tekrarından kaçınmak için testler yazarken ClassShould adlandırma kuralını kullanmayı tercih ederim. Bununla ilgili daha fazla bilgiyi buradan okuyabilirsiniz.

Yukarıdaki test ne işe yarar?

Testi satır satır bölelim:

  1. @Test ek açıklaması, JUnit çerçevesinin hangi yöntemlerin test olarak çalıştırılması gerektiğini bilmesini sağlar. Test sınıfında test olmayan private yöntemlerin olması tamamen normaldir.
  2. Bu, test ortamını hazırladığımız testimizin düzenleme aşamasıdır. Bu test için ihtiyacımız olan tek şey bir Calculator örneğine sahip olmak.
  3. Bu, test etmek istediğimiz davranışı tetiklediğimiz harekete geçme aşamasıdır.
  4. Bu, ne olduğunu ve her şeyin beklendiği gibi çözülüp çözülmediğini incelediğimiz onaylama aşamasıdır. assertThat(result) yöntemi AssertJ kitaplığının bir parçasıdır ve birden çok aşırı yüklemeye sahiptir.

Her aşırı yükleme, özel bir Assert nesnesi döndürür. Döndürülen nesne, assertThat yöntemine ilettiğimiz nesne için anlamlı olan yöntemlere sahiptir. Bizim durumumuzda bu nesne, Tamsayıları test etme yöntemleriyle birlikte AbstractIntegerAssert . isEqualTo(3) , result == 3 olup olmadığını kontrol edecektir. Eğer öyleyse, test geçecek ve aksi takdirde başarısız olacaktır.

Bu blog gönderisinde herhangi bir uygulamaya odaklanmayacağız.

Arrange , Act , Assert is Give , When , Then hakkında düşünmenin başka bir yolu .

sum uygulamamızı yazdıktan sonra kendimize bazı sorular sorabiliriz:

  • Bu testi nasıl geliştirebilirim?
  • Ele almam gereken daha fazla test vakası var mı?
  • Bir pozitif ve bir negatif sayı eklersem ne olur? İki negatif sayı? Bir olumlu ve bir olumsuz?
  • Tamsayı değerini aşarsam ne olur?

Bu durumları ekleyelim ve mevcut test adını biraz geliştirelim.

Uygulamamızda taşmalara izin vermeyeceğiz. sum taşarsa, bunun yerine bir ArithmeticException oluşturacağız.

 sınıf Hesap MakinesiShould {

     özel Hesap Makinesi hesap makinesi = yeni Hesap Makinesi();

     @Ölçek
     void sumPositiveNumbers() {
         int toplam = hesap makinesi.sum(1, 2);
         assertThat(sum).isEqualTo(3);
     }

     @Ölçek
     void sumNegativeNumbers() {
         int toplam = hesap makinesi.toplam(-1, -1);
         assertThat(sum).isEqualTo(-2);
     }

     @Ölçek
     void sumPositiveAndNegativeNumbers() {
         int toplam = hesap makinesi.sum(1, -2);
         assertThat(sum).isEqualTo(-1);
     }

     @Ölçek
     geçersiz failWithArithmeticExceptionWhenOverflown() {
         assertThatThownBy(() -> hesap makinesi.sum(Tamsayı.MAX_VALUE, 1))
             .isInstanceOf(ArithmeticException.class);
     } 

}

JUnit, her @Test yöntemini çalıştırmadan önce yeni bir CalculatorShould örneği oluşturacaktır. Bu, her CalculatorShould farklı bir calculator sahip olması gerektiği anlamına gelir, böylece onu her testte örneklememiz gerekmez.

shouldFailWithArithmeticExceptionWhenOverflown testi, farklı türde bir assert kullanır. Bir kod parçasının başarısız olup olmadığını kontrol eder. assertThatThrownBy yöntemi, sağladığımız lambda'yı çalıştıracak ve başarısız olduğundan emin olacaktır. Bildiğimiz gibi, tüm assertThat yöntemleri, hangi tür istisnanın meydana geldiğini kontrol etmemize izin veren özel bir Assert döndürür.

Bu, beklediğimizde kodumuzun başarısız olduğunu nasıl test edebileceğimize bir örnektir. Herhangi bir noktada Calculator yeniden düzenlersek ve bir taşma durumunda ArithmeticException atmazsa, testimiz başarısız olur.

NesneAna tasarım deseni

Sonraki örnek, bir Person örneğinin geçerli olduğundan emin olmak için bir validator sınıfıdır.

 class PersonValidatorShould {

    private PersonValidator doğrulayıcı = yeni PersonValidator();

    @Ölçek
    void failWhenNameIsNull() {
        Kişi kişi = yeni Kişi(boş, 20, yeni Adres(...), ...);

        assertThatThownBy(() -> validator.validate(kişi))
            .isInstanceOf(InvalidPersonException.class);
    }

    @Ölçek
    void failWhenAgeIsNegative() {
        Kişi kişi = yeni Kişi("John", -5, yeni Adres(...), ...);

        assertThatThownBy(() -> validator.validate(kişi))
            .isInstanceOf(InvalidPersonException.class);

    }
}

ObjectMother tasarım deseni, genellikle testten örnekleme ayrıntılarını gizlemek için karmaşık nesneler oluşturan testlerde kullanılır. Birden çok test aynı nesneyi oluşturabilir, ancak üzerinde farklı şeyleri test edebilir.

Test #1, test #2'ye çok benzer. Doğrulamayı özel bir yöntem olarak çıkararak PersonValidatorShould yeniden düzenleyebilir, ardından yasa dışı Person örneklerini, hepsinin aynı şekilde başarısız olmasını bekleyerek geçirebiliriz.

 class PersonValidatorShould {

     private PersonValidator doğrulayıcı = yeni PersonValidator();

     @Ölçek
     void failWhenNameIsNull() {
         mustFailValidation(PersonObjectMother.createPersonWithoutName());
     }

     @Ölçek
     void failWhenAgeIsNegative() {
         ShouldFailValidation(PersonObjectMother.createPersonWithNegativeAge());
     }

     private void mustFailValidation(Kişi geçersizKişi) {
         assertThatThownBy(() -> validator.validate(invalidPerson))
             .isInstanceOf(InvalidPersonException.class);
   
     }
 }

Rastgeleliği test etme

Kodumuzdaki rastgeleliği nasıl test etmemiz gerekiyor?

Rastgele Person örnekleri generateRandom için createRandom olan bir PersonGenerator olduğunu varsayalım.

Aşağıdakileri yazarak başlıyoruz:

 class PersonGeneratorShould {

     özel PersonGenerator oluşturucu = yeni PersonGenerator();

     @Ölçek
     geçersiz createValidPerson() {
         Kişi kişi = generator.generateRandom();
         iddia Bu (kişi).
    }
}

Ve sonra kendimize sormalıyız:

  • Burada neyi kanıtlamaya çalışıyorum? Bu işlevin ne yapması gerekiyor?
  • Oluşturulan kişinin boş olmayan bir örnek olduğunu doğrulamalı mıyım?
  • Rastgele olduğunu kanıtlamam gerekir mi?
  • Oluşturulan örneğin bazı iş kurallarına uyması gerekiyor mu?

Dependency Injection kullanarak testimizi basitleştirebiliriz.

 genel arayüz RandomGenerator {
     String createRandomString();
     int createRandomInteger();
}

PersonGenerator artık o arayüzün bir örneğini de kabul eden başka bir kurucuya sahip. Varsayılan olarak, java.Random kullanarak rastgele değerler üreten JavaRandomGenerator uygulamasını kullanır.

Ancak testte, daha öngörülebilir başka bir uygulama yazabiliriz.

 @Ölçek
 geçersiz createValidPerson() {
     RandomGenerator randomGenerator = new PredictableGenerator("John Doe", 20);
     PersonGenerator oluşturucu = yeni PersonGenerator(randomGenerator);
     Kişi kişi = generator.generateRandom();
     assertThat(person).isEqualTo(new Person("John Doe", 20));
}

Bu test, RandomGenerator herhangi bir ayrıntısına girmeden, RandomGenerator tarafından belirtildiği gibi, PersonGenerator rastgele örnekler oluşturduğunu kanıtlar.

JavaRandomGenerator'ı test etmek, JavaRandomGenerator etrafında basit bir sarmalayıcı olduğundan, gerçekten herhangi bir değer java.Random . Test ederek, esasen Java standart kitaplığından java.Random test etmiş olursunuz. Belirgin testler yazmak, yalnızca çok az fayda ile ek bakıma yol açacaktır.

PredictableGenerator gibi test amaçlı uygulamalar yazmaktan kaçınmak için Mockito gibi bir alay kitaplığı kullanmalısınız.

PredictableGenerator yazdığımızda, aslında RandomGenerator sınıfını manuel olarak sapladık. Mockito'yu kullanarak da saplamış olabilirsin:

 @Ölçek
 geçersiz createValidPerson() {
     RandomGenerator randomGenerator = sahte(RandomGenerator.class);
     ne zaman(randomGenerator.generateRandomString()).thenReturn("John Doe");
     ne zaman(randomGenerator.generateRandomInteger()).thenReturn(20);

     PersonGenerator oluşturucu = yeni PersonGenerator(randomGenerator);
     Kişi kişi = generator.generateRandom();
     assertThat(person).isEqualTo(new Person("John Doe", 20));
 }

Test yazmanın bu yolu daha anlamlıdır ve belirli testler için daha az uygulamaya yol açar.

Mockito, maketler ve taslaklar yazmak için bir Java kütüphanesidir. Kolayca somutlaştıramayacağınız dış kitaplıklara bağlı kodu test ederken çok kullanışlıdır. Doğrudan uygulamadan bu sınıflar için davranış yazmanıza olanak tanır.

Mockito ayrıca, alıştığımıza benzer birden fazla testimiz olduğunda, kalıp levhasını azaltmak için sahte oluşturmak ve enjekte etmek için başka bir sözdizimine izin verir:

 @ExtendWith(MockitoExtension.class) // 1
 class PersonGeneratorShould {

     @Sahte // 2
     RandomGenerator randomGenerator;

     @InjectMocks // 3
     özel PersonGenerator üreteci;

     @Ölçek
     geçersiz createValidPerson() {
         ne zaman(randomGenerator.generateRandomString()).thenReturn("John Doe");
         ne zaman(randomGenerator.generateRandomInteger()).thenReturn(20);

         Kişi kişi = generator.generateRandom();
         assertThat(person).isEqualTo(new Person("John Doe", 20));
     }
}

1. JUnit 5, yeteneklerini genişletmek için “uzantıları” kullanabilir. Bu ek açıklama, ek açıklamalar aracılığıyla taklitleri tanımasına ve bunları uygun şekilde enjekte etmesine olanak tanır.

2. @Mock ek açıklaması, alanın alaylı bir örneğini oluşturur. Bu, test yöntemi gövdemizde mock(RandomGenerator.class) yazmakla aynıdır.

3. @InjectMocks ek açıklaması, yeni bir PersonGenerator örneği oluşturacak ve generator örneğine alaylar enjekte edecektir.

JUnit 5 uzantıları hakkında daha fazla ayrıntı için buraya bakın.

Mockito enjeksiyonu hakkında daha fazla ayrıntı için buraya bakın.

@InjectMocks kullanmanın bir dezavantajı var. Nesnenin bir örneğini manuel olarak bildirme ihtiyacını ortadan kaldırabilir, ancak yapıcının derleme zamanı güvenliğini kaybederiz. Herhangi bir zamanda biri yapıcıya başka bir bağımlılık eklerse, burada derleme zamanı hatasını almazdık. Bu, tespit edilmesi kolay olmayan testlerin başarısız olmasına neden olabilir. Örneği manuel olarak kurmak için @BeforeEach kullanmayı tercih ederim:

 @ExtendWith(MockitoExtension.class)
class PersonGeneratorShould {

     @Sahte
     RandomGenerator randomGenerator;

     özel PersonGenerator üreteci;

     @BeforeEach
     geçersiz kurulum() {
         jeneratör = new PersonGenerator(randomGenerator);
     }

     @Ölçek
     geçersiz createValidPerson() {
         ne zaman(randomGenerator.generateRandomString()).thenReturn("John Doe");
         ne zaman(randomGenerator.generateRandomInteger()).thenReturn(20);

         Kişi kişi = generator.generateRandom();
         assertThat(person).isEqualTo(new Person("John Doe", 20));
     }
}

Zamana duyarlı süreçleri test etme

Bir kod parçası genellikle zaman damgalarına bağlıdır ve mevcut dönem zaman damgasını almak için System.currentTimeMillis() gibi yöntemler kullanma eğilimindeyiz.

Bu iyi görünse de, sınıf bizim için dahili olarak kararlar verdiğinde kodumuzun doğru çalışıp çalışmadığını test etmek ve kanıtlamak zordur. Böyle bir kararın bir örneği, içinde bulunulan günün ne olduğunu belirlemek olabilir.

 class IndexerShould {
     özel Dizin Oluşturucu dizinleyici = yeni Dizin Oluşturucu();
     @Ölçek
     void createIndexNameForTomorrow() {
         String indexName = indexer.tomorrow("dizinim");
         // bu test bugün işe yarar, peki ya yarın?
        assertThat(indexName)
           .isEqualTo("my-index.2022-02-02");
     }
}

Dizin adını oluştururken günün ne olduğunu 'kontrol edebilmek' için tekrar Bağımlılık Enjeksiyonu kullanmalıyız.

Java, bunun gibi kullanım durumlarını işlemek için bir Clock sınıfına sahiptir. Zamanı kontrol etmek için bir Clock örneğini Indexer iletebiliriz. Varsayılan kurucu, geriye dönük uyumluluk için Clock.systemUTC() kullanabilir. Artık System.currentTimeMillis() çağrılarını clock.millis() ile değiştirebiliriz.

Bir Clock enjekte ederek, sınıflarımızda öngörülebilir bir süre uygulayabilir ve daha iyi testler yazabiliriz.

Dosya üretme yöntemlerini test etme

  • Çıktılarını dosyalara yazan sınıfları nasıl test etmeliyiz?
  • Herhangi bir işletim sisteminde çalışabilmeleri için bu dosyaları nerede saklamalıyız?
  • Dosyanın zaten var olmadığından nasıl emin olabiliriz?

Dosyalarla uğraşırken, aşağıdaki örnekte göreceğimiz gibi, bu endişeleri kendimiz halletmeye çalışırsak, testler yazmak zor olabilir. Aşağıdaki test, kalitesi şüpheli eski bir testtir. DogToCsvWriter köpekleri bir CSV dosyasına serileştirip yazıp yazmadığını test etmelidir:

 sınıf DogToCsvWriterShould {

     private DogToCsvWriter yazar = new DogToCsvWriter("/tmp/dogs.csv");
     
     @Ölçek
     geçersiz convertToCsv() {
         writer.appendAsCsv(new Dog(Breed.CORGI, Color.BROWN, "Monty"));
         writer.appendAsCsv(new Dog(Breed.MALTESE, Color.WHITE, "Zoe"));

         String csv = Files.readString("/tmp/dogs.csv");

         assertThat(csv).isEqualTo("Monty,corgi,kahverengi\nZoe,malta dili,beyaz");
     }
}

Serileştirme süreci, yazma sürecinden ayrılmalıdır, ancak testi düzeltmeye odaklanalım.

Yukarıdaki testle ilgili ilk sorun, Windows kullanıcıları /tmp/dogs.csv yolunu çözemeyeceğinden, Windows'ta çalışmamasıdır. Diğer bir sorun ise, yukarıdaki test yürütüldüğünde silinmediği için dosya zaten mevcutsa çalışmayacaktır. Bir CI/CD işlem hattında sorunsuz çalışabilir, ancak birden çok kez çalıştırılırsa yerel olarak çalışmayabilir.

JUnit 5, sizin için çerçeve tarafından oluşturulan ve silinen geçici bir dizine referans almak için kullanabileceğiniz bir ek açıklamaya sahiptir. Geçici dosyalar oluşturma ve silme mekanizması çerçeveden çerçeveye değişse de fikirler aynı kalır.

 sınıf DogToCsvWriterShould {

     @Ölçek
     void convertToCsv(@TempDir Path tempDir) {
         Yol köpeklerCsv = tempDir.resolve("dogs.csv");
         DogToCsvWriter yazar = yeni DogToCsvWriter(dogsCsv);
         writer.appendAsCsv(new Dog(Breed.CORGI, Color.BROWN, "Monty"));
         writer.appendAsCsv(new Dog(Breed.MALTESE, Color.WHITE, "Zoe"));

         String csv = Files.readString(dogsCsv);

         assertThat(csv).isEqualTo("Monty,corgi,kahverengi\nZoe,malta dili,beyaz");
     }
}

Bu küçük değişiklikle, yukarıdaki testin mutlak yollar hakkında endişelenmenize gerek kalmadan Windows, macOS ve Linux üzerinde çalışacağından eminiz. Ayrıca, oluşturulan dosyaları testten sonra silecektir, böylece artık birden çok kez çalıştırabilir ve her seferinde tahmin edilebilir sonuçlar alabiliriz.

Komuta karşı sorgu testi

Komut ve sorgu arasındaki fark nedir?

  • Komut : bir nesneye, bir değer döndürmeden efekt üreten bir eylemi gerçekleştirmesi talimatını veririz (void yöntemleri)
  • Sorgu : bir nesneden bir eylem gerçekleştirmesini ve bir sonuç veya istisna döndürmesini isteriz

Şimdiye kadar, esas olarak, eylem aşamasında bir değer döndüren veya bir istisna oluşturan bir yöntem çağırdığımız sorguları test ettik. void yöntemlerini nasıl test edebilir ve diğer sınıflarla doğru etkileşime girip girmediklerini nasıl görebiliriz? Çerçeveler, bu tür testleri yazmak için farklı yöntemler sağlar.

Sorgular için şu ana kadar yazdığımız iddialar, assertThat ile başlıyordu. Komut testleri yazarken, sorgularda yaptığımız gibi yöntemlerin doğrudan sonuçlarını incelemediğimiz için farklı bir yöntem kümesi kullanırız. Yöntemimizin sistemimizin diğer bölümleriyle olan etkileşimlerini 'doğrulamak' istiyoruz.

 @ExtendWith(MockitoExtension.class)
 class FeedMentionServiceShould {

     @Sahte
     özel FeedRepository deposu;

     @Sahte
     özel FeedMentionEventEmitter emitörü;

     özel FeedMentionService hizmeti;

     @BeforeEach
     geçersiz kurulum() {
         hizmet = new FeedMentionService(depo, emitör);
     }

     @Ölçek
     void insertMentionToFeed() {
         uzun besleme kimliği = 1L;
         Mansiyon sözü = ...;

         ne zaman(repository.upsertMention(feedId, söz))
             .thenReturn(UpsertResult.success(feedId, söz));

         FeedInsertionEvent olayı = new FeedInsertionEvent(feedId, bahsetme);
         sözService.insertMentionToFeed(olay);

         doğrulamak(yayıcı).mentionInsertedToFeed(feedId, bahsetme);
         doğrulamaNoMoreInteractions(yayıcı);
     }
}

Bu testte, feed'imizde belirtilmesi istendiğinde bir UpsertResult.success ile yanıt vermesi için ilk önce havuzumuzla alay ettik. Burada depoyu test etmekle ilgilenmiyoruz. Depo yöntemleri FeedRepositoryShould içinde test edilmelidir. Bu davranışla alay ederek, aslında depo yöntemini çağırmadık. Bir dahaki sefere çağrıldığında nasıl yanıt vereceğini söyledik.

Daha sonra söz konusu hizmetimize bu sözü mentionService eklemesini söyledik. Sonucu yalnızca, sözü beslemeye başarıyla eklediyse yayınlaması gerektiğini biliyoruz. verify yöntemini kullanarak, söz ve feed'imizle söz mentionInsertedToFeed yönteminin çağrıldığından ve doğrulamaNoMoreInteractions kullanılarak yeniden çağrılmadığından verifyNoMoreInteractions olabiliriz.

Son düşünceler

Kaliteli testler yazmak deneyimden gelir ve öğrenmenin en iyi yolu yapmaktır. Bu blogda yazılan ipuçları pratikten geliyor. Onlarla hiç karşılaşmadıysanız bazı tuzakları görmek zordur ve umarım bu öneriler kod tasarımınızı daha sağlam hale getirir. Güvenilir testlere sahip olmak, kodunuzu her dağıtmak zorunda kaldığınızda, hiç zorlanmadan bir şeyleri değiştirme konusundaki güveninizi artıracaktır.

Mediatoolkit ekibine katılmak ister misiniz?
Kıdemli Ön Uç Geliştirici için açık pozisyonumuza göz atın!