Test stratejisi, Mockito, Testcontainers ve TDD
Hangi davranışın hangi seviyede test edileceğini, JUnit 5, AssertJ, Mockito ve Spring test dilimleriyle doğru testi yazmayı, Testcontainers ile gerçek altyapıda doğrulamayı ve live coding'de TDD'yi görünür biçimde uygulamayı öğretir.
Bu bölümde
Bir davranışı hangi seviyede (unit, integration, contract, uçtan uca) test edeceğine nasıl karar verileceğini, Mockito'yu nerede kullanıp nerede kullanmayacağını, Spring'in test dilimlerinin maliyetini ve Testcontainers'ın neden H2'nin yerini aldığını öğreneceksin. Son kısımda TDD'yi kata k4 üzerinden adım adım uygulayacağız.
Test kültürü güçlü şirketlerde test bilgisi üç yerde ölçülür: live coding'de yazdığın testlerin kalitesi (isim, sınır değer, hata yolu), "bu sınıfı nasıl test edersin" türü kod okuma soruları ve "unit ile integration testin sınırı nerede" gibi strateji soruları. Mülakatçı test yazıp yazmadığından çok, neyi neden test ettiğine bakar.
Konu anlatımı
Testin üç işi
Test sadece hata yakalamaz. Üç ayrı değer üretir:
- Regresyon güvencesi: Bugün çalışan davranışın yarın bozulmadığını söyler.
- Refactoring güvenliği: Davranışı sabitlediği için tasarımı değiştirmeye cesaret verir. Implementasyona bağlı testler bu değeri yok eder: her refactor'da kırılırlar ve gerçek bir hatayı gürültüden ayırmak imkânsızlaşır.
- Tasarım geri bildirimi: Test yazmak zorsa genelde tasarım kötüdür. Bir sınıfı test etmek için yedi mock gerekiyorsa sınıf çok şey yapıyordur;
LocalDateTime.now()çağıran kod test edilemiyorsa gizli bir bağımlılık vardır.
İyi bir test bu üçünü birden sağlar: davranışı doğrular, implementasyonu değil.
Test piramidi, honeycomb ve maliyet
Mike Cohn'un test piramidi basit bir ekonomik argümandır: alttaki testler hızlı, ucuz ve deterministiktir, bu yüzden çok sayıda yazılır; üste çıktıkça testler yavaşlar, kırılganlaşır ve hata yerini daha belirsiz gösterir, bu yüzden azalır.
flowchart TB
E2E["Uçtan uca (az): gerçek kullanıcı akışı, birden çok servis"]
INT["Integration (orta): servis + gerçek DB/Kafka, HTTP katmanı"]
UNIT["Unit (çok): tek sınıf ya da küçük bir sınıf grubu, bellek içinde"]
E2E --> INT --> UNITMikroservislerde bu şekil tartışılır. Spotify'ın testing honeycomb yaklaşımı, servis içi mantığın çoğunlukla ince olduğu (al, dönüştür, kaydet, yayınla) sistemlerde ağırlığın servisin kendi sınırında yapılan integration testlerine kayması gerektiğini söyler: servisi gerçek veritabanı ve mesajlaşmayla ayağa kaldırıp dış arayüzünden test etmek. Karmaşık ve kendi içinde izole mantık (fiyatlama motoru, kural değerlendirme) için implementation detail testleri az sayıda kalır; başka servislerin doğru çalışmasına bağlı "integrated" testlerden ise mümkünse kaçınılır.
İki yaklaşım çelişmez. Asıl soru şudur: bu davranıştaki risk nerede? Risk iş kuralındaysa (indirimlerin toplanması, tavan, sınır değerler) unit test; risk SQL'de, transaction sınırında, eşzamanlılıkta ya da serileştirmedeyse integration test; risk iki servisin sözleşmesindeyse contract test.
Test türleri
| Tür | Kapsam | Hız | Ne yakalar | Ne yakalayamaz |
|---|---|---|---|---|
| Unit | Bir sınıf veya küçük bir grup, bağımlılıklar test double | Milisaniye | İş kuralları, sınır değerler, hata dalları | SQL, transaction, serileştirme, framework davranışı |
| Integration | Servis + gerçek altyapı (Testcontainers) veya Spring katmanı | Saniye | Mapping, sorgular, kilitler, @Transactional, HTTP binding | Başka servislerin gerçek davranışı |
| Contract | Tüketici ile sağlayıcı arasındaki API/event sözleşmesi | Saniye | Alan adı değişiklikleri, kırıcı şema değişiklikleri | İş akışının tamamı |
| Uçtan uca | Birden çok servis, gerçek ortam | Dakika | Konfigürasyon ve entegrasyon hataları | Hatanın nerede olduğunu net göstermez |
"Unit" kelimesinin iki okulu vardır. Solitary (London, mockist) okul her bağımlılığı mock'lar. Sociable (Detroit, classicist) okul gerçek işbirlikçileri kullanır, sadece yavaş ya da dış dünyaya çıkan bağımlılıkları (DB, HTTP, saat) değiştirir. Senior bakış: "unit" bir sınıf değil bir davranış birimidir; CartPricer'ı test ederken onun kullandığı ThreeForTwoRule'u mock'lamak, testi implementasyona kilitler ve hiçbir şey kazandırmaz.
Test double türleri
Gerard Meszaros'un sınıflandırması (Martin Fowler'ın "Mocks Aren't Stubs" yazısıyla yaygınlaştı), mülakatta "mock ile stub farkı ne" sorusunun cevabıdır:
| Tür | Ne yapar | Örnek |
|---|---|---|
| Dummy | Sadece parametre listesini doldurur, kullanılmaz | Hiç çağrılmayan bir AuditLogger |
| Stub | Önceden belirlenmiş cevap döner | findById(42) için müşteri dön |
| Fake | Çalışan ama production'a uygun olmayan basit implementasyon | HashMap tabanlı InMemoryOrderRepository |
| Mock | Beklenen etkileşimi doğrular | "save hiç çağrılmamalı" |
| Spy | Gerçek nesneyi sarar, çağrıları kaydeder; seçili metotlar stub'lanabilir | Gerçek bir listeye yapılan add'leri saymak |
Fark doğrulama türündedir. Stub ve fake ile durum doğrulaması yapılır: işlemden sonra sonuca bakılır. Mock ile davranış doğrulaması yapılır: hangi çağrıların yapıldığına bakılır. Mockito'da tek bir mock() nesnesi hem stub hem mock olarak kullanılabilir; isim kütüphaneden değil kullanım biçiminden gelir.
Pratik kural: sorgulara stub, komutlara mock. Bir metot değer dönüyorsa (findById) onu stub'la ve sonucu doğrula; verify(repo).findById(42) yazmak çoğu zaman gereksizdir, çünkü doğru müşteri bulunmasaydı sonuç zaten yanlış olurdu. Bir metot dış dünyada yan etki yaratıyorsa (save, send, publish) ve bu yan etki davranışın kendisiyse, verify anlamlıdır.
JUnit 5 (Jupiter)
Spring Boot 3.x JUnit 5 ile gelir. JUnit 6.0 (Eylül 2025, Java 17 tabanı) platform, Jupiter ve Vintage sürümlerini birleştirdi ve JUnit 4 köprüsünü (Vintage) deprecated yaptı; JUnit 5.x ile Jupiter yazan ekipler için geçiş çoğunlukla bir bağımlılık güncellemesidir. Aşağıdaki API'ler iki sürümde de aynıdır.
Yaşam döngüsü. Jupiter varsayılan olarak her test metodu için yeni bir test sınıfı örneği oluşturur. Bu, testler arasında instance alanı üzerinden durum sızmasını engeller.
class LifecycleDemoTest {
@BeforeAll
static void startOnce() { } // sınıf başına bir kez, static olmalı
@BeforeEach
void setUp() { } // her testten önce, yeni örnek üzerinde
@Test
void someBehaviour() { }
@AfterEach
void tearDown() { }
@AfterAll
static void stopOnce() { }
}
@TestInstance(Lifecycle.PER_CLASS) ile sınıf başına tek örnek kullanılabilir (o zaman @BeforeAll static olmak zorunda değildir), ama testler arasında paylaşılan alan sızıntısı riskini geri getirir. Test metotlarının çalışma sırası deterministik ama bilerek belirsiz tutulmuştur; sıraya güvenen testler yanlıştır.
Assertion'lar ve exception. assertThrows yakalanan exception'ı döner; mesajı ya da alanları ayrıca doğrulanabilir:
var ex = assertThrows(InsufficientStockException.class,
() -> orderService.placeOrder(new PlaceOrderCommand(42L, 2)));
assertTrue(ex.getMessage().contains("productId=42"));
@Nested ilgili testleri bağlam bazında gruplar; test raporu bir spesifikasyon gibi okunur:
@DisplayName("CartPricer")
class CartPricerTest {
private final CartPricer pricer = new CartPricer();
@Nested
@DisplayName("3 al 2 öde")
class ThreeForTwo {
@Test
void givesNoFreeItemBelowThree() {
var summary = pricer.price(List.of(line("A", "100", 2, true)));
assertThat(summary.discount()).isEqualByComparingTo("0");
}
}
@Nested
@DisplayName("1000 TL eşiği")
class Threshold {
// ...
}
}
@ParameterizedTest aynı davranışı farklı girdilerle tablo gibi test eder. Kaynaklar: @ValueSource, @CsvSource, @MethodSource, @EnumSource, @NullSource, @EmptySource, @NullAndEmptySource. JUnit, String'i int, long, BigDecimal, enum, LocalDate gibi tiplere örtük olarak çevirir.
@ParameterizedTest(name = "{0} kg → {1} TL")
@CsvSource({
"5.00, 49.90", // eşiğin tam kendisi: standart
"5.01, 89.90", // eşiğin hemen üstü: ağır
"0.10, 49.90"
})
void arasFeeDependsOnWeightThreshold(BigDecimal weightKg, BigDecimal expectedFee) {
var fee = new ArasFeePolicy().feeFor(new ShipmentQuote(weightKg, BigDecimal.TEN));
assertThat(fee).isEqualByComparingTo(expectedFee);
}
AssertJ
AssertJ, Spring Boot'un test starter'ında gelir ve JUnit'in assertion'larından daha okunur, daha iyi hata mesajı veren akıcı (fluent) bir API sunar.
assertThat(order.getStatus()).isEqualTo(OrderStatus.CREATED);
assertThat(orders).hasSize(3).extracting(Order::getProductId).containsExactly(1L, 2L, 3L);
assertThatThrownBy(() -> registry.get("stripe"))
.isInstanceOf(UnsupportedProviderException.class)
.hasMessageContaining("stripe");
assertThat(actualDto).usingRecursiveComparison().ignoringFields("createdAt").isEqualTo(expectedDto);
BigDecimal tuzağı: new BigDecimal("5.0").equals(new BigDecimal("5.00")) false döner, çünkü equals scale'i de karşılaştırır. isEqualTo bu yüzden yanıltıcıdır; isEqualByComparingTo compareTo kullanır. Aynı tuzak record'larda da vardır: BigDecimal alanlı bir record'un equals'ı da scale'e duyarlıdır.
Birden fazla bağımsız özelliği tek testte doğruluyorsan SoftAssertions hepsini çalıştırıp hataları birlikte raporlar; ilk hatada durmaz.
Mockito
Temel API:
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock StockReservationService stockReservationService;
@Mock OrderRepository orderRepository;
@Mock OutboxEventRepository outboxEventRepository;
}
- Stub (dönüş değeri olan metot):
when(repo.findById(42L)).thenReturn(Optional.of(customer)). Ardışık çağrılar içinthenReturn(a, b), hesaplanmış cevap içinthenAnswer(inv -> inv.getArgument(0)). - Void metot:
when(...)içine void bir çağrı argüman olarak verilemez, derlenmez.do*ailesi kullanılır:doThrow(...).when(mock).reserve(1L, 2),doNothing(),doAnswer(...). BDD stilindewillThrow(...).given(mock).reserve(1L, 2). - Doğrulama:
verify(mock).save(any())(tam bir kez),verify(mock, times(2)),verify(mock, never()).save(any()),verifyNoInteractions(a, b)(hiç çağrı yok),verifyNoMoreInteractions(a)(doğrulananlar dışında çağrı yok; aşırı kullanılırsa testi implementasyona kilitler). - Argument matcher kuralı: bir argümanda matcher kullanırsan hepsinde kullanmalısın.
verify(stock).reserve(eq(42L), anyInt())doğru;reserve(42L, anyInt())InvalidUseOfMatchersExceptionverir. ArgumentCaptor: kaydedilen nesnenin içeriğini doğrulamak için.
@Test
void writesOrderCreatedEventWithOrderIdAsAggregateId() {
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
UUID orderId = orderService.placeOrder(new PlaceOrderCommand(42L, 2));
ArgumentCaptor<OutboxEvent> captor = ArgumentCaptor.forClass(OutboxEvent.class);
verify(outboxEventRepository).save(captor.capture());
OutboxEvent event = captor.getValue();
assertThat(event.getAggregateId()).isEqualTo(orderId.toString());
assertThat(event.getEventType()).isEqualTo("OrderCreated");
assertThat(event.getCreatedAt()).isEqualTo(Instant.parse("2026-10-01T10:00:00Z"));
}
InOrder: sıra davranışın parçasıysa (inOrder.verify(stock).reserve(...)sonrainOrder.verify(orderRepository).save(...)). Sıra sadece implementasyon detayıysa kullanma.
Strict stubs. MockitoExtension varsayılan olarak Strictness.STRICT_STUBS ile çalışır. İki şey yakalar: test boyunca hiç kullanılmayan bir stub (UnnecessaryStubbingException) ve bir stub'ın farklı argümanlarla çağrılması (PotentialStubbingProblem). Bunlar gürültü değil, değerli sinyallerdir: kullanılmayan stub, testin sandığın yoldan geçmediğini ya da kopyala-yapıştır artığı olduğunu gösterir. lenient() sadece gerçekten bazı parametre kombinasyonlarında çağrılmayan paylaşılan stub'lar için kullanılır; refleksle her stub'a eklemek strict stubs'ın faydasını yok eder.
Spy tuzağı. Spy gerçek nesneyi çağırır. when(spy.get(0)) ifadesi stub tanımlanmadan önce gerçek get(0)'ı çalıştırır:
List<String> list = spy(new ArrayList<>());
when(list.get(0)).thenReturn("x"); // IndexOutOfBoundsException: gerçek get(0) çağrılır
doReturn("x").when(list).get(0); // doğru: gerçek metot çağrılmaz
Spy ihtiyacı çoğu zaman bir tasarım kokusudur: test edilen sınıfın bir parçasını değiştirmek istiyorsan o parça muhtemelen ayrı bir bağımlılık olmalıdır.
Mockito 5 notları. Varsayılan mock maker inline'dır; final sınıflar ve metotlar mock'lanabilir, mockStatic ile static metotlar da (try-with-resources içinde). Bunun mümkün olması yapılması gerektiği anlamına gelmez: LocalDateTime.now()'ı mockStatic ile değiştirmek yerine Clock enjekte etmek daha iyi bir tasarımdır. Java 21+'da Mockito'nun çalışma anında agent yüklemesi uyarı üretir (JEP 451); Mockito dokümantasyonu build'de Mockito'yu -javaagent olarak tanımlamayı önerir.
Neyi mock'lamamalı
- Value object'ler ve record'lar:
Money,CartLine,PlaceOrderCommand. Gerçeğini oluşturmak mock'tan kısadır ve davranışı gerçektir. - Kendi domain mantığın:
CartPricer'ın kurallarını,Order.create'i. Mock'larsan test ettiğin şey kodun değil, mock kurulumun olur. - Entity'ler: k3'teki gibi
Customer'ı mock'lamak çalışır ama getter'ları tek tek stub'lamak hem gürültülü hem kırılgandır. Bir test data builder (aCustomer().premium().bornOn(...)) daha okunurdur. - Sahibi olmadığın tipler:
RestTemplate,KafkaTemplate,JdbcTemplate,ObjectMapper. Mock'larsan o kütüphanenin nasıl davrandığına dair kendi varsayımını test edersin ("Don't mock what you don't own"). Bunun yerine onları kendi küçük arayüzünün (PaymentGatewayClient,EventPublisher) arkasına sar; uygulama kodunda o arayüzü mock'la, sarmalayıcıyı ise gerçek kütüphaneyle integration testinde (MockRestServiceServer, WireMock, Testcontainers) test et. - Saat ve rastgelelik: mock'lama, enjekte et (
Clock,Supplier<UUID>).
Mock'lanacak şeyler: süreç dışına çıkan ya da yavaş olan sınırlar (repository, HTTP client, message publisher) ve test edilen davranışın bir parçası olan yan etkiler.
Spring test dilimleri
@SpringBootTest tüm uygulama context'ini yükler: bütün bean'ler, auto-configuration, bağlantılar. Gerçekçidir ama yavaştır. Spring Boot bu yüzden sadece bir katmanı yükleyen test dilimleri (slice) sunar:
| Anotasyon | Yükler | Tipik kullanım |
|---|---|---|
@WebMvcTest(OrderController.class) | MVC altyapısı, controller, @ControllerAdvice, filter, converter; servisler yüklenmez | Request binding, validation, status kodları, hata formatı |
@DataJpaTest | JPA, repository'ler, Flyway/DataSource; her test transaction içinde ve sonunda rollback | Sorgular, mapping, constraint'ler |
@JsonTest | Jackson konfigürasyonu | Serileştirme sözleşmesi |
@SpringBootTest | Her şey; webEnvironment varsayılanı MOCK (gerçek sunucu açılmaz), RANDOM_PORT ile gerçek sunucu | Uçtan uca servis içi akış |
@WebMvcTest ile controller testi; servis @MockitoBean ile değiştirilir:
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvcTester mvc; // Spring Framework 6.2+, AssertJ tabanlı
@MockitoBean OrderService orderService;
@Test
void returns409WhenStockIsInsufficient() {
given(orderService.placeOrder(any())).willThrow(new InsufficientStockException(2L, 1));
assertThat(mvc.post().uri("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"productId": 2, "quantity": 1}
"""))
.hasStatus(HttpStatus.CONFLICT);
}
@Test
void returns400ForNonPositiveQuantity() {
assertThat(mvc.post().uri("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"productId": 2, "quantity": 0}
"""))
.hasStatus(HttpStatus.BAD_REQUEST);
verifyNoInteractions(orderService); // validasyon servise ulaşmadan reddetti
}
}
Eski kod tabanlarında aynı test klasik MockMvc ile yazılır: mockMvc.perform(post("/api/orders")...).andExpect(status().isConflict()). MockMvcTester Spring Framework 6.2 ile geldi ve AssertJ varsa Spring Boot tarafından @WebMvcTest'te otomatik yapılandırılır.
@MockitoBean ve @MockBean. Spring Framework 6.2, bean override mekanizmasını framework'e taşıdı ve @MockitoBean / @MockitoSpyBean anotasyonlarını ekledi. Spring Boot 3.4'te Boot'un @MockBean ve @SpyBean'i deprecated oldu, Spring Boot 4.0'da kaldırıldı. Eski kod tabanında @MockBean görürsen anlamı aynıdır: context'teki bean'i bir Mockito mock'uyla değiştirir.
Context caching ve maliyet. Spring TestContext, aynı konfigürasyona sahip testler arasında ApplicationContext'i önbelleğe alır (varsayılan en fazla 32 context, spring.test.context.cache.maxSize). Cache anahtarına aktif profiller, property'ler ve bean override'ları dahildir. Yani her test sınıfında farklı bir @MockitoBean kombinasyonu kullanmak, her sınıf için yeni bir context açılması demektir; test süresi sessizce katlanır. @DirtiesContext context'i atar ve bir sonraki testte yeniden kurdurur; en pahalı anotasyonlardan biridir. Hızlı bir integration test paketi için: ortak bir taban sınıf (aynı profil, aynı container'lar, aynı override'lar) ve @DirtiesContext yerine veriyi temizlemek.
Spring Boot 4 notu. Spring Boot 4.0 modüler hale geldi; test desteği de teknolojiye göre starter'lara ayrıldı (ör. spring-boot-starter-webmvc-test) ve paketler org.springframework.boot.<modül> düzenine taşındı. Anotasyonların kullanımı aynı, import'lar değişti.
@Transactional ile işaretlenmiş integration testleri (ve @DataJpaTest'in varsayılan davranışı) her testi sonunda rollback eder. Rahattır ama gerçeği gizler: commit hiç olmadığı için @TransactionalEventListener(AFTER_COMMIT) tetiklenmez, flush olmayan bir constraint ihlali görünmez, test ile kod aynı transaction'da olduğu için lazy loading her yerde çalışır ve production'daki LazyInitializationException saklanır. Transaction davranışını test ediyorsan teste @Transactional koyma; veriyi testten önce temizle (demo projesindeki TRUNCATE yaklaşımı).
Testcontainers
Testcontainers testten Docker container'ı başlatır: gerçek PostgreSQL, Kafka, Redis. H2 ile test etmek "testte geçti, prod'da patladı" riskini taşır; FOR UPDATE SKIP LOCKED, ON CONFLICT, JSONB, partial index ve PostgreSQL'in kilit davranışı H2'de ya yoktur ya farklı çalışır.
@ServiceConnection (Spring Boot 3.1+), container'ın bağlantı bilgisini (URL, kullanıcı, şifre) spring.datasource.* property'lerine elle yazmadan otomatik bağlar. Öncesinde @DynamicPropertySource ile property'ler elle tanımlanırdı.
@DataJpaTest
@Testcontainers
class OutboxEventRepositoryTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired OutboxEventRepository repository;
@Test
void lockNextBatchReturnsOnlyUnpublishedEvents() {
// ...
}
}
Spring Boot 3.4'ten itibaren @AutoConfigureTestDatabase, veritabanının bir container'dan geldiğini algılar ve onu gömülü veritabanıyla değiştirmez. Daha eski sürümlerde @DataJpaTest'e @AutoConfigureTestDatabase(replace = Replace.NONE) eklemek gerekirdi; yoksa test sessizce H2'ye (classpath'teyse) bağlanırdı.
Singleton container. Her test sınıfında container başlatmak (PostgreSQL için birkaç saniye) paketi yavaşlatır. Daha ince bir tuzak da var: @Testcontainers + @Container static alanı JUnit tarafından sınıf sonunda durdurulur, ama Spring cache'lenmiş context'i bir sonraki sınıfta yeniden kullanır; context artık ölü bir port'a bağlıdır. Çözüm container'ı JVM ömrü boyunca bir kez başlatmaktır. Demo projesinin taban sınıfı:
@SpringBootTest
@ActiveProfiles("test")
public abstract class AbstractIntegrationTest {
@ServiceConnection
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
static {
POSTGRES.start(); // JVM başına bir kez; JVM bitince Ryuk container'ı temizler
}
@Autowired
protected JdbcTemplate jdbcTemplate;
@BeforeEach
void cleanDatabase() {
jdbcTemplate.execute("TRUNCATE orders, outbox_event, processed_event, shipment, demo_order, audit_log");
}
}
Alternatifleri: container'ları @TestConfiguration içinde @Bean @ServiceConnection olarak tanımlamak (o zaman yaşam döngüsünü Spring yönetir, context kapanınca durur) ya da @ImportTestcontainers. Spring Boot dokümanı, context cache'iyle uyum için bu Spring yönetimli yaklaşımları önerir.
Reuse. withReuse(true) ve kullanıcının ~/.testcontainers.properties dosyasında testcontainers.reuse.enable=true ile container testler bittikten sonra da ayakta kalır ve bir sonraki çalıştırmada tekrar kullanılır. Lokal geliştirmede hızlıdır; CI'da genelde kapalı tutulur, çünkü önceki çalıştırmadan kalan veri izolasyonu bozar.
Asenkron akış. Kafka consumer'ı ya da outbox relay'i test ederken sonuç hemen oluşmaz. Thread.sleep(2000) hem yavaş (her zaman 2 sn bekler) hem kırılgandır (yavaş bir CI makinesinde 2 sn yetmez). Awaitility koşul sağlanana kadar kısa aralıklarla dener, üst sınırda fail eder:
kafkaTemplate.send("order-created", orderId.toString(), payload);
await().atMost(Duration.ofSeconds(10))
.untilAsserted(() -> assertThat(countRows("shipment")).isEqualTo(1));
Testcontainers 2.x (Spring Boot 4 ile gelen sürüm) modül artifact'larını testcontainers-postgresql gibi yeniden adlandırdı; yükseltmede bağımlılık isimleri değişir.
TDD: red, green, refactor
TDD bir test tekniği değil, bir tasarım tekniğidir. Döngü:
- Red: Henüz olmayan bir davranış için başarısız bir test yaz. Testin doğru sebepten kırmızı olduğunu gör (derleme hatası ya da beklenen assertion hatası).
- Green: Testi geçirecek en basit kodu yaz. Bu aşamada güzellik aranmaz.
- Refactor: Testler yeşilken tekrarı kaldır, isimleri düzelt, tasarımı iyileştir. Davranış değişmez.
Kent Beck'in "Canon TDD" yazısı bunun önüne önemli bir adım ekler: test listesi. Koda başlamadan önce kapsanacak davranışlar listelenir (implementasyon kararları değil, davranışlar). Sonra listeden bir madde seçilir, teste dönüştürülür, geçirilir, gerekirse refactor edilir; yeni fark edilen durumlar listeye eklenir.
Birkaç tekniğin adı mülakatta işe yarar:
- Fake it: İlk testi sabit bir değer döndürerek geçir. Garip görünür ama sonraki testin gerçek mantığı zorlamasını sağlar.
- Triangulation: İkinci bir örnekle (farklı girdi) sabit değeri gerçek hesaba dönüştürmeye zorla.
- Obvious implementation: Çözüm gerçekten açıksa doğrudan yaz; TDD dogma değildir.
Live coding'de TDD nasıl görünür? Mülakatçı TDD'nin ritmini görmek ister, bu yüzden ritmi sesli anlat:
- "Önce test listesini çıkarıyorum: boş sepet, kampanyasız toplam, 3 al 2 öde sınırları, eşik, kural sırası, tavan."
- "Bu test şu an kırmızı, çünkü metot
UnsupportedOperationExceptionfırlatıyor. Doğru sebepten kırmızı." - "En basit hali bu; sabit değer dönüyorum. Bir sonraki test bunu kıracak."
- "Şimdi yeşil. Refactor'a geçmeden önce testleri çalıştırıyorum, sonra iki kuralı ortak bir arayüze çıkaracağım."
Zaman kısıtlıysa testleri tek tek değil, ilk birkaç küçük döngüden sonra biraz daha büyük adımlarla ilerletmek kabul edilir. Önemli olan, hiçbir zaman uzun süre kırmızıda kalmamak ve refactor'ı sadece yeşilken yapmaktır.
TDD'nin zayıf olduğu yerler de vardır: keşif aşamasındaki prototipler (ne istediğini bilmiyorsun), UI düzeni, tamamen framework konfigürasyonundan oluşan kod. Senior aday bunu söyleyebilir.
İyi test isimlendirme ve given/when/then
Test adı, test kırıldığında neyin bozulduğunu söylemelidir. test1, testDiscount, shouldWork bir şey söylemez.
İşe yarayan kalıplar:
- Davranış cümlesi:
discountIsCappedAt250,rejectsUnknownCarrier,doesNotPersistOrderOrEventWhenStockIsInsufficient. methodName_condition_expectedResult:discountFor_premiumCustomer_returnsTenPercent. Metot adı değişince test adı da değişmek zorunda kalır; davranış cümlesi bu yüzden genelde daha dayanıklıdır.
Gövde üç bölümdür: Arrange / Act / Assert (ya da given / when / then). Act bölümü tek satır olmalıdır; birden fazla act varsa muhtemelen iki ayrı test vardır. Arrange uzunsa test data builder'larına ya da yardımcı metotlara (givenCustomer(...)) taşınır.
@Test
void discountIsCappedAt250() {
// given: %20 indirim hakkı olan müşteri (premium + yeni + doğum günü)
givenCustomer(true, 0, LocalDate.of(1990, 3, 15));
// when: 2000 TL'lik sepet (%20 = 400 TL)
BigDecimal discount = service.discountFor(CUSTOMER_ID, new BigDecimal("2000"));
// then: tavan uygulanır
assertThat(discount).isEqualByComparingTo("250.00");
}
Flaky testler: nedenler ve çareler
Flaky test, kod değişmeden bazen geçen bazen kalan testtir. Tek bir flaky test bile ekibin kırmızı build'e güvenini bozar. Sık nedenler:
| Neden | Belirti | Çare |
|---|---|---|
| Zaman | Gece yarısı, ay sonu, artık yıl, saat dilimi değişiminde kırılır | Clock enjekte et, testte Clock.fixed(...) |
| Test sırası | Tek başına geçer, pakette kalır (ya da tersi) | Her test kendi verisini kurar ve temizler; sıra bağımlılığı yok |
| Paylaşılan durum | Static alan, singleton cache, ortak DB satırları, cache'lenmiş Spring context | Testten önce temizlik, benzersiz test verisi, mutable static'ten kaçınmak |
| Asenkronluk | Thread.sleep yetmediğinde kalır | Awaitility ile koşullu bekleme |
| Eşzamanlılık | Race condition nadiren tetiklenir | Kontrollü eşzamanlılık (CountDownLatch başlangıç kapısı), tekrar (@RepeatedTest) |
| Ortam | Locale, timezone, port çakışması, dosya yolu | Açık Locale.ROOT, UTC, rastgele port, geçici dizin (@TempDir) |
| Sırasız koleksiyonlar | HashMap/HashSet sırasına güvenen assertion | containsExactlyInAnyOrder ya da sıralı koleksiyon |
Türkçe locale tuzağı somut bir örnektir: "TITLE".toLowerCase() Türkçe varsayılan locale'de "tıtle" döner (büyük I küçük ı olur). Geliştirici makinesinde geçen test İngilizce locale'li CI'da da geçer, ama production sunucusu tr_TR ise kod kırılır. Enum ve anahtar dönüşümlerinde toUpperCase(Locale.ROOT) kullanılır.
Zamanı ilerletmek gereken testler için kendi değiştirilebilir saatini yazabilirsin:
final class MutableClock extends Clock {
private Instant now;
private final ZoneId zone;
MutableClock(Instant start, ZoneId zone) { this.now = start; this.zone = zone; }
void advance(Duration d) { now = now.plus(d); }
@Override public Instant instant() { return now; }
@Override public ZoneId getZone() { return zone; }
@Override public Clock withZone(ZoneId z) { return new MutableClock(now, z); }
}
Flaky bir testle karşılaşınca yapılacak şey onu @Disabled ile susturmak değil, karantinaya alıp (ayrı bir etiketle ana build'den çıkarıp) sahibini ve süresini belirleyerek kök nedeni bulmaktır.
Coverage ve mutation testing
Coverage (JaCoCo) testlerin hangi satırları ve dalları çalıştırdığını ölçer. Söylediği şey sınırlıdır: bir satırın çalışmış olması, sonucunun doğrulandığı anlamına gelmez. Hiç assertion içermeyen bir test %100 satır coverage üretebilir. Coverage'ın asıl değeri tersinedir: hiç çalışmayan kodu gösterir. Düşük coverage bir sorun işaretidir; yüksek coverage kalite kanıtı değildir. Coverage'ı bir hedef sayıya bağlamak ekipleri değersiz testler yazmaya iter (Goodhart yasası).
Mutation testing (Java'da PIT) testlerin gerçekten bir şey doğrulayıp doğrulamadığını ölçer. PIT koda küçük hatalar (mutant) ekler: > yerine >=, + yerine -, bir dönüş değerini null ya da sabit yapmak, bir metot çağrısını silmek. Sonra testleri çalıştırır. Testlerden biri kırılırsa mutant "öldürülmüş"tür; hiçbiri kırılmazsa mutant "hayatta kalmıştır" ve bu, testlerin o davranışı doğrulamadığını gösterir.
Örnek: ArasFeePolicy'de weight.compareTo(THRESHOLD) > 0 koşulu PIT tarafından >= 0'a çevrilir. Testlerin sadece 1 kg ve 10 kg ile çalışıyorsa bu mutant hayatta kalır; tam 5 kg'lık bir test eklediğinde ölür. Mutation testing sınır değer testlerinin eksikliğini doğrudan gösterir.
Maven'da mvn org.pitest:pitest-maven:mutationCoverage ile çalışır; JUnit 5 için pitest-junit5-plugin eklentisi gerekir. Yavaştır (her mutant için testler tekrar çalışır), bu yüzden genelde kritik modüllerde (fiyatlama, ödeme) ya da sadece değişen kodda çalıştırılır.
Quiz'deki sorular: çözüm ve nedenleri
q18: Stok yetersizken ne sipariş ne event yazılmalı (test kısmı)
Senaryo. OrderService.placeOrder sırasıyla stockReservationService.reserve(...), orderRepository.save(...), outboxEventRepository.save(...) çağırıyor. reserve void ve stok yetmezse InsufficientStockException fırlatıyor. Constructor: (StockReservationService, OrderRepository, OutboxEventRepository, ObjectMapper, Clock). (1) Unit test yaz: stok yetersizse exception fırlamalı, ne sipariş ne outbox kaydı yapılmalı. (2) Hangi davranışı Testcontainers ile test edersin?
Unit test:
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
private static final Clock FIXED = Clock.fixed(Instant.parse("2026-10-01T10:00:00Z"), ZoneOffset.UTC);
@Mock StockReservationService stockReservationService;
@Mock OrderRepository orderRepository;
@Mock OutboxEventRepository outboxEventRepository;
private OrderService orderService;
@BeforeEach
void setUp() {
orderService = new OrderService(stockReservationService, orderRepository,
outboxEventRepository, new ObjectMapper(), FIXED);
}
@Test
void doesNotPersistOrderOrEventWhenStockIsInsufficient() {
doThrow(new InsufficientStockException(42L, 2))
.when(stockReservationService).reserve(42L, 2);
assertThatThrownBy(() -> orderService.placeOrder(new PlaceOrderCommand(42L, 2)))
.isInstanceOf(InsufficientStockException.class);
verifyNoInteractions(orderRepository, outboxEventRepository);
}
}
Her satırın gerekçesi:
doThrow(...).when(...):reservevoid olduğu içinwhen(...).thenThrow(...)derlenmez (z5'in konusu).assertThatThrownBy+isInstanceOf: Exception'ın yutulmadığını ve doğru tipte yükseldiğini doğrular. Tip önemlidir:GlobalExceptionHandlerbu tipi 409'a çevirir ve@Transactionalbu exception unchecked olduğu için rollback yapar.verifyNoInteractions: "Ne sipariş ne event" şartının doğrudan ifadesi.verify(orderRepository, never()).save(any())da doğrudur ama sadecesave'i kapsar;verifyNoInteractionsherhangi bir çağrıyı yakalar.ObjectMappergerçek, mock değil: Sahibi olmadığımız bir tip ve gerçeği ucuz.Clock.fixed:placeOrderilk satırdaclock.instant()çağırıyor; sabit saat testi deterministik yapar ve başka testlerdecreatedAtdoğrulamasına imkân verir.
Bu unit testin sınırı. Test, kontrol akışını doğrular: exception sonrası satırlar çalışmıyor. Ama production'daki asıl garanti başka bir mekanizmadan gelir: reserve bir SQL UPDATE çalıştırdıktan sonra exception fırlatırsa ya da outboxEventRepository.save patlarsa, o ana kadar yapılan yazmaların geri alınması. Bu, @Transactional proxy'sinin ve veritabanının işidir; mock'larla test edilen bir sınıfta proxy de veritabanı da yoktur.
Testcontainers ile test edilecek davranışlar (demo projesindeki OrderServiceIntegrationTest):
- Atomiklik: Stok, sipariş ve outbox satırı aynı transaction'da yazılır; stok yetersizse stok değişmez,
ordersveoutbox_eventboş kalır. - Eşzamanlılık: 10 stoklu ürüne 50 eşzamanlı istek tam 10 sipariş üretir. Bu,
UPDATE stock SET quantity = quantity - :qty WHERE product_id = :id AND quantity >= :qtysorgusunun atomikliğine ve PostgreSQL'in satır kilidine dayanır. - Propagation:
reservemetoduPropagation.MANDATORYile işaretli; transaction dışından çağrılıncaIllegalTransactionStateExceptionfırlar. - Outbox içeriği: JSONB payload'ındaki
eventIdile satır id'sinin aynı olması (consumer idempotency'si buna dayanır).
@Test
void concurrentOrdersNeverOversell() throws Exception {
setStock(PRODUCT_ID, 10);
int successes = ConcurrentRunner.countSuccesses(50, 16, () -> {
try {
orderService.placeOrder(new PlaceOrderCommand(PRODUCT_ID, 1));
return true;
} catch (InsufficientStockException e) {
return false;
}
});
assertThat(successes).isEqualTo(10);
assertThat(stockOf(PRODUCT_ID)).isZero();
assertThat(countRows("outbox_event")).isEqualTo(10);
}
ConcurrentRunner bir CountDownLatch'i başlangıç kapısı olarak kullanır: tüm görevler hazır olana kadar bekler, sonra aynı anda serbest bırakılır. Latch olmasaydı ilk görevler diğerleri oluşmadan biterdi ve gerçek bir yarış olmazdı.
Neden H2 değil: FOR UPDATE SKIP LOCKED, ON CONFLICT, JSONB cast'i ve PostgreSQL'in kilit semantiği H2'de ya yok ya farklı. Test, production'daki veritabanının aynısında çalışmalı.
k3: İndirim servisine test yaz
Senaryo. DiscountService.discountFor(customerId, cartTotal): tutar pozitif değilse IllegalArgumentException; müşteri yoksa CustomerNotFoundException; premium %10, hiç siparişi yoksa %5, doğum günüyse %5; oranlar toplanır, indirim 2 basamağa HALF_UP yuvarlanır ve 250 TL ile sınırlanır. Servis Clock alıyor. Kod değiştirilmeyecek; testler yazılacak.
Önce test listesi. Koddaki her dal ve her sınır bir test adayıdır:
| Davranış | Test |
|---|---|
Geçersiz tutar: null, 0, negatif | Parameterized; repository'ye hiç gidilmemeli |
| Müşteri yok | Optional.empty() → CustomerNotFoundException |
| Kuralsız müşteri | 0 indirim |
| Her kural tek başına ve birleşimi | Parameterized tablo |
| Tavan | Tavanı aşan ve hemen altındaki tutar |
Doğum tarihi null | Doğum günü sayılmaz |
Çözümün mantığı.
@ParameterizedTest
@NullSource
@ValueSource(strings = {"0", "-10"})
void rejectsNonPositiveCartTotal(BigDecimal cartTotal) {
assertThatThrownBy(() -> service.discountFor(CUSTOMER_ID, cartTotal))
.isInstanceOf(IllegalArgumentException.class);
verifyNoInteractions(customerRepository); // validasyon DB'den önce
}
@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);
}
Önemli noktalar:
- Deterministik doğum günü:
Clock.fixed(Instant.parse("2026-03-15T10:00:00Z"), ZoneOffset.UTC). ServisLocalDate.now(clock)kullandığı için "bugün" sabittir. ServisLocalDate.now()çağırsaydı test yılda bir gün farklı sonuç verirdi. - "Doğum günü değil" verisi bir gün sonrası seçilmiş (16 Mart). Bu, ay ve günün ayrı ayrı karşılaştırıldığını sınar; tamamen alakasız bir tarih (1 Ocak) seçmek daha zayıf bir testtir.
- Tavanın iki tarafı: 2000 TL x %20 = 400 → 250; 2499,90 x %10 = 249,99 → tavan uygulanmaz. Yalnızca tavanı aşan testi yazarsan
minyerine yanlışlıkla sabit 250 dönen bir kod da geçer. isEqualByComparingTo: ServissetScale(2)ile10.00döner;isEqualTo(new BigDecimal("10"))scale farkından kalırdı.
Quiz çözümündeki küçük düzeltme. Çözüm, givenCustomer içinde getBirthDate stub'ına lenient() ekliyor ve gerekçe olarak "bazı senaryolarda getBirthDate çağrılmayabilir" diyor. Koda bakınca bu doğru değil: üç if birbirinden bağımsız, isBirthday(customer) her zaman çalışıyor ve ilk iş c.getBirthDate() çağırıyor. Dolayısıyla strict stubs bu stub için hata vermez ve lenient() gereksizdir. Genel ilke doğru (gerçekten bazı yollarda kullanılmayan paylaşılan stub'lar için lenient()), ama burada refleksle eklenmiş ve strict stubs'ın koruma değerini azaltıyor.
Daha iyi bir alternatif: entity'yi mock'lamamak. Customer'ın üç getter'ını stub'lamak yerine bir test data builder daha okunur ve daha az kırılgandır:
Customer customer = CustomerFixtures.aCustomer()
.premium(true)
.orderCount(0)
.birthDate(LocalDate.of(1990, 3, 15))
.build();
when(customerRepository.findById(CUSTOMER_ID)).thenReturn(Optional.of(customer));
Mülakatta söylenebilecek ek sınır durumu: 29 Şubat doğumlu bir müşteri bu kodla artık yıl olmayan yıllarda hiç doğum günü indirimi alamaz. Bu bir test değil, bir iş sorusudur: "28 Şubat'ta mı, 1 Mart'ta mı uygulanmalı?" Bunu fark edip sormak senior bir sinyaldir.
Tasarım kokusu. Kurallar if'lerle gömülü. Yeni kural geldikçe List<DiscountRule> (Strategy) yapısına geçilebilir; bu testler davranışı servisin public metodu üzerinden doğruladığı için o refactor'da kırılmaz, güvenlik ağı olur (bkz. Bölüm 17).
k4: TDD ile sepet kampanya motoru
Senaryo. CartLine(sku, unitPrice, quantity, threeForTwo). Kurallar sırayla: (1) threeForTwo satırlarında her 3 adetten 1'i bedava; (2) 1. kuraldan sonraki ara toplam 1000 TL ve üzeriyse ara toplam üzerinden %10; (3) toplam indirim 300 TL'yi geçemez. CartPricer.price(List<CartLine>) → PriceSummary(subtotal, discount, total).
Adım 0: netleştirme ve test listesi. Koda başlamadan sorulacaklar: "%10 kargo dahil mi?", "Tavan sepet bazında mı?", "Yuvarlama hangi aşamada?", "Negatif ya da sıfır adet gelebilir mi?". Sonra liste:
- Boş sepet → 0 / 0 / 0
- Kampanyasız sepet → satırların toplamı
- 3 al 2 öde: 2 adet (bedava yok), 3 adet (1 bedava), 6 ve 7 adet (2 bedava)
- Eşik: tam 1000 → %10; 999,99 → yok
- Kural sırası: 3 al 2 öde sonrası eşiğin altına düşen sepet → %10 yok
- Tavan: 5000 TL → %10 = 500 → 300
Döngü 1 (boş sepet). Red: metot UnsupportedOperationException fırlatıyor. Green: en basit kod, sabit sıfır dönmek (fake it).
@Test
void emptyCartCostsNothing() {
var summary = pricer.price(List.of());
assertThat(summary.subtotal()).isEqualByComparingTo("0");
assertThat(summary.discount()).isEqualByComparingTo("0");
assertThat(summary.total()).isEqualByComparingTo("0");
}
Döngü 2 (kampanyasız toplam). Red: sabit sıfır artık yetmiyor (triangulation). Green: satır toplamı.
BigDecimal subtotal = lines.stream()
.map(l -> l.unitPrice().multiply(BigDecimal.valueOf(l.quantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
return new PriceSummary(subtotal, BigDecimal.ZERO, subtotal);
Döngü 3 (3 al 2 öde). Sınırları tek bir parameterized testle sabitle: 2→0, 3→100, 5→100, 6→200, 7→200. Green: bedava adet quantity / 3 (tam sayı bölmesi), indirim unitPrice x (quantity / 3).
Döngü 4 (eşik). Tam 1000 TL'lik tek satır → indirim 100. Bu noktada aday çoğunlukla eşiği subtotal üzerinden kontrol eden kodu yazar ve test geçer.
Döngü 5 (kural sırası), asıl ayırt edici test. 4 x 300 TL, 3 al 2 öde işaretli: ara toplam 1200, 3 al 2 öde ile -300 → 900. Gereksinime göre %10 uygulanmamalı. Eşiği brüt subtotal üzerinden kontrol eden kod bu testte kırmızıya döner (1200 ≥ 1000 diye %10 uygular). TDD'nin değeri tam burada görünür: gereksinimdeki "sırayla uygulanır" ifadesi bir testle sabitlenir ve yanlış implementasyonu yakalar. Green: eşik kontrolü subtotal - discountSoFar üzerinden.
Döngü 6 (tavan). 5000 TL → %10 = 500 → 300; toplam 4700. Green: discount.min(MAX_DISCOUNT).
Refactor. Testler yeşilken kuralları ortak bir arayüze çıkar:
interface DiscountRule {
BigDecimal discount(List<CartLine> lines, BigDecimal runningTotal);
}
public class CartPricer {
private static final BigDecimal MAX_DISCOUNT = new BigDecimal("300");
private final List<DiscountRule> rules;
public CartPricer() {
this(List.of(new ThreeForTwoRule(), new CartThresholdRule())); // sıra = iş kuralı
}
CartPricer(List<DiscountRule> rules) { this.rules = List.copyOf(rules); }
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) {
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));
}
}
Yeni kampanya tipi artık yeni bir DiscountRule demektir (Open/Closed, bkz. Bölüm 17). Testler CartPricer'ın public API'si üzerinden yazıldığı için refactor sırasında hiçbiri değişmedi; değişseydi testler implementasyona bağlı demekti.
Quiz çözümüne notlar.
- Çözümdeki ilk test
PriceSummary'yiisEqualTo(new PriceSummary(money("0"), ...))ile karşılaştırıyor. Record'unequals'ıBigDecimal.equalskullandığı için bu test scale'e bağlı: implementasyonsetScale(2)yapmayı bırakırsa davranış aynı olduğu halde test kırılır. Alan bazındaisEqualByComparingTodaha sağlamdır; daha iyisi para için kendiMoneyvalue object'ini tanımlamaktır. - Yuvarlama yalnızca en sonda yapılıyor;
CartThresholdRule1234,56 gibi bir ara toplamda 123,456 üretir ve tavan karşılaştırması yuvarlanmamış değerle yapılır. Mülakatta "yuvarlamayı hangi aşamada ve satır bazında mı yapıyoruz?" diye sormak doğru hamledir. - Negatif ya da sıfır adet ve
nullfiyat doğrulanmıyor.CartLine'ın compact constructor'ında doğrulama, domain'in kendi invariant'ını korumasının en basit yoludur.
z5: Void metottan exception fırlatmak
Soru. void reserve(long productId, int qty) metodunun mock'ta exception fırlatması için doğru yazım hangisi?
Doğru: b) doThrow(new InsufficientStockException()).when(stock).reserve(1L, 2).
Neden: when(T methodCall) bir değer bekler; Java'da void bir ifade argüman olarak verilemez, bu yüzden when(stock.reserve(1L, 2)) derleme hatasıdır. do* ailesi sırayı tersine çevirir: önce davranışı tanımlar, sonra when(mock) ile mock'u döner ve metot o dönen nesne üzerinde çağrılır; Mockito bu çağrıyı stub tanımı olarak kaydeder.
Yanlış seçenekler:
- a)
when(stock.reserve(1L, 2)).thenThrow(...): void ifade argüman olamaz, derlenmez. - c)
given(stock.reserve(1L, 2)).willThrow(...): BDDMockito'da aynı sorun; doğru BDD karşılığıwillThrow(...).given(stock).reserve(1L, 2). - d)
verify(stock).reserve(1L, 2).thenThrow(...):verifydavranış tanımlamaz, çağrının yapıldığını doğrular; ayrıca void dönüşün üzerinde metot çağrılamaz.
do* ailesi void dışında iki yerde daha gerekir: spy'ları stub'lamak (gerçek metodu çağırmamak için) ve daha önce exception fırlatacak şekilde stub'lanmış bir metodu yeniden stub'lamak.
z6: Live coding'de iyi test pratikleri
Doğru: a, b, e.
- a) Her test tek davranışı doğrular ve adı o davranışı anlatır (
rejectsUnknownCarrier). Kırıldığında rapor neyin bozulduğunu söyler. - b) Arrange-Act-Assert (given-when-then). Okuyan kişi kurulumu, eylemi ve beklentiyi ayırt eder.
- e) Sınır değerleri test etmek (eşiğin kendisi, bir altı, bir üstü). Off-by-one hatalarını (
>ile>=karışıklığı) yakalar; PIT'in conditional boundary mutant'ları tam olarak bu testlerin eksikliğinde hayatta kalır.
Yanlış:
- c)
Thread.sleep(2000): hem her zaman yavaş hem yavaş makinede yetersiz; Awaitility ile koşullu bekleme. - d) Test sırasına güvenmek: JUnit sırayı garanti etmez, paralel çalıştırmada her şey karışır; her test kendi verisini kurar.
- f) Sadece mutlu yol: hatalar en çok hata dallarında ve kenar durumlarda çıkar; k1'deki "bilinmeyen firma 0 TL" bug'ı tam olarak test edilmemiş bir hata dalıdır.
Live coding pratiği: zaman yetmeyecekse önce test listesini yorum olarak yaz, en riskli davranışın testini önce yaz ve mülakatçıya söyle: "Zaman kalırsa şu senaryoları da eklerim." Liste bile puan getirir.
Üç seviyede cevap
Soru: "Unit test ile integration test arasındaki sınırı nasıl çizersin?"
seviye: Başlangıç etiketler: [testing, unit-test, integration-test] kaynak: Tahmini
Unit test tek bir metodu test eder ve her şeyi mock'larız. Integration test ise Spring context'i ayağa kaldırır ve veritabanına bağlanır. Genelde her servis sınıfı için unit test yazarım, controller'lar için de birkaç integration test.
Unit test bir sınıfın mantığını bağımlılıkları test double ile değiştirerek test eder; hızlıdır, iş kuralları ve sınır değerler için kullanırım. Integration testte gerçek altyapıyla (Testcontainers ile PostgreSQL, Kafka) birlikte çalışmayı test ederim: SQL, mapping, transaction. Controller için @WebMvcTest, repository için @DataJpaTest gibi dilimleri kullanırım ki tüm context'i yüklemeyeyim.
Sınırı sınıf sayısına göre değil, riskin nerede olduğuna göre çizerim. Bir davranışın doğruluğu benim kodumdaki mantığa dayanıyorsa (indirim kuralları, durum geçişleri) unit test; doğruluğu bir altyapı mekanizmasına dayanıyorsa (transaction rollback, WHERE quantity >= :qty'nin atomikliği, unique constraint, JSON serileştirmesi, Kafka'da partition sırası) mock'lar bunu çalıştırmaz, integration test gerekir. Mock'lu bir testin cevaplayabildiği tek soru "çağrıldı mı"dır; "doğru çalıştı mı" değildir.
Unit'i sociable yazarım: domain nesnelerini ve kendi kurallarımı gerçek kullanırım, sadece süreç dışına çıkan sınırları değiştiririm. Böylece refactor testleri kırmaz. Integration testlerde maliyeti yönetirim: singleton container, ortak taban sınıf, aynı @MockitoBean kombinasyonu ki context cache'i bozulmasın, @Transactional test yerine açık temizlik ki commit davranışı gerçekten test edilsin. İki servis arasındaki sözleşmeyi ise ikisini birlikte ayağa kaldırarak değil, contract testle doğrularım. Ne zaman integration test yazmam: saf hesaplama mantığında; orada sadece yavaşlık ekler.
Junior cevap "unit = her şeyi mock'la" yanılgısını taşıyor ve kararı sınıf türüne göre veriyor. Mid cevap araçları ve dilimleri doğru biliyor. Senior cevap sınırı bir ilkeye bağlıyor (risk mantıkta mı mekanizmada mı), mock'ların neyi kanıtlayamadığını açıkça söylüyor, refactor dayanıklılığı (sociable), test paketinin maliyeti (context cache, container) ve @Transactional testlerin gizlediği şeyler gibi işletme detaylarını ekliyor.
Takip sorusu: @Transactional ile işaretli bir integration testinin geçip production'da patlayabileceği iki somut durum söyler misin?
Soru: "Mock ile stub arasındaki fark ne? Ne zaman mock kullanmazsın?"
seviye: Başlangıç etiketler: [testing, mocking] kaynak: Tahmini
Mock sahte nesnedir, stub da sahte bir metottur. Mockito'da mock() ile oluşturur, when ile davranış veririz. Dış bağımlılıkların hepsini mock'larım.
Stub önceden belirlenmiş cevap döner ve sonucu (durumu) doğrularım; mock ise etkileşimi doğrular, verify ile. Mockito'da aynı nesne ikisi için de kullanılabilir. Value object'leri ve kendi domain sınıflarımı mock'lamam; gerçeğini oluşturmak daha kolay. Void metotlar için doThrow().when() kullanırım.
Ayrım kullanımda: stub ile durum doğrularım, mock ile davranış. Kuralım "sorgulara stub, komutlara mock": findById'i stub'larım ve sonuca bakarım, ayrıca verify(repo).findById(...) yazmam çünkü bu testi implementasyona kilitler. save, send, publish gibi yan etkiler davranışın kendisiyse onları verify ederim, ArgumentCaptor ile içeriklerini de.
Mock'lamadıklarım: value object, entity (builder kullanırım), kendi domain mantığım ve sahibi olmadığım tipler. KafkaTemplate'i mock'lamak, Kafka'nın nasıl davrandığına dair benim varsayımımı test eder; onu kendi EventPublisher arayüzümün arkasına sarar, uygulamada o arayüzü mock'lar, sarmalayıcıyı gerçek Kafka ile test ederim. Saati de mock'lamam, Clock enjekte ederim. Bir test kurulumu çok fazla mock gerektiriyorsa bunu test sorunu değil tasarım sinyali olarak okurum: sınıf çok fazla işbirlikçiyi koordine ediyordur. Strict stubs'ı da bilinçli kullanırım; UnnecessaryStubbingException genelde testin sandığım yoldan geçmediğini gösterir.
Junior cevap kavramları karıştırıyor ve "her şeyi mock'la" diyor; bu, refactor'da sürekli kırılan testlere götürür. Mid cevap doğru tanımı ve temel istisnaları biliyor. Senior cevap ayrımı bir karar kuralına dönüştürüyor (sorgu ve komut), "don't mock what you don't own" ilkesini gerekçesi ve alternatifiyle anlatıyor ve mock sayısını tasarım geri bildirimi olarak okuyor.
Takip sorusu: Bir spy üzerinde when(spy.calculate()).thenReturn(...) yazdığında beklenmedik bir şey olabilir mi? Neden?
Soru: "TDD uyguluyor musun? Live coding'de nasıl görünür?"
seviye: Orta etiketler: [tdd, live-coding] kaynak: Tahmini
Evet, önce testi yazıp sonra kodu yazıyorum. Live coding'de de önce bütün testleri yazar, sonra implementasyona geçerim.
Red-green-refactor döngüsüyle çalışırım: başarısız bir test, onu geçiren en basit kod, sonra testler yeşilken refactor. Live coding'de küçük adımlarla ilerler, her adımda testleri çalıştırırım. Önce boş ya da en basit durumla başlarım, sonra kuralları tek tek eklerim.
Önce soruları netleştirip bir test listesi çıkarırım; listede implementasyon değil davranışlar olur. Sonra en basit maddeyle başlarım ve ritmi sesli anlatırım: "Bu test doğru sebepten kırmızı", "Şimdilik sabit dönüyorum, bir sonraki test bunu zorlayacak", "Yeşil, şimdi refactor". Listeyi gereksinimdeki riskli ifadelere göre sıralarım; k4'te "sırayla uygulanır" ifadesi için ayrı bir test yazarım, çünkü yanlış bir implementasyonu tam olarak o yakalar.
Zaman kısıtlıysa adım büyüklüğünü ayarlarım: açık olan kısımlarda obvious implementation, belirsiz kısımlarda küçük adımlar. Refactor'ı sadece yeşilken yaparım ve testleri public davranış üzerinden yazdığım için refactor'da testlere dokunmam gerekmez. TDD'yi dogma olarak da görmem: keşif aşamasındaki bir prototipte ya da sadece framework konfigürasyonundan oluşan kodda test-sonra yazarım. Ama iş kuralı yoğun bir modülde TDD'nin bana en büyük katkısı tasarım oluyor: test yazmak zorlaşınca bağımlılığı fark ediyorum.
Junior cevap TDD'yi "önce bütün testler" diye tarif ediyor; bu döngü değil, test-first bir şelale. Mid cevap döngüyü doğru biliyor. Senior cevap döngünün önüne test listesini koyuyor, mülakatta ritmi görünür kılıyor, testlerin seçimini riskle ilişkilendiriyor (kural sırası testi), adım büyüklüğünü bilinçli ayarlıyor ve TDD'nin uygun olmadığı durumları söylüyor.
Takip sorusu: Refactor adımında bir test kırıldı. Bu neyi gösterir ve ne yaparsın?
Soru: "CI'da testlerimiz ara sıra fail ediyor, tekrar çalıştırınca geçiyor. Ne yaparsın?"
seviye: Orta etiketler: [testing, flaky-tests, ci] kaynak: Tahmini
Testi birkaç kez tekrar çalıştırırım, geçerse devam ederim. Sürekli sorun çıkaran testi @Disabled yaparım.
Bu flaky test. Sık nedenleri zaman, test sırası, paylaşılan durum ve asenkron beklemeler. Thread.sleep gördüğüm yerde Awaitility kullanırım, LocalDateTime.now() yerine Clock enjekte ederim, testlerin kendi verisini kurup temizlediğinden emin olurum.
Önce görünür kılarım: CI'da her test sonucunu kaydedip hangi testlerin kod değişmeden hem geçip hem kaldığını listelerim. Sonra flaky testleri sahibi ve süresi belli bir karantinaya alırım; ana build'i bloklamaz ama kaybolmaz da. @Disabled testi unutur, retry ise gerçek bir race condition'ı da gizler.
Teşhiste deseni ararım: belli saatlerde mi (zaman, saat dilimi), sadece paralel çalışmada mı (paylaşılan DB satırı, static durum), tek başına geçip pakette mi kalıyor (sıra ve context cache), yavaş runner'larda mı (sabit bekleme). Testi döngüde yüz kez çalıştırmak ya da sırayı rastgeleleştirmek çoğu zaman yeniden üretir. Bazen flaky test production'daki gerçek bir eşzamanlılık hatasını gösterir; o yüzden "test sorunu" diye kapatmadan önce kodda yarış olup olmadığına bakarım. Kalıcı çözüm olarak Clock, Awaitility, benzersiz test verisi ve Locale.ROOT gibi alışkanlıkları ekip standardı yaparım.
Junior cevap semptomu bastırıyor; flaky testler birikir ve ekip kırmızı build'e güvenmeyi bırakır. Mid cevap nedenleri ve teknik çareleri biliyor. Senior cevap önce ölçüp görünür kılıyor, karantina ile retry/disable farkını gerekçelendiriyor, sistematik bir teşhis yolu izliyor ve en önemlisi flaky testin production'daki gerçek bir yarışın belirtisi olabileceğini hatırlatıyor.
Takip sorusu: Sadece CI'da, paralel test çalıştırmada kalan bir repository testi var. İlk neye bakarsın?
Sorulmadı ama sorulabilir
Contract testing
Mikroservislerde iki servisin uyumunu her değişiklikte ikisini birlikte ayağa kaldırarak test etmek yavaş ve kırılgandır. Consumer-driven contract testing'de tüketici servis, sağlayıcıdan beklediği istek ve cevapları bir sözleşme olarak yazar (Pact) ya da sağlayıcı sözleşmeyi tanımlar ve tüketiciler için stub üretir (Spring Cloud Contract). Sağlayıcının CI'ı, tüm tüketicilerinin sözleşmelerini kendi koduna karşı doğrular; bir alanı silen ya da tipini değiştiren değişiklik merge olmadan yakalanır. Aynı yaklaşım Kafka event şemalarına da uygulanır; schema registry'nin uyumluluk kuralları da bu işin bir parçasını yapar.
Tek cümle: "Servisler arası uyumu uçtan uca testle değil, tüketicinin yazdığı sözleşmeyi sağlayıcının CI'ında doğrulayarak test ederim."
HTTP client'ları nasıl test edersin?
Kendi RestClient/RestTemplate sarmalayıcını test ederken HTTP seviyesine inmek gerekir: doğru URL, header, body ve hata kodlarının doğru exception'a çevrilmesi. MockRestServiceServer, client'ın request factory'sini değiştirerek gerçek ağ olmadan beklenen istekleri doğrular ve cevap döner. WireMock gerçek bir HTTP sunucusu açar; timeout, yavaş cevap, bozuk JSON gibi durumları da simüle edebilir. Timeout davranışı ancak gerçek bir sunucuyla güvenilir test edilir. Bunun için client'ın request factory'sini ya da timeout ayarlarını içeride sabit kurmak yerine dışarıdan alması gerekir; aksi halde test aracı bağlanamaz (bkz. Bölüm 17, k2).
Tek cümle: "Sarmalayıcıyı MockRestServiceServer ya da WireMock ile HTTP seviyesinde, uygulama kodunu ise sarmalayıcının arayüzünü mock'layarak test ederim."
Test data builder ve Object Mother
Testlerde nesne kurulumu tekrar ettikçe testler okunmaz hale gelir. Object Mother hazır nesneler döner (Customers.premium()); hızlıdır ama varyasyonlar çoğaldıkça metot patlaması olur. Test data builder makul varsayılanlarla başlar ve sadece testin önemsediği alanları değiştirir: aCustomer().premium().bornOn(MARCH_15).build(). Böylece test sadece konuyla ilgili alanları gösterir; geri kalanı gürültü olmaktan çıkar. Entity'ye yeni zorunlu alan eklendiğinde yüzlerce testi değil tek builder'ı güncellersin.
Tek cümle: "Testte sadece önemsediğim alanları görmek için makul varsayılanlı test data builder'ları kullanırım."
ArchUnit ile mimari kuralları test etmek
ArchUnit, mimari kuralları unit test olarak yazmayı sağlar: "controller paketi repository paketine doğrudan erişmez", "domain paketi Spring'e bağımlı değildir", "@Transactional sadece servis katmanında". Code review'da gözden kaçabilecek katman ihlallerini build'de yakalar. Kuralları az ve anlamlı tutmak gerekir; aksi halde ekip testi aşmak için istisna eklemeye başlar.
Tek cümle: "Katman ve bağımlılık kurallarını ArchUnit testleriyle build'e bağlarım; review'da kaçan ihlal merge olmaz."
Property-based testing
Örnek tabanlı testler senin aklına gelen girdileri dener. Property-based testing (Java'da jqwik) bir özellik tanımlar ve kütüphane yüzlerce rastgele girdi üretir; başarısız bir örnek bulursa onu en küçük hale indirir (shrinking). Fiyatlama için iyi özellikler: "indirim asla negatif değildir", "indirim asla ara toplamı geçmez", "toplam = ara toplam - indirim", "satırların sırası sonucu değiştirmez". Bu özellikler, örnek tabanlı testlerin kaçırdığı kenar durumları bulur.
Tek cümle: "Hesaplama mantığında 'her girdi için doğru olmalı' özelliklerini jqwik ile rastgele girdilerle sınarım."
Performans ve yük testi
Fonksiyonel testler doğruluğu, yük testleri (Gatling, k6, JMeter) davranışı yük altında ölçer: throughput, percentile latency, kaynak kullanımı, kırılma noktası. Flash sale gibi senaryolarda yük testi stok düşümündeki kilit çekişmesini, connection pool doygunluğunu ve throttling'i ortaya çıkarır. Anlamlı olması için production'a yakın veri hacmi, gerçekçi trafik karışımı ve ısınma süresi gerekir; sonuçlar ortalama değil percentile olarak raporlanır (bkz. Bölüm 15).
Tek cümle: "Yük testinde ortalamaya değil p99'a ve kırılma noktasına bakarım; flash sale gibi senaryolarda kilit çekişmesini ve havuz doygunluğunu ancak böyle görürüm."
Sık yapılan hatalar
- Her bağımlılığı, domain nesneleri dahil mock'lamak → testler implementasyona kilitlenir, her refactor'da kırılır → sadece süreç dışı sınırları değiştir, domain'i gerçek kullan.
- Sorguları
verifyetmek → davranış değil çağrı sırası test edilir → sorguları stub'la ve sonuca bak; yan etkileriverifyet. - Void metoda
when(...).thenThrow(...)yazmak → derlenmez →doThrow(...).when(mock).metot(...). - Matcher ile ham değeri karıştırmak (
reserve(42L, anyInt())) →InvalidUseOfMatchersException→ ya hepsi matcher (eq(42L), anyInt()) ya hiçbiri. - Spy'ı
when(...)ile stub'lamak → gerçek metot çağrılır →doReturn(...).when(spy). - Her stub'a refleksle
lenient()eklemek → strict stubs'ın yakaladığı gerçek hatalar gizlenir →lenient()sadece gerçekten bazı yollarda kullanılmayan paylaşılan stub'larda. - BigDecimal'i
isEqualToile karşılaştırmak → scale farkından kırılan testler →isEqualByComparingToya daMoneyvalue object. - H2 ile repository testi → PostgreSQL'e özgü SQL ve kilit davranışı test edilmez → Testcontainers ile gerçek PostgreSQL.
- Her test sınıfında farklı
@MockitoBeankombinasyonu → her sınıf için yeni context, paket yavaşlar → ortak taban sınıf ve tutarlı override'lar. - Transaction davranışını
@Transactionaltestle doğrulamak → commit hiç olmaz,AFTER_COMMITlistener'ları ve lazy loading hataları gizlenir → testi transaction'sız çalıştır, veriyi açıkça temizle. Thread.sleepile asenkron bekleme → yavaş ve flaky → Awaitility.LocalDateTime.now()doğrudan çağırmak → zamana bağlı testler yazılamaz ya da flaky olur →Clockenjekte et.- Coverage'ı hedef sayı yapmak → assertion'sız testler, kalite yanılsaması → coverage'ı çalışmayan kodu bulmak için, test gücünü mutation testing ile ölç.
- Sadece eşiği aşan değeri test etmek →
>/>=hataları kaçar → eşiğin kendisi, bir altı ve bir üstü.
Hızlı tekrar
S: Stub ile mock arasındaki fark nedir?
C: Stub önceden belirlenmiş cevap döner ve testte sonuç (durum) doğrulanır; mock ile yapılan etkileşimler (davranış) doğrulanır. Mockito'da aynı nesne ikisi için de kullanılabilir.
S: Void bir metodu Mockito'da exception fırlatacak şekilde nasıl stub'larsın?
C: doThrow(new X()).when(mock).metot(args); BDD stilinde willThrow(new X()).given(mock).metot(args). when(mock.voidMetot()) derlenmez.
S: verifyNoInteractions ile verify(mock, never()).save(any()) farkı nedir?
C: never() sadece o metodun çağrılmadığını doğrular; verifyNoInteractions mock'la hiçbir etkileşim olmadığını doğrular.
S: Strict stubs neleri yakalar?
C: Hiç kullanılmayan stub'ları (UnnecessaryStubbingException) ve stub'lanmış bir metodun farklı argümanlarla çağrılmasını (PotentialStubbingProblem). MockitoExtension'da varsayılan açıktır.
S: Spy'ı neden doReturn().when() ile stub'larız?
C: when(spy.metot()) stub tanımlanmadan önce gerçek metodu çağırır; yan etki ya da exception oluşabilir. doReturn gerçek metodu çağırmaz.
S: Neyi mock'lamamalısın?
C: Value object'leri, entity'leri, kendi domain mantığını, sahibi olmadığın tipleri (onları kendi arayüzünün arkasına sar) ve saati (Clock enjekte et).
S: @MockBean ile @MockitoBean ilişkisi nedir?
C: @MockitoBean Spring Framework 6.2 ile geldi; Boot'un @MockBean'i Boot 3.4'te deprecated oldu ve Boot 4.0'da kaldırıldı. İkisi de context'teki bean'i Mockito mock'uyla değiştirir.
S: Farklı @MockitoBean kombinasyonları test süresini neden uzatır?
C: Bean override'ları context cache anahtarının parçasıdır; her farklı kombinasyon yeni bir ApplicationContext kurulması demektir.
S: @ServiceConnection ne yapar ve hangi sürümde geldi?
C: Testcontainers container'ının bağlantı bilgisini (URL, kullanıcı, şifre) Spring Boot'a otomatik bağlar; @DynamicPropertySource gereğini kaldırır. Spring Boot 3.1+.
S: Singleton container pattern hangi sorunu çözer?
C: Container'ı JVM başına bir kez başlatarak hem süreyi kısaltır hem de JUnit'in sınıf sonunda durdurduğu container'a cache'lenmiş Spring context'in bağlı kalması sorununu önler.
S: @Transactional testler neyi gizleyebilir?
C: Commit olmadığı için AFTER_COMMIT listener'ları tetiklenmez, flush edilmeyen constraint ihlalleri görünmez ve test ile kod aynı transaction'da olduğu için lazy loading hataları saklanır.
S: TDD döngüsü ve Canon TDD'nin eklediği adım nedir?
C: Red (doğru sebepten kırmızı test), green (geçiren en basit kod), refactor (yeşilken tasarımı iyileştir). Canon TDD başa davranışlardan oluşan bir test listesi ekler.
S: Flaky testin en sık dört nedeni nedir?
C: Zaman (now()), test sırası ve paylaşılan durum, asenkron beklemeler (sleep) ve ortam farkları (locale, timezone, port).
S: Mutation testing coverage'dan neyi farklı ölçer?
C: Coverage kodun çalışıp çalışmadığını, mutation testing testlerin bir hatayı yakalayıp yakalamadığını ölçer. Hayatta kalan mutant, doğrulanmayan bir davranışı gösterir.
Kaynaklar
- The Practical Test Pyramid: hangi testin hangi katmanda yazılacağına dair kapsamlı ve örnekli bir rehber.
- Mocks Aren't Stubs: test double türleri ve classicist/mockist ayrımının kaynağı.
- Testing of Microservices (Spotify): testing honeycomb yaklaşımı.
- Canon TDD (Kent Beck): test listesiyle başlayan TDD döngüsünün yazarından tanımı.
- Mockito Javadoc: stub, verify, spy, strictness ve
do*ailesinin resmi açıklaması. - AssertJ Documentation: fluent assertion API'si, recursive comparison ve soft assertion'lar.
- Spring Framework: @MockitoBean and @MockitoSpyBean: bean override mekanizması ve context cache'e etkisi.
- Spring Framework: Context Caching: cache anahtarının içeriği ve varsayılan boyut.
- Spring Boot: Testcontainers:
@ServiceConnection, container'ları bean olarak yönetme ve yaşam döngüsü uyarıları. - Testcontainers for Java: modüller, reuse ve singleton container deseni.
- PIT Mutation Testing: mutasyon operatörleri ve Maven/Gradle kurulumu.