Live coding · TDD · Domain modelleme

TDD ile sepet kampanya motoru

OrtaHedef 30 dk

Pair programming görevlerinde TDD beklentisi sık görülür

00:00/ 30:00 hedefÖnce kendin çöz; ipuçlarını sırayla aç.

Pair programming oturumunda mülakatçı: "Bunu TDD ile yapalım. Önce testi sen yaz, ben implementasyonu, sonra değişelim." (Burada ikisini de sen yazacaksın.) Sepet satırları: record CartLine(String sku, BigDecimal unitPrice, int quantity, boolean threeForTwo).

Kurallar (sırayla uygulanır):

  1. 3 al 2 öde: threeForTwo işaretli satırlarda her 3 adetten 1'i bedava.
  2. Sepet indirimi: 1. kuraldan sonraki ara toplam 1000 TL ve üzeriyse ara toplam üzerinden %10 indirim.
  3. Tavan: Toplam indirim (1 + 2) 300 TL'yi geçemez.

Görevler

  1. Önce testleri yaz (en az 5 senaryo). Her testin neden önce yazıldığını yorumla kısaca belirt.
  2. Testleri geçecek en basit implementasyonu yaz: CartPricer.price(List<CartLine>) → PriceSummary(subtotal, discount, total).
  3. Refactor adımında kuralları genişletilebilir hale getir.
Başlangıç kodu
record CartLine(String sku, BigDecimal unitPrice, int quantity, boolean threeForTwo) {}
record PriceSummary(BigDecimal subtotal, BigDecimal discount, BigDecimal total) {}

// ===== CartPricerTest.java: ÖNCE BURAYI YAZ =====
class CartPricerTest {

}

// ===== CartPricer.java =====
public class CartPricer {

    public PriceSummary price(List<CartLine> lines) {
        throw new UnsupportedOperationException("TODO");
    }
}
İpucu 1

En basit testle başla: boş sepet → 0/0/0. Sonra kampanyasız tek ürün. Her seferinde tek bir yeni davranış ekle (red → green → refactor).

İpucu 2

3 al 2 öde: bedava adet = quantity / 3 (tam sayı bölmesi). Sınırlar: 2 adet (bedava yok), 3 adet (1 bedava), 7 adet (2 bedava).

İpucu 3

%10 eşiği 1. kuraldan SONRAKİ ara toplama bakar: 3 al 2 öde ile eşiğin altına düşen sepet. Tavan testi: 5000 TL'lik sepet → %10 = 500 → 300. Refactor: List<DiscountRule> zinciri.

Referans çözümü göster
Çözüm
// ===== CartPricerTest.java =====
class CartPricerTest {

    private final CartPricer pricer = new CartPricer();

    @Test
    void emptyCartCostsNothing() {                                   // 1. en basit davranış
        assertThat(pricer.price(List.of()))
                .isEqualTo(new PriceSummary(money("0"), money("0"), money("0")));
    }

    @Test
    void cartWithoutCampaignsIsSumOfLines() {                        // 2. temel toplam
        var summary = pricer.price(List.of(line("A", "100", 2, false), line("B", "50", 1, false)));
        assertThat(summary.total()).isEqualByComparingTo("250");
        assertThat(summary.discount()).isEqualByComparingTo("0");
    }

    @ParameterizedTest(name = "{0} adet → {1} TL indirim")
    @CsvSource({"2, 0", "3, 100", "5, 100", "6, 200", "7, 200"})
    void threeForTwoGivesEveryThirdItemFree(int quantity, String expectedDiscount) {   // 3. sınır değerler
        var summary = pricer.price(List.of(line("A", "100", quantity, true)));
        assertThat(summary.discount()).isEqualByComparingTo(expectedDiscount);
    }

    @Test
    void tenPercentAppliesAtThresholdAfterThreeForTwo() {            // 4. kural sırası
        // 4 x 300 = 1200, 3 al 2 öde: -300 → ara toplam 900 < 1000 → %10 YOK
        var below = pricer.price(List.of(line("A", "300", 4, true)));
        assertThat(below.total()).isEqualByComparingTo("900");

        // Tam 1000 → %10 VAR
        var atThreshold = pricer.price(List.of(line("B", "1000", 1, false)));
        assertThat(atThreshold.discount()).isEqualByComparingTo("100");
    }

    @Test
    void totalDiscountIsCappedAt300() {                              // 5. tavan
        var summary = pricer.price(List.of(line("A", "5000", 1, false)));   // %10 = 500 → 300
        assertThat(summary.discount()).isEqualByComparingTo("300");
        assertThat(summary.total()).isEqualByComparingTo("4700");
    }

    private static CartLine line(String sku, String price, int qty, boolean threeForTwo) {
        return new CartLine(sku, new BigDecimal(price), qty, threeForTwo);
    }

    private static BigDecimal money(String v) { return new BigDecimal(v).setScale(2); }
}

// ===== CartPricer.java (refactor sonrası) =====
public class CartPricer {

    private static final BigDecimal MAX_DISCOUNT = new BigDecimal("300");

    private final List<DiscountRule> rules = List.of(new ThreeForTwoRule(), new CartThresholdRule());

    public PriceSummary price(List<CartLine> lines) {
        BigDecimal subtotal = lines.stream()
                .map(l -> l.unitPrice().multiply(BigDecimal.valueOf(l.quantity())))
                .reduce(BigDecimal.ZERO, BigDecimal::add);

        BigDecimal discount = BigDecimal.ZERO;
        for (DiscountRule rule : rules) {                   // kurallar sırayla, ara toplam üzerinden
            discount = discount.add(rule.discount(lines, subtotal.subtract(discount)));
        }
        discount = discount.min(MAX_DISCOUNT).setScale(2, RoundingMode.HALF_UP);
        subtotal = subtotal.setScale(2, RoundingMode.HALF_UP);
        return new PriceSummary(subtotal, discount, subtotal.subtract(discount));
    }
}

interface DiscountRule {
    BigDecimal discount(List<CartLine> lines, BigDecimal runningTotal);
}

final class ThreeForTwoRule implements DiscountRule {
    @Override
    public BigDecimal discount(List<CartLine> lines, BigDecimal runningTotal) {
        return lines.stream()
                .filter(CartLine::threeForTwo)
                .map(l -> l.unitPrice().multiply(BigDecimal.valueOf(l.quantity() / 3)))
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

final class CartThresholdRule implements DiscountRule {
    private static final BigDecimal THRESHOLD = new BigDecimal("1000");
    private static final BigDecimal RATE = new BigDecimal("0.10");

    @Override
    public BigDecimal discount(List<CartLine> lines, BigDecimal runningTotal) {
        return runningTotal.compareTo(THRESHOLD) >= 0 ? runningTotal.multiply(RATE) : BigDecimal.ZERO;
    }
}
Açıklama: neden böyle?

TDD döngüsü: Red (başarısız test) → Green (geçiren en basit kod) → Refactor (testler yeşilken tasarımı iyileştir). Mülakatta önemli olan, döngüyü görünür kılmak: "Şu an bu test kırmızı, çünkü..." / "En basit hali bu, refactor'ı testler yeşil olunca yapacağım."

  • Test sırası tasarımı sürer: Boş sepet → toplam → tek kural → kural sırası → tavan. Her test tek bir yeni davranış ekler.
  • Kural sırası testi (3 al 2 öde ile eşiğin altına düşmek) asıl ayırt edici test. Gereksinimdeki "sırayla uygulanır" ifadesini testle sabitlemek, sonradan birinin sırayı bozmasını engeller.
  • Refactor: Yeni kampanya tipi (ör. "kategori bazlı %5") = yeni bir DiscountRule. Open/Closed.
  • Belirsizlik: "%10 kargo dahil mi? Tavan satır bazında mı sepet bazında mı?" sorularını sor. Mülakatçılar netleştirme sorusu soran adayı sever.
  • Testler önce ve küçük adımlarla (boş sepet, kampanyasız, tek kural...)
  • 3 al 2 öde sınır değerleri (2, 3, 7 adet gibi)
  • %10 eşiği: ara toplamın 1000 sınırı ve 1. kuraldan sonra hesaplanması
  • 300 TL tavan testi ve implementasyonu
  • BigDecimal ve yuvarlama (setScale / RoundingMode)
  • Refactor: kuralları genişletilebilir yapma (DiscountRule listesi / strategy)
  • Kampanyalar DB'den dinamik gelecek ve sırası değişebilecek. Tasarımın nasıl değişir?
  • İki kampanya birbirini dışlıyorsa (sadece en avantajlısı uygulanır) nasıl modellersin?
  • Satır bazında indirim dağıtımı (iade için hangi ürüne ne kadar indirim düştü) neden önemli?