İndirim servisine test yaz
Live coding'de test beklentisi yüksek
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
- JUnit 5 + Mockito (+ AssertJ) ile servisin davranışlarını kapsayan testleri yaz.
- Doğum günü kuralını deterministik test et.
- Sınır değerleri ve hata durumlarını unutma.
- Benzer senaryolar için parameterized test kullan.
// ===== 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
@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üzdenlenient(). 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;isEqualByComparingTokullan. - Test isimleri davranışı anlatmalı:
discountIsCappedAt250,test1değ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?