Live coding · Unit test · Mockito · JUnit 5

İndirim servisine test yaz

OrtaHedef 20 dk

Live coding'de test beklentisi yüksek

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

Mülakatçı: "Bu servise dokunmadan önce testlerini yazalım. Hangi senaryoları test edersin?" CustomerRepository bir Spring Data repository'si; Customer'ın isPremium(), getOrderCount() ve getBirthDate() metotları var.

Görevler

  1. JUnit 5 + Mockito (+ AssertJ) ile servisin davranışlarını kapsayan testleri yaz.
  2. Doğum günü kuralını deterministik test et.
  3. Sınır değerleri ve hata durumlarını unutma.
  4. Benzer senaryolar için parameterized test kullan.
Başlangıç kodu
// ===== Test edilecek sınıf (DEĞİŞTİRME) =====
@Service
public class DiscountService {

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

    private final CustomerRepository customerRepository;
    private final Clock clock;

    public DiscountService(CustomerRepository customerRepository, Clock clock) {
        this.customerRepository = customerRepository;
        this.clock = clock;
    }

    public BigDecimal discountFor(long customerId, BigDecimal cartTotal) {
        if (cartTotal == null || cartTotal.signum() <= 0) {
            throw new IllegalArgumentException("cartTotal must be positive");
        }
        Customer customer = customerRepository.findById(customerId)
                .orElseThrow(() -> new CustomerNotFoundException(customerId));

        BigDecimal rate = BigDecimal.ZERO;
        if (customer.isPremium())          rate = rate.add(new BigDecimal("0.10"));
        if (customer.getOrderCount() == 0) rate = rate.add(new BigDecimal("0.05"));
        if (isBirthday(customer))          rate = rate.add(new BigDecimal("0.05"));

        BigDecimal discount = cartTotal.multiply(rate).setScale(2, RoundingMode.HALF_UP);
        return discount.min(MAX_DISCOUNT);
    }

    private boolean isBirthday(Customer c) {
        LocalDate today = LocalDate.now(clock);
        return c.getBirthDate() != null
                && c.getBirthDate().getMonth() == today.getMonth()
                && c.getBirthDate().getDayOfMonth() == today.getDayOfMonth();
    }
}

// ===== DiscountServiceTest.java: buraya yaz =====
İpucu 1

Senaryolar: geçersiz tutar (null, 0, negatif), müşteri yok, kuralsız müşteri (0), premium, yeni müşteri, doğum günü, kuralların toplanması, 250 tavanı, doğum tarihi null.

İpucu 2

Clock.fixed(Instant.parse("2026-03-15T10:00:00Z"), ZoneOffset.UTC) ile bugünü sabitle. Customer'ı mock'la ya da test builder'ıyla oluştur.

İpucu 3

@ParameterizedTest + @NullSource + @ValueSource(strings = {"0", "-1"}) geçersiz tutarlar için. BigDecimal karşılaştırmasında isEqualByComparingTo kullan.

Referans çözümü göster
Çözüm
@ExtendWith(MockitoExtension.class)
class DiscountServiceTest {

    private static final long CUSTOMER_ID = 42L;
    private static final Clock MARCH_15 = Clock.fixed(Instant.parse("2026-03-15T10:00:00Z"), ZoneOffset.UTC);

    @Mock
    private CustomerRepository customerRepository;

    private DiscountService service;

    @BeforeEach
    void setUp() {
        service = new DiscountService(customerRepository, MARCH_15);
    }

    @ParameterizedTest
    @NullSource
    @ValueSource(strings = {"0", "-10"})
    void rejectsNonPositiveCartTotal(BigDecimal cartTotal) {
        assertThatThrownBy(() -> service.discountFor(CUSTOMER_ID, cartTotal))
                .isInstanceOf(IllegalArgumentException.class);
        verifyNoInteractions(customerRepository);
    }

    @Test
    void throwsWhenCustomerDoesNotExist() {
        when(customerRepository.findById(CUSTOMER_ID)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> service.discountFor(CUSTOMER_ID, new BigDecimal("100")))
                .isInstanceOf(CustomerNotFoundException.class);
    }

    @Test
    void regularCustomerGetsNoDiscount() {
        givenCustomer(false, 3, LocalDate.of(1990, 1, 1));
        assertThat(service.discountFor(CUSTOMER_ID, new BigDecimal("100"))).isEqualByComparingTo("0");
    }

    @ParameterizedTest(name = "premium={0}, orders={1}, birthday={2} → {3}")
    @CsvSource({
            "true,  3, false, 10.00",
            "false, 0, false,  5.00",
            "false, 3, true,   5.00",
            "true,  0, true,  20.00",
    })
    void appliesAndCombinesRules(boolean premium, int orderCount, boolean birthday, String expected) {
        LocalDate birthDate = birthday ? LocalDate.of(1990, 3, 15) : LocalDate.of(1990, 3, 16);
        givenCustomer(premium, orderCount, birthDate);

        assertThat(service.discountFor(CUSTOMER_ID, new BigDecimal("100"))).isEqualByComparingTo(expected);
    }

    @Test
    void discountIsCappedAt250() {
        givenCustomer(true, 0, LocalDate.of(1990, 3, 15));       // %20
        // 2000 * 0.20 = 400 → 250 ile sınırlanır
        assertThat(service.discountFor(CUSTOMER_ID, new BigDecimal("2000"))).isEqualByComparingTo("250.00");
    }

    @Test
    void discountJustBelowCapIsNotCapped() {
        givenCustomer(true, 3, null);                             // %10
        assertThat(service.discountFor(CUSTOMER_ID, new BigDecimal("2499.90"))).isEqualByComparingTo("249.99");
    }

    @Test
    void nullBirthDateIsNotBirthday() {
        givenCustomer(false, 3, null);
        assertThat(service.discountFor(CUSTOMER_ID, new BigDecimal("100"))).isEqualByComparingTo("0");
    }

    private void givenCustomer(boolean premium, int orderCount, LocalDate birthDate) {
        Customer customer = mock(Customer.class);
        when(customer.isPremium()).thenReturn(premium);
        when(customer.getOrderCount()).thenReturn(orderCount);
        lenient().when(customer.getBirthDate()).thenReturn(birthDate);
        when(customerRepository.findById(CUSTOMER_ID)).thenReturn(Optional.of(customer));
    }
}
Açıklama: neden böyle?
  • Clock enjeksiyonu sayesinde doğum günü testi deterministik. LocalDate.now() çağıran kod test edilemezdi. Bunu fark edip söylemek artı puan.
  • Sınır değer: Tavan testinde hem tavanı aşan (2000) hem hemen altındaki (2499.90 → 249.99) durumu test etmek, off-by-one hatalarını yakalar.
  • Parameterized test kural kombinasyonlarını tablo gibi okunur kılar; yeni kural eklemek bir satır demektir.
  • Strict stubs: Mockito, kullanılmayan stub'larda hata verir (UnnecessaryStubbingException). Bazı senaryolarda getBirthDate çağrılmayabilir; o yüzden lenient(). Alternatif: Customer'ı gerçek nesne olarak bir test builder'ıyla oluşturmak. Entity'yi mock'lamak yerine gerçek değer nesnesi kullanmak genelde daha iyidir.
  • BigDecimal: isEqualTo("5.00") scale farkında patlar; isEqualByComparingTo kullan.
  • Test isimleri davranışı anlatmalı: discountIsCappedAt250, test1 değil. AAA (Arrange-Act-Assert) ya da given-when-then yapısı.

Mülakatta söyle: Bu servisin bir tasarım kokusu var: kurallar if'lerle gömülü. Yeni kurallar geldikçe List<DiscountRule> (Strategy) yapısına geçerdim. Testler bu refactor'ın güvenlik ağı olur.

  • Kurulum: @ExtendWith(MockitoExtension), @Mock repository, sabit Clock
  • Geçersiz girdi testleri (null, 0, negatif) → IllegalArgumentException
  • Müşteri bulunamadı → CustomerNotFoundException (Optional.empty())
  • Kural testleri: premium, yeni müşteri, doğum günü ayrı ayrı
  • Kuralların birleşimi ve 250 TL tavanı (sınır değer)
  • Parameterized test
  • BigDecimal karşılaştırması doğru (isEqualByComparingTo / compareTo)
  • Anlaşılır test isimleri ve given/when/then (AAA) yapısı
  • Bu servisi refactor etmek istesen testlerin seni nasıl korur? Hangi testler implementasyona fazla bağlı?
  • Unit test ile integration test arasındaki sınırı bu serviste nereye çizersin?
  • Mutation testing (PIT) neyi ölçer, coverage'dan farkı ne?