İçeriğe geç
academia.sh

Ders 05 / 12

Birim Test Çerçevesi

Kurulum, gövde ve sökme çağrılarını sırayla yapan küçük bir koşturucu elle kurulur; aynı iddia sökmeden önce ve sonra kontrol edildiğinde iki ayrı sonuç veriyor, ikisi de koştu sayılıyor. Aynı derlenmiş test sınıfı, çerçeve tipi eksik bir sınıf yoluyla çalıştırılınca test sınıf yolunun üçüncü, bağımsız bir yol olduğu ölçülüyor.

İçindekiler

Önceki konu koşum sınıf yolunu taşınabilir bir ürüne çevirdi ve içine ne konduğunun bir karar olduğunu gösterdi. O karar orada iki yolla sınırlıydı: derleme sınıf yolu ve koşum sınıf yolu. Bu ders aynı kararı üçüncü bir yol için verir. Test yazarken elimizin altında bir sınıf yolu daha durur — testin gördüğü yol — ve bu yol üretimin gördüğü yolla aynı değildir. Sorumuz şu: bu üçüncü yol neyi taşır, ve üretim sınıf yolunda hiç bulunmayan bir tip eksik olduğunda ne olur?

Sorunun ikinci yarısı ayrı bir konudur ve M21/K02 Birim Testi ve Test Odaklı Geliştirme kursunda kuruldu: test sınıflandırması, iddia yazımı, test adlandırma — bunların hiçbiri burada yeniden kurulmaz. Bu derste tek yeni soru, testin çalışması için gereken tiplerin hangi sınıf yolunda durduğudur. O soruyu yanıtlamadan önce, üzerine bir sınıf yolu sorusu kuracağımız çerçevenin kendisini — kurulum, gövde ve sökme çağrılarını sırayla yapan koşturucuyu — elimizle kurmamız gerekiyor.

Bu iki soru göründüğünden daha yakın duruyor. Bir çerçeve, kaynakta hiç yazılmayan bir sırayı her olgu için tekrar tekrar uygular; bu tekrar, bir dosyadaki kaynağın hiçbir yerinde görünmeyen bir dizi çağrı anlamına gelir. O çağrıların derlenip koşabilmesi için bir yere yazılmış olması gerekir — ve o yer, üretim kodunun okuduğu sınıf yolu değildir. Çerçeveyi kurmadan sınıf yolu sorusuna geçmek, boş bir soyutlamayı ölçmek olurdu; bu yüzden ders önce çağrıları elle yazıyor, sonra o çağrıların hangi tipe ihtiyaç duyduğunu sayıyor.

Çerçevenin Kurduğu Sıra

  • TS1. Kurulum ve sökme burada birer Runnable olarak modellenir; gerçek bir çerçevenin test keşfi (dosya tarama, ad eşleme) burada kurulmaz — yalnız kurulum, gövde, sökme sırası ve başarısızlığın bildirilme biçimi modellenir.
  • TS2. İddia başarısızlığı bir AssertionError fırlatarak bildirilir; koşturucu bunu yakalar ve olguyu “düştü” diye günlükler, aksi hâlde “geçti” yazar. Sökme adımı her koşulda — gövde düşse de düşmese de — çalışır.

Bir test çerçevesinin yaptığı iş, dışarıdan bakınca üç çağrıyı belirli bir sırayla yapmaktan ibarettir: önce kurulum, sonra gövde, en sonda sökme. İddia bir karşılaştırmadır ve başarısızlığını bir istisnayla bildirir; çerçeve bu istisnayı yakalayıp olguyu “düştü” diye işaretler, yakalamazsa “geçti” yazar. Kurulum her olgu için yeniden çalışır — bir olgunun bıraktığı durum bir sonrakine sızmaz — ve sökme, olgu geçse de düşse de çalışır; aksi hâlde bir olgunun düşmesi bir sonrakinin kurulumunu bozardı. Aşağıdaki koşturucu bu üç çağrıyı ve iddiayı elle kurar.

// Cerceve.java — kurulum, govde ve sokme cagrilarini sirayla yapan kucuk bir kosturucu
public class Cerceve {

    static void olguKosustur(String ad, Runnable kurulum, Runnable govde, Runnable sokme) {
        kurulum.run();
        try {
            govde.run();
            System.out.println(pad(ad) + "gecti");
        } catch (AssertionError hata) {
            System.out.println(pad(ad) + "dustu: " + hata.getMessage());
        } finally {
            sokme.run();
        }
    }

    static void iddiaEsit(String alan, int beklenen, int gelen) {
        if (beklenen != gelen) throw new AssertionError(alan + " (beklenen " + beklenen + ", gelen " + gelen + ")");
    }

    static String pad(String s) { return String.format("%-16s", s); }

    public static void main(String[] args) {
        int[] miktar = {0};
        olguKosustur("depo-dolu",
                () -> miktar[0] = 10,
                () -> iddiaEsit("miktar", 10, miktar[0]),
                () -> miktar[0] = 0);

        int[] alan = {0};
        Runnable kurulum = () -> alan[0] = 5;
        Runnable sokme = () -> alan[0] = 0;

        olguKosustur("once-bakan", kurulum, () -> iddiaEsit("alan", 5, alan[0]), sokme);

        kurulum.run();
        olguKosustur("sonra-bakan", () -> {}, () -> { sokme.run(); iddiaEsit("alan", 5, alan[0]); }, () -> {});
    }
}
depo-dolu       gecti
once-bakan      gecti
sonra-bakan     dustu: alan (beklenen 5, gelen 0)

İlk olgu sıradan bir geçiş: kurulum miktar’ı 10 yapıyor, gövde bunu okuyup doğruluyor, sökme sıfırlıyor. Asıl soru ikinci ve üçüncü olguda. Her ikisi de aynı iddiayı taşıyor — alan’ın değeri 5 olmalı — ama biri iddiayı sökmeden önce, öteki sökmeden sonra kontrol ediyor. Sökme alan’ı sıfırlıyor; bu yüzden aynı iddia birinci olguda geçiyor, ikincisinde düşüyor. Koşturucunun kendisi bu ayrımı bilmiyor — o yalnız üç çağrıyı sırayla yapıyor ve gövdenin fırlattığı istisnayı yakalıyor. once-bakan ve sonra-bakan çıktıda ikisi de birer satır olarak görünüyor, ikisi de koşturucunun listesinde “koştu” sayılıyor; aralarındaki fark yalnız sonuç sütununda okunuyor.

Buradan çıkan sonuç şudur: çerçevenin çağırdığı sıra bir garanti değil, bir sözleşmedir. Kurulum-gövde-sökme sırası her zaman aynı kalır, ama gövdenin ne zaman okuduğu — kurulumun bıraktığı durumu mu, sökmenin bıraktığı durumu mu — yazan kişinin elindedir. Aynı iddia metni, iki farklı konumda yazıldığında iki farklı gerçeği ölçer, ve çerçeve ikisi arasında hakemlik yapmaz; yalnız hangisinin fırlattığını, hangisinin fırlatmadığını kaydeder. Bir olgunun “koştu” sayılması, o olgunun doğru kurulduğu anlamına gelmez — yalnız üç çağrının sırayla yapıldığı ve bir sonucun üretildiği anlamına gelir. M21/K02’nin kurduğu test sınıflandırması ve iddia yazımı burada değişmiyor; değişen tek şey, iddianın gövde içinde nereye yazıldığının sonucu belirlediğidir.

Bu ayrımın kaynağı finally bloğudur: sökme çağrısı gövdenin sonucundan bağımsız olarak çalışır, çünkü koşturucu onu try bloğunun dışına değil, finally içine koymuştur. Sökme adımı bu yüzden hem depo-dolu gibi geçen bir olguda hem sonra-bakan gibi düşen bir olguda aynı şekilde işler — alan’ı sıfırlar. Fark, iddianın bu sıfırlamadan önce mi sonra mı çalıştığındadır, ve bu bilgi koşturucunun kendisinde değil, gövdeyi yazan kişinin kararındadır. Gerçek bir çerçevede de durum aynıdır: kurulum ve sökmenin çağrılma sırası belgelenir ve garanti edilir, ama gövdenin o sırayı nasıl kullandığı — hangi anı, hangi durumu okuduğu — çerçevenin denetiminde değildir.

Test Sınıf Yolu: Üçüncü Bir Yol

  • TS3. Derleme sınıf yolu ve koşum sınıf yolu ayrımı önceki konudan (java-build) devralınır ve burada yeniden kurulmaz; bu derste eklenen tek soru, üçüncü bir sınıf yolunun neyi taşıdığıdır.
  • TS4. “Tip sayısı” burada derlenmiş .class dosyalarının sayımıdır. Aşağıdaki üç kaynağın her biri iç sınıf içermediği için tam olarak bir .class dosyası üretir; sayım bu yüzden kaynak sayısıyla örtüşüyor, ama genel olarak tip sayısı kaynak dosyası sayısından farklı çıkabilir.
  • TS5. “Çerçeve tipi olmadan” koşum, gerçek bir sınıf yükleyicisinin, verilen sınıf yolunda o tipi arayıp bulamamasıdır — yükleyici burada URLClassLoader’dır ve platform sınıf yükleyicisinin üstüne kurulur, böylece uygulamanın kendi sınıf yolundaki hiçbir şeyi miras almaz.

Yukarıdaki koşturucu ve içindeki iddia yardımcısı Cerceve sınıfının bir parçasıydı — ama gerçek bir programda bu tür bir yardımcı, sınanan koddan ayrı bir yerde durur. Aşağıdaki ölçüm üç ayrı sınıfı gerçekten derliyor: Depo (üretim kodu), Iddia (çerçevenin kendisi, yalnız testin ihtiyaç duyduğu bir eşitlik kontrolü) ve DepoTest (ikisini de kullanan test). Depo yalnız kendi sınıf yolunda derleniyor; Iddia ayrı bir dizinde derleniyor; DepoTest ikisine karşı derleniyor. Sonra iki ayrı sınıf yoluyla koşturuluyor: biri üçünü de içeriyor, öteki Iddia’yı dışarıda bırakıyor.

// UcuncuYol.java — test sinif yolunun uretim sinif yolundan fazladan tasidigi tipler gercekten sayilir
import java.io.*;
import java.lang.reflect.InvocationTargetException;
import java.net.*;
import java.nio.file.*;
import java.util.*;
import java.util.spi.ToolProvider;

public class UcuncuYol {

    static Path yaz(Path dizin, String ad, String kaynak) throws IOException {
        Files.createDirectories(dizin);
        Path p = dizin.resolve(ad + ".java");
        Files.writeString(p, kaynak);
        return p;
    }

    static boolean derle(Path cikti, List<Path> sinifYolu, List<Path> kaynaklar) throws IOException {
        Files.createDirectories(cikti);
        List<String> arg = new ArrayList<>(List.of("-d", cikti.toString()));
        if (!sinifYolu.isEmpty()) {
            StringJoiner sj = new StringJoiner(File.pathSeparator);
            for (Path p : sinifYolu) sj.add(p.toString());
            arg.add("-classpath");
            arg.add(sj.toString());
        }
        for (Path k : kaynaklar) arg.add(k.toString());
        ToolProvider derleyici = ToolProvider.findFirst("javac").orElseThrow();
        StringWriter sessizCikti = new StringWriter();
        return derleyici.run(new PrintWriter(sessizCikti), new PrintWriter(sessizCikti),
                arg.toArray(new String[0])) == 0;
    }

    static String kos(List<Path> sinifYolu, String anaSinif) {
        URL[] url = new URL[sinifYolu.size()];
        try {
            for (int i = 0; i < url.length; i++) url[i] = sinifYolu.get(i).toUri().toURL();
            try (URLClassLoader yukleyici = new URLClassLoader(url, UcuncuYol.class.getClassLoader().getPlatformClassLoader())) {
                Class<?> sinif = Class.forName(anaSinif, true, yukleyici);
                sinif.getMethod("main", String[].class).invoke(null, (Object) new String[0]);
                return "tamam";
            }
        } catch (InvocationTargetException e) {
            return e.getCause().getClass().getSimpleName();
        } catch (Throwable t) {
            return t.getClass().getSimpleName();
        }
    }

    static long tipSayisi(Path dizin) throws IOException {
        try (var akis = Files.walk(dizin)) {
            return akis.filter(p -> p.toString().endsWith(".class")).count();
        }
    }

    public static void main(String[] args) throws Exception {
        Path kok = Files.createTempDirectory("ucuncuyol");
        Path kaynak = kok.resolve("kaynak");
        Path uretimD = kok.resolve("uretim"), cercD = kok.resolve("cerceve"), testD = kok.resolve("test");

        Path sDepo = yaz(kaynak, "Depo",
                "public class Depo {\n"
              + "  private int miktar;\n"
              + "  public Depo(int b) { miktar = b; }\n"
              + "  public void ekle(int adet) { miktar += adet; }\n"
              + "  public void azalt(int adet) {\n"
              + "    if (adet > miktar) throw new IllegalStateException(\"yetersiz miktar\");\n"
              + "    miktar -= adet;\n"
              + "  }\n"
              + "  public int miktar() { return miktar; }\n"
              + "}\n");
        Path sIddia = yaz(kaynak, "Iddia",
                "public class Iddia {\n"
              + "  public static void esit(int beklenen, int gelen) {\n"
              + "    if (beklenen != gelen) throw new AssertionError(\"beklenen \" + beklenen + \", gelen \" + gelen);\n"
              + "  }\n"
              + "}\n");
        Path sDepoTest = yaz(kaynak, "DepoTest",
                "public class DepoTest {\n"
              + "  public static void main(String[] a) {\n"
              + "    Depo d = new Depo(10);\n"
              + "    d.ekle(5);\n"
              + "    d.azalt(3);\n"
              + "    Iddia.esit(12, d.miktar());\n"
              + "    System.out.println(\"olgu gecti\");\n"
              + "  }\n"
              + "}\n");

        derle(uretimD, List.of(), List.of(sDepo));
        derle(cercD, List.of(), List.of(sIddia));
        derle(testD, List.of(uretimD, cercD), List.of(sDepoTest));

        System.out.println("uretim sinif yolundaki tip sayisi : " + tipSayisi(uretimD));
        System.out.println("test sinif yolundaki tip sayisi   : "
                + (tipSayisi(uretimD) + tipSayisi(cercD) + tipSayisi(testD)));

        System.out.println("tam test sinif yoluyla kosum        : " + kos(List.of(uretimD, cercD, testD), "DepoTest"));
        System.out.println("cerceve tipi olmadan (uretim+test)  : " + kos(List.of(uretimD, testD), "DepoTest"));
    }
}
uretim sinif yolundaki tip sayisi : 1
test sinif yolundaki tip sayisi   : 3
olgu gecti
tam test sinif yoluyla kosum        : tamam
cerceve tipi olmadan (uretim+test)  : NoClassDefFoundError

Sayılar iddiayı doğruluyor. Depo tek başına derlenip koştuğunda üretim sınıf yolu tek bir tip taşıyor. DepoTest’i derlemek ve koşturmak için gereken tam küme ise üç tip: üretim kodu, çerçevenin kendisi ve testin kendisi. Bu fazladan iki tip — Iddia ve DepoTest — üretim programının hiçbir yerinde geçmiyor; dağıtılan çalıştırılabilir çıktıya konmaları gerekmiyor, konursa gereksiz büyür. Test sınıf yolu bu yüzden üretim sınıf yolunun bir üst kümesi değil, ayrı bir yoldur: üretimin ihtiyaç duymadığı tipleri taşır ve üretimin taşıdığı tipleri de kapsar.

Yön tek taraflıdır. DepoTest’i derlemek Depo’yu ve Iddia’yı sınıf yoluna koymayı gerektiriyor, çünkü test ikisini de kaynağında adıyla anıyor. Depo’yu derlemek ise ne Iddia’ya ne DepoTest’e ihtiyaç duyuyor — üretim kodu test tiplerinin varlığından habersiz yazıldı. Paketleme konusunun kurduğu ayrım burada aynen geçerli: bir çalıştırılabilir çıktı üretim sınıf yolundan üretiliyorsa, içine Iddia ya da DepoTest gibi bir tip hiç girmiyor; girerse, o çıktı gereğinden büyük ve gereğinden çok tip taşıyor demektir. Test sınıf yolunun üçüncü bir yol olması, üretim çıktısına karışmaması gerektiği anlamına da gelir — iki yolun birbirine karışması, sızmış bir bağımlılığın en sık görülen biçimidir.

Üç sınıf yolunu yan yana koyunca sıralama netleşiyor: derleme sınıf yolu DepoTest’i derlemek için Depo ile Iddia’yı ister, koşum sınıf yolu yalnız Depo’yu taşır ve Iddia’dan hiç haberdar değildir, test sınıf yolu ise üçünü birden taşır. Üç yol da aynı diskteki aynı .class dosyalarına işaret edebilir; aralarındaki fark dosyaların içeriğinde değil, hangi komuta hangi dizinlerin verildiğindedir. Bir tipin “test sınıf yolunda gerekli” olması, o tipin üretim kodunda hiç yazılmadığı, yalnız testin kendi ihtiyacından doğduğu anlamına gelir — ve bu ayrım, iki yolu gerçekten ayrı ayrı kurmadan görünmez.

Bu derste Iddia ve DepoTest derleme zamanında birbirine bağımlıydı, ama koşum zamanında Iddia’nın koşum sınıf yolunda bulunması derlemeden ayrı bir zorunluluktu — nitekim son satır bunu ayırıyor. Sıradaki ders bu gözlemi bir adım öteye taşıyor: testin ihtiyaç duyduğu tip, sınanan kodun zaten kullandığı bir tipin yerine geçtiğinde, aynı derlenmiş test iki ayrı koşum sınıf yoluyla iki ayrı sonuç verebilir.

Son iki satır bunu koşarak gösteriyor. DepoTest tam sınıf yoluyla — üretim, çerçeve ve test tipleri bir arada — çalıştırıldığında Iddia.esit çağrısı bulunuyor ve olgu “tamam” dönüyor. Aynı DepoTest.class dosyası, bu kez yalnız üretim ve test dizinleriyle, çerçeve dizini olmadan çalıştırıldığında yükleyici Iddia adını hiçbir yerde bulamıyor ve NoClassDefFoundError fırlatıyor. Dosyanın kendisi değişmedi, yeniden derlenmedi; yalnız kendisine verilen sınıf yolu daraldı. Bu, “test sınıf yolu üçüncü bir yoldur” iddiasının en somut kanıtı: çerçevenin sağladığı tip, testin derlenmesi için de koşması için de gerekli, ama üretimin ne derlenmesi ne koşması için gerekli.

Özet

  • Test çerçevesi burada kurulum, gövde ve sökme çağrılarını sırayla yapan, iddia başarısızlığını bir istisnayla yakalayan küçük bir koşturucu olarak modellendi.
  • Aynı iddia sökmeden önce kontrol edildiğinde geçti, sökmeden sonra kontrol edildiğinde düştü; koşturucu ikisini de “koştu” saydı, sıranın kendisi doğruluğu garanti etmedi.
  • Üretim sınıf yolu tek bir tip taşıdı; test sınıf yolu aynı tipe ek olarak çerçevenin ve testin kendi tiplerini taşıdı — toplam üç tip.
  • Aynı derlenmiş DepoTest.class, çerçeve tipi eksik bir sınıf yoluyla koşturulunca yeniden derlenmeden NoClassDefFoundError verdi; dosya değişmedi, yalnız sınıf yolu değişti.
  • Test sınıflandırması ve iddia yazımı M21/K02 Birim Testi ve Test Odaklı Geliştirme kursunda kuruldu ve burada tekrarlanmadı.

Sonraki Adım

Bu derste test sınıf yoluna giren tipler kendi elimizle yazdığımız, sınanan koddan tamamen ayrı duran tiplerdi. Ama testin ihtiyaç duyduğu bir bağımlılık, üretim kodunun zaten kullandığı bir tipin yerini almak zorunda kalabilir — aynı adı taşıyan, aynı imzayı sağlayan ama farklı davranan bir tip. Sıradaki ders aynı derlenmiş testi, gerçek tipin durduğu bir sınıf yoluna karşı bir kez, aynı adı taşıyan sahte tipin durduğu başka bir sınıf yoluna karşı bir daha koşturur ve test yeniden derlenmeden iki ayrı sonuç aldığını sayar.

İlerlemeni kaydetmek ve not almak için Giriş yap

Notlarım

Not almak için giriş yapmalısın.

Aramak için yazmaya başlayın.

↑↓ Esc gezin · aç · kapat