← Back to list

Test-Driven Development Eğitim Serisi — Bölüm 20 — @WebMvcTest ile Controller Testleri

Faz 5: Spring Boot Ekosisteminde TDD

Sahin Yelkenci · 2026-07-13 20:38 · 0 claps · 13.7 min read
#test-driven-development #spring-boot-testing #webmvctest #mock-mvc #rest-api-testing
Open on Medium ↗

Test-Driven Development Eğitim Serisi — Bölüm 20 — @WebMvcTest ile Controller Testleri

Faz 5: Spring Boot Ekosisteminde TDD

» İçindekiler » Bu Bölüm Neden Önemli » @WebMvcTest’in Anatomisi » MockMvc API’sinin Anatomisi » İlk Tam Controller Testi » Bean Validation Testleri » Exception Handler Testleri » Spring Security ile Entegrasyon » Pagination ve Sorting Testleri » Spring REST Docs ile Yaşayan Dokümantasyon » WebTestClient: Reactive ve Modern Alternatif » Yaygın Tuzaklar » Bölüm 21'e Köprü » Üç Gözlem Sorusu » Felsefi Mühür » *https://gitlab.com/sahin.yelkenci2/tdd-pure-java » [https://gitlab.com/sahin.yelkenci2/tdd-spring](https://gitlab.com/sahin.yelkenci2/tdd-spring)*

» Bu Bölüm Neden Önemli

Bölüm 19'da @DataJpaTest ile persistence katmanını derinlemesine inceledik. Şimdi sistemin diğer ucuna — web katmanına — geçiyoruz. Modern bir Spring Boot uygulamasında controller'lar, dış dünyaya açılan giriş kapısıdır: HTTP isteği gelir, controller onu parse eder, validate eder, uygun service'e delege eder, sonucu serileştirip döner. Bu sınırın doğru test edilmesi, sistemin dış kontrat sağlığını belirler.

Türk Spring Boot kültüründe controller testleri, sıklıkla yarım yamalak yazılır. Yaygın iki uç vardır. Birinci uç: controller’ın hiç test edilmemesi — “controller sadece delege yapıyor, test gerekmez” varsayımıyla. Bu varsayım yanıltıcıdır; controller’ın validation, exception handling, JSON serialization, security, request mapping gibi onlarca sorumluluğu vardır. Hepsi test edilmeli. İkinci uç: controller’ın @SpringBootTest ile test edilmesi — bütün uygulamayı yüklüyor, çok yavaş çalışıyor. Bu, "her test 30 saniye sürüyor" senaryosuna götürür. @WebMvcTest, bu iki uç arasında doğru ortayı sunar.

Bu bölümün özgün katkısı, @WebMvcTest'in pratik incelikli haritasını çıkarmaktır. Slice'ın anatomisi, MockMvc API'sinin sade gücü, JSON serialization sınama teknikleri, Bean Validation testleri, exception handler test stratejisi, Spring Security ile entegrasyonun nüanslı yönetimi, ve Spring REST Docs ile testten otomatik dokümantasyon üretimi — hepsi bu bölümde işlenir. Modern bir REST API pratisyeninin web layer test pratiğinin tam kılavuzunu sunar.

Bir başka önemli mesele, API sözleşmesi kavramıdır. Bir controller, sadece kod değildir; bir API sözleşmesinin tezahürüdür. Dış sistemler (frontend, mobil uygulama, başka servisler) bu sözleşmeye güvenir. Sözleşmenin doğru çalıştığını her kod değişikliğinde sınamak, regression’ı önler. Web layer testleri bu sözleşmeyi çalıştırılabilir biçimde belgeleyen araçlardır; iyi yazıldıklarında, API dokümantasyonunun yerini bile alabilirler. Bu bölümün son bölümünde işleyeceğimiz Spring REST Docs, bu fikri sistematik biçimde uygular.

» @WebMvcTest’in Anatomisi

@WebMvcTest annotation'ı uygulandığında, Spring Boot arka planda spesifik bir context kurar. Bu context, web katmanına ait olan ama servis katmanını yüklemeyen optimize bir altyapıdır.

Yüklenen bean’ler ve auto-configuration’lar:

  • WebMvcAutoConfiguration — Spring MVC altyapısı (DispatcherServlet, HandlerMapping, HandlerAdapter)
  • JacksonAutoConfiguration — JSON serialization (ObjectMapper, MessageConverter'lar)
  • ValidationAutoConfiguration — Bean Validation (Hibernate Validator)
  • ErrorMvcAutoConfiguration — Error handling altyapısı
  • Test sınıfında belirtilen @Controller/@RestController sınıfları
  • @ControllerAdvice / @RestControllerAdvice sınıfları (default olarak hepsi)
  • Spring Security (eğer dependency’de varsa)

Yüklenmeyen bean’ler:

  • @Service sınıfları
  • @Repository sınıfları
  • @Component sınıfları (controller ve advice dışındakiler)
  • JPA, JDBC, MongoDB, vb. data layer auto-configuration’ları
  • Caching, scheduling, mail, vb. cross-cutting auto-configuration’ları

Otomatik enjekte edilen test bean’i: MockMvc. Bu, controller’lara HTTP isteklerini taklit etmek için kullanılan API’dir. Gerçek bir HTTP sunucusu başlatılmaz; ama Spring MVC’nin tam pipeline’ı (request mapping, validation, message conversion, exception handling) çalışır.

Filtreleme parametresi: @WebMvcTest(OrderController.class). Bu, sadece o controller'ı yükler; uygulamadaki diğer controller'lar test context'ine dahil edilmez. Bu, test'in odaklı olmasını sağlar.

Bu yapı, Mockist okulun pratik tezahürüdür (Bölüm 1011). Controller test edilirken, service collaborator’ları mock’lanır; HTTP, validation, serialization gibi controller’ın gerçek sorumlulukları sınanır; service mantığı izole edilir (zaten kendi testlerinde sınanmıştır).

» MockMvc API’sinin Anatomisi

MockMvc, Spring Test'in en güçlü API'lerinden biridir. Rossen Stoyanchev (Spring Web lead) ve Sam Brannen (Spring Test lead) tarafından geliştirilmiş, gerçek bir HTTP sunucusu başlatmadan Spring MVC pipeline'ını test eden bir araç. Yapısı üç ana parçaya ayrılır.

Request Builders. HTTP isteğini kuran metodlar:

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
get("/api/orders/{id}", orderId)          // GET request
post("/api/orders")                        // POST request
put("/api/orders/{id}", orderId)          // PUT request
patch("/api/orders/{id}", orderId)        // PATCH request
delete("/api/orders/{id}", orderId)       // DELETE request
multipart("/api/upload")                  // Multipart form upload

Request’e ek parametreler eklenir:

get("/api/orders")
    .param("status", "PENDING")           // Query parameter
    .header("Authorization", "Bearer ...")  // Custom header
    .contentType(MediaType.APPLICATION_JSON)
    .accept(MediaType.APPLICATION_JSON)
    .content("""
        {
          "customerId": "c-1",
          "productId": "p-1"
        }
        """)                              // Request body

MockMvc.perform() ile request gerçekleştirilir; bir ResultActions döner.

Result Actions. Response üzerinde assertion yapan metodlar:

import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
.andExpect(status().isOk())               // HTTP 200
.andExpect(status().isCreated())          // HTTP 201
.andExpect(status().isBadRequest())       // HTTP 400
.andExpect(jsonPath("$.id").value("123")) // JSON path assertion
.andExpect(jsonPath("$.items").isArray())
.andExpect(jsonPath("$.items.length()").value(3))
.andExpect(header().exists("Location"))
.andExpect(content().contentType(MediaType.APPLICATION_JSON))

JSON Path expressions Jayway JsonPath kütüphanesini kullanır. Hızlı referans:

  • $ — root
  • $.field — top-level field
  • $.array[0] — first element
  • $.array[*].field — every array element's field
  • $.array.length() — array size
  • $.field.subfield — nested field

Result handlers. Side effect’ler için:

.andDo(print())                           // Response'u stdout'a yazdır
.andDo(MockMvcResultHandlers.log())       // Debug log
ResultActions actions = ...;
MvcResult result = actions.andReturn();   // İlerideki manipülasyon için
String responseBody = result.getResponse().getContentAsString();

andDo(print()), debug için son derece değerli; request ve response'un tam içeriğini gösterir. Test başarısız olduğunda neyin yanlış olduğunu hızla görmenizi sağlar.

Çalışan örnek kodlara erişmek için: » https://gitlab.com/sahin.yelkenci2/tdd-pure-java » https://gitlab.com/sahin.yelkenci2/tdd-spring

» İlk Tam Controller Testi

Bütün bu API’yi somut bir örnekte birleştirelim. Bir OrderController testi:

@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
    private MockMvc mockMvc;
    @MockBean
    private PlaceOrderUseCase placeOrderUseCase;
    @Autowired
    private ObjectMapper objectMapper;   // Otomatik enjekte edilir
    @Test
    void post_orders_yeni_sipariş_oluşturur() throws Exception {
        // Arrange
        PlaceOrderResult expectedResult = new PlaceOrderResult(
            new OrderId("o-123"),
            Money.of("70.00"),     // finalPrice
            Money.of("30.00"),     // appliedDiscount
            Money.of("100.00")     // originalPrice
        );
        when(placeOrderUseCase.execute(any(PlaceOrderCommand.class)))
            .thenReturn(expectedResult);
        PlaceOrderRequest request = new PlaceOrderRequest(
            "c-1", "p-1", 1
        );
        // Act & Assert
        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated())
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            .andExpect(jsonPath("$.orderId").value("o-123"))
            .andExpect(jsonPath("$.finalPrice").value(70.00))
            .andExpect(jsonPath("$.appliedDiscount").value(30.00))
            .andExpect(jsonPath("$.originalPrice").value(100.00));
        // Use case'in doğru argümanlarla çağrıldığını verify et
        verify(placeOrderUseCase).execute(argThat(cmd ->
            cmd.customerId().equals(new CustomerId("c-1")) &&
            cmd.productId().equals(new ProductId("p-1")) &&
            cmd.quantity().equals(Quantity.of(1))
        ));
    }
    @Test
    void get_order_var_olan_sipariş_döner() throws Exception {
        OrderResponse expected = new OrderResponse(
            "o-123",
            "c-1",
            "PENDING",
            Money.of("70.00")
        );
        when(placeOrderUseCase.findById(new OrderId("o-123")))
            .thenReturn(Optional.of(expected));
        mockMvc.perform(get("/api/orders/{id}", "o-123")
                .accept(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value("o-123"))
            .andExpect(jsonPath("$.customerId").value("c-1"))
            .andExpect(jsonPath("$.status").value("PENDING"))
            .andExpect(jsonPath("$.finalPrice").value(70.00));
    }
    @Test
    void get_order_var_olmayan_id_için_404_döner() throws Exception {
        when(placeOrderUseCase.findById(new OrderId("o-nope")))
            .thenReturn(Optional.empty());
        mockMvc.perform(get("/api/orders/{id}", "o-nope")
                .accept(MediaType.APPLICATION_JSON))
            .andExpect(status().isNotFound());
    }
}

Bu testlerin önemli özellikleri var. Birincisi: PlaceOrderUseCase @MockBean ile mock'lanır. Web layer izole olarak sınanır; iş mantığı kendi testlerinde sınanmıştır. İkincisi: JSON path assertion'ları net biçimde API sözleşmesini ifade eder; "response şu shape'e sahip olmalı" cümlesi. Üçüncüsü: verify ile use case'in doğru argümanlarla çağrıldığı sınanır; bu, controller'ın doğru parse ettiğinin kanıtıdır.

argThat lambda'sının okunabilirliği bazen zayıf olabilir. Alternatif olarak ArgumentCaptor kullanılabilir (Bölüm 7'de işledik):

ArgumentCaptor<PlaceOrderCommand> commandCaptor = ArgumentCaptor.forClass(PlaceOrderCommand.class);
mockMvc.perform(/* ... */);
verify(placeOrderUseCase).execute(commandCaptor.capture());
PlaceOrderCommand captured = commandCaptor.getValue();
assertThat(captured.customerId()).isEqualTo(new CustomerId("c-1"));
assertThat(captured.productId()).isEqualTo(new ProductId("p-1"));
assertThat(captured.quantity()).isEqualTo(Quantity.of(1));

Bu yaklaşım, AssertJ’nin akıcı API’sini kullanarak daha okunaklı olur; özellikle karmaşık command’lerde tercih edilir.

Çalışan örnek kodlara erişmek için: » https://gitlab.com/sahin.yelkenci2/tdd-pure-java » https://gitlab.com/sahin.yelkenci2/tdd-spring

» Bean Validation Testleri

Spring Boot’un en yaygın kullanılan özelliklerinden biri, Bean Validation’dır. Controller’a gelen request body’leri @Valid annotation'ı ile valide edilir; constraint'ler ihlal edildiğinde otomatik olarak 400 Bad Request döner. Bu davranışın test edilmesi, API sözleşmesinin sağlamlığı açısından kritiktir.

Tipik bir request DTO’su:

public record PlaceOrderRequest(@NotBlank(message = "customerId boş olamaz")
    String customerId,
    @NotBlank(message = "productId boş olamaz")
    String productId,
    @Positive(message = "quantity pozitif olmalı")
    @Max(value = 100, message = "quantity 100'den büyük olamaz")
    int quantity
) {}

Controller, request’i @Valid ile alır:

@PostMapping
public ResponseEntity<PlaceOrderResponse> placeOrder(
    @Valid @RequestBody PlaceOrderRequest request
) {
    // ...
}

Bu validation’ların testi:

@Test
void post_orders_boş_customerId_için_400_döner() throws Exception {
    PlaceOrderRequest invalid = new PlaceOrderRequest(
        "",        // boş customerId
        "p-1",
        1
    );
mockMvc.perform(post("/api/orders")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(invalid)))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.errors[*].field").value(hasItem("customerId")))
        .andExpect(jsonPath("$.errors[*].message").value(hasItem("customerId boş olamaz")));
    verifyNoInteractions(placeOrderUseCase);   // Use case hiç çağrılmamalı
}
@Test
void post_orders_negatif_quantity_için_400_döner() throws Exception {
    PlaceOrderRequest invalid = new PlaceOrderRequest(
        "c-1", "p-1", -5
    );
    mockMvc.perform(post("/api/orders")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(invalid)))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.errors[*].field").value(hasItem("quantity")));
    verifyNoInteractions(placeOrderUseCase);
}
@Test
void post_orders_üst_limit_üstü_quantity_için_400_döner() throws Exception {
    PlaceOrderRequest invalid = new PlaceOrderRequest(
        "c-1", "p-1", 150
    );
    mockMvc.perform(post("/api/orders")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(invalid)))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.errors[*].field").value(hasItem("quantity")));
}

Bu testlerin önemli iki özelliği var. Birincisi: verifyNoInteractions(placeOrderUseCase) — validation başarısız olduğunda, iş kuralı hiç çalıştırılmamış olmalı. Bu, "kötü request'ler ön kapıdan geri çevrilir" prensibinin sağlanmasının kanıtıdır. İkincisi: hata mesajı yapısının sınanması — $.errors[*].field ve $.errors[*].message gibi path'ler, API hata sözleşmesinin parçasıdır; frontend bu yapıya güvenir, yapı değişmemelidir.

Validation hata mesajının yapısı, çoğu zaman bir @ControllerAdvice ile özelleştirilir; bu, bir sonraki konumuz.

Çalışan örnek kodlara erişmek için: » https://gitlab.com/sahin.yelkenci2/tdd-pure-java » https://gitlab.com/sahin.yelkenci2/tdd-spring

» Exception Handler Testleri

Spring Boot’un @RestControllerAdvice ile yazılan global exception handler'ları, uygulamanın hata sözleşmesini belirler. Bir use case'ten fırlatılan domain exception (örneğin ProductNotFoundException), bir HTTP response'a dönüştürülür. Bu dönüşümün doğru yapıldığını test etmek, controller test'lerinin önemli bir parçasıdır.

Tipik bir exception handler:

@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ProductNotFoundException.class)
    public ResponseEntity<ProblemDetail> handleProductNotFound(ProductNotFoundException ex) {
        ProblemDetail detail = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
        detail.setTitle("Product Not Found");
        detail.setDetail(ex.getMessage());
        detail.setProperty("productId", ex.getProductId().value());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(detail);
    }
    @ExceptionHandler(InsufficientStockException.class)
    public ResponseEntity<ProblemDetail> handleInsufficientStock(InsufficientStockException ex) {
        ProblemDetail detail = ProblemDetail.forStatus(HttpStatus.CONFLICT);
        detail.setTitle("Insufficient Stock");
        detail.setDetail(ex.getMessage());
        return ResponseEntity.status(HttpStatus.CONFLICT).body(detail);
    }
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ValidationErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
        List<ValidationError> errors = ex.getBindingResult().getFieldErrors().stream()
            .map(e -> new ValidationError(e.getField(), e.getDefaultMessage()))
            .toList();
        return ResponseEntity.badRequest().body(new ValidationErrorResponse(errors));
    }
}

Burada RFC 9457 ProblemDetail standardı kullanılır. Bu, HTTP API’lar için modern bir hata format standardıdır; Spring 6.0+ ve Spring Boot 3.0+ doğrudan desteğine sahiptir.

Exception handler testleri:

@Test
void post_orders_product_not_found_için_404_döner() throws Exception {
    when(placeOrderUseCase.execute(any()))
        .thenThrow(new ProductNotFoundException(new ProductId("p-nope")));
    PlaceOrderRequest request = new PlaceOrderRequest("c-1", "p-nope", 1);
    mockMvc.perform(post("/api/orders")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(request)))
        .andExpect(status().isNotFound())
        .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
        .andExpect(jsonPath("$.title").value("Product Not Found"))
        .andExpect(jsonPath("$.status").value(404))
        .andExpect(jsonPath("$.detail").exists())
        .andExpect(jsonPath("$.productId").value("p-nope"));
}
@Test
void post_orders_insufficient_stock_için_409_döner() throws Exception {
    when(placeOrderUseCase.execute(any()))
        .thenThrow(new InsufficientStockException(new ProductId("p-1"), 5, 10));
    PlaceOrderRequest request = new PlaceOrderRequest("c-1", "p-1", 10);
    mockMvc.perform(post("/api/orders")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(request)))
        .andExpect(status().isConflict())
        .andExpect(jsonPath("$.title").value("Insufficient Stock"));
}

Exception handler test’leri, @WebMvcTest slice'ında otomatik olarak çalışır; çünkü @RestControllerAdvice sınıfları default olarak yüklenir. Eğer sadece belirli advice'leri test etmek istiyorsanız, @WebMvcTest(controllers = OrderController.class, includeFilters = ...) ile kontrol edilir; ama çoğu durumda default davranış yeterlidir.

Çalışan örnek kodlara erişmek için: » https://gitlab.com/sahin.yelkenci2/tdd-pure-java » https://gitlab.com/sahin.yelkenci2/tdd-spring

» Spring Security ile Entegrasyon

Modern bir Spring Boot uygulamasında, Spring Security yaygın olarak kullanılır. @WebMvcTest ile Security entegrasyonu nüanslı bir konudur; çünkü Security default davranışı her endpoint'i koruma altına alır, ve test'lerde bu beklenmedik 401/403 hataları yaratabilir.

Default davranış: Eğer Spring Security classpath’te varsa, @WebMvcTest Security auto-configuration'ını yükler; tüm endpoint'ler authenticated user gerektirir. Bu, test'leri kırar — çünkü authentication yapılmaz.

Çözüm 1: @WithMockUser annotation’ı. Test metoduna @WithMockUser eklenir; sahte bir authenticated user simüle edilir.

@Test
@WithMockUser(username = "test-user", roles = "USER")
void authenticated_user_post_order_yapabilir() throws Exception {
    when(placeOrderUseCase.execute(any())).thenReturn(/* ... */);
    mockMvc.perform(post("/api/orders")
            .contentType(MediaType.APPLICATION_JSON)
            .content("..."))
        .andExpect(status().isCreated());
}
@Test
void anonymous_user_post_order_için_401_alır() throws Exception {
    // @WithMockUser yok, anonymous
    mockMvc.perform(post("/api/orders")
            .contentType(MediaType.APPLICATION_JSON)
            .content("..."))
        .andExpect(status().isUnauthorized());
}

Çözüm 2: @WithUserDetails. Eğer custom UserDetailsService varsa, gerçek bir user'ı çekip kullanır. Bu, daha realistic olabilir ama setup gerektirir.

Çözüm 3: CSRF disable. POST/PUT/DELETE request’leri için Spring Security default olarak CSRF token bekler; test’lerde bunu vermek karmaşık olabilir. İki seçenek var:

// Seçenek A: request'e CSRF token ekle
.with(csrf())
// Seçenek B: test configurasyonunda CSRF disable

Çözüm A kod örneği:

@Test
@WithMockUser
void post_with_csrf_token_works() throws Exception {
    mockMvc.perform(post("/api/orders")
            .with(csrf())   // CSRF token eklenir
            .contentType(MediaType.APPLICATION_JSON)
            .content("..."))
        .andExpect(status().isCreated());
}

Çözüm 4: Security’yi devre dışı bırakmak. Sadece test için, Security auto-configuration’ını exclude edebilirsiniz:

@WebMvcTest(
    controllers = OrderController.class,
    excludeAutoConfiguration = {SecurityAutoConfiguration.class}
)
class OrderControllerTest {
    // Security yok, her test direkt çalışır
}

Bu yaklaşım pratik olabilir ama risk taşır: Security konfigürasyonundaki bir bug fark edilmez. Karışık bir yaklaşım: bazı testler @WithMockUser ile (security davranışı test edilir), bazı testler exclude ile (sadece controller mantığı test edilir).

JWT ve OAuth2 testleri. Modern uygulamalar genellikle JWT veya OAuth2 kullanır. Spring Security’nin test desteği bu protokolleri kapsar:

@Test
void jwt_ile_authenticated_request() throws Exception {
    mockMvc.perform(post("/api/orders")
            .with(jwt().jwt(jwt -> jwt.subject("user-1").claim("scope", "orders:write")))
            .contentType(MediaType.APPLICATION_JSON)
            .content("..."))
        .andExpect(status().isCreated());
}

SecurityMockMvcRequestPostProcessors.jwt() ile JWT-authenticated request taklit edilir; gerçek bir JWT token üretilmesine gerek yoktur.

Çalışan örnek kodlara erişmek için: » https://gitlab.com/sahin.yelkenci2/tdd-pure-java » https://gitlab.com/sahin.yelkenci2/tdd-spring

» Pagination ve Sorting Testleri

Spring Data ile entegre çalışan REST endpoint’leri, genellikle Pageable parametresi alır. Bu parametre, query string'den page, size, sort değerlerini okur. Test'leri:

@RestController
@RequestMapping("/api/orders")
public class OrderController {
    @GetMapping
    public Page<OrderResponse> listOrders(
        @RequestParam(required = false) String customerId,
        @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
        Pageable pageable
    ) {
        return placeOrderUseCase.listOrders(customerId, pageable);
    }
}
@Test
void list_orders_pagination_doğru_uygulanır() throws Exception {
    List<OrderResponse> orders = List.of(
        new OrderResponse("o-1", "c-1", "PENDING", Money.of("100")),
        new OrderResponse("o-2", "c-1", "CONFIRMED", Money.of("200"))
    );
    Page<OrderResponse> page = new PageImpl<>(orders, PageRequest.of(0, 20), 50);
    when(placeOrderUseCase.listOrders(eq("c-1"), any(Pageable.class)))
        .thenReturn(page);
    mockMvc.perform(get("/api/orders")
            .param("customerId", "c-1")
            .param("page", "0")
            .param("size", "20"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.content").isArray())
        .andExpect(jsonPath("$.content.length()").value(2))
        .andExpect(jsonPath("$.totalElements").value(50))
        .andExpect(jsonPath("$.totalPages").value(3))
        .andExpect(jsonPath("$.number").value(0))
        .andExpect(jsonPath("$.size").value(20));
    ArgumentCaptor<Pageable> pageableCaptor = ArgumentCaptor.forClass(Pageable.class);
    verify(placeOrderUseCase).listOrders(eq("c-1"), pageableCaptor.capture());
    Pageable captured = pageableCaptor.getValue();
    assertThat(captured.getPageNumber()).isEqualTo(0);
    assertThat(captured.getPageSize()).isEqualTo(20);
}

Page response yapısı (content, totalElements, totalPages, number, size) Spring Data'nın standart formatıdır. Frontend'ler bu format bekler; format değişiklikleri client'ları kırar. Bu yüzden bu yapının test edilmesi önemlidir.

Çalışan örnek kodlara erişmek için: » https://gitlab.com/sahin.yelkenci2/tdd-pure-java » https://gitlab.com/sahin.yelkenci2/tdd-spring

» Spring REST Docs ile Yaşayan Dokümantasyon

@WebMvcTest'in en güçlü kullanımlarından biri, API dokümantasyonunun otomatik üretilmesidir. Spring REST Docs, Mike Wiesner tarafından geliştirilmiş ve Spring topluluğuna katılmış bir araçtır. Test'lerin çalıştırılması sırasında, request ve response'lar yakalanır; bunlardan AsciiDoc veya Markdown formatında dokümantasyon üretilir.

Bu yaklaşımın iki büyük avantajı vardır. Birincisi: dokümantasyon gerçek davranıştan üretilir, manuel yazılmaz; “kod ile doc senkron mu” sorunu ortadan kalkar. İkincisi: test başarısız olursa doc da üretilmez; doc her zaman çalışan davranışı yansıtır.

Tipik kullanım:

@WebMvcTest(OrderController.class)
@AutoConfigureRestDocs
class OrderControllerDocTest {
    @Autowired
    private MockMvc mockMvc;
    @MockBean
    private PlaceOrderUseCase placeOrderUseCase;
    @Test
    void create_order_documentation() throws Exception {
        when(placeOrderUseCase.execute(any())).thenReturn(/* ... */);
        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {
                      "customerId": "c-1",
                      "productId": "p-1",
                      "quantity": 1
                    }
                    """))
            .andExpect(status().isCreated())
            .andDo(document("orders/create",
                requestFields(
                    fieldWithPath("customerId").description("Müşteri kimliği"),
                    fieldWithPath("productId").description("Ürün kimliği"),
                    fieldWithPath("quantity").description("Sipariş edilen adet (1-100)")
                ),
                responseFields(
                    fieldWithPath("orderId").description("Oluşturulan siparişin kimliği"),
                    fieldWithPath("finalPrice").description("Ödenen son fiyat"),
                    fieldWithPath("appliedDiscount").description("Uygulanan indirim"),
                    fieldWithPath("originalPrice").description("İndirim öncesi fiyat")
                )
            ));
    }
}

Test çalıştırıldığında, build/generated-snippets/orders/create/ dizininde AsciiDoc snippet'ları üretilir:

  • http-request.adoc — HTTP request örneği
  • http-response.adoc — HTTP response örneği
  • request-fields.adoc — Request alan tablosu
  • response-fields.adoc — Response alan tablosu
  • curl-request.adoc — cURL komutu
  • httpie-request.adoc — HTTPie komutu

Bu snippet’ları bir ana AsciiDoc dosyasında include ederek tam API dokümantasyonu üretirsiniz:

== Order API
=== Create Order
include::{snippets}/orders/create/http-request.adoc[]
include::{snippets}/orders/create/request-fields.adoc[]
include::{snippets}/orders/create/http-response.adoc[]
include::{snippets}/orders/create/response-fields.adoc[]

Spring REST Docs, **Bölüm 4'te konuştuğumuz “yaşayan dokümantasyon”** kavramının somut bir tezahürüdür. Test geçer → dokümantasyon geçerlidir. Test kırılır → dokümantasyon eskidir (üretilmez). Bu sıkı bağ, API dokümantasyonunun her zaman doğru olmasını garanti eder.

Çalışan örnek kodlara erişmek için: » https://gitlab.com/sahin.yelkenci2/tdd-pure-java » https://gitlab.com/sahin.yelkenci2/tdd-spring

» WebTestClient: Reactive ve Modern Alternatif

Spring 5'te tanıtılan WebTestClient, MockMvc'nin reactive ve modern alternatifidir. Hem MVC hem WebFlux uygulamalarını test edebilir; daha fluent bir API'ye sahiptir. Yeni projelerde WebTestClient tercih edilmeye başlanmıştır.

@WebMvcTest(OrderController.class)
@AutoConfigureMockMvc
class OrderControllerWebTestClientTest {
    @Autowired
    private WebTestClient webTestClient;
    @MockBean
    private PlaceOrderUseCase placeOrderUseCase;
    @Test
    void post_orders_creates_order() {
        when(placeOrderUseCase.execute(any())).thenReturn(/* ... */);
        webTestClient.post().uri("/api/orders")
            .contentType(MediaType.APPLICATION_JSON)
            .bodyValue(new PlaceOrderRequest("c-1", "p-1", 1))
            .exchange()
            .expectStatus().isCreated()
            .expectBody()
            .jsonPath("$.orderId").isEqualTo("o-123")
            .jsonPath("$.finalPrice").isEqualTo(70.00);
    }
}

WebTestClient’in faydaları MockMvc’ye göre şunlardır:

  • Daha akıcı API (exchange(), expectBody(), jsonPath())
  • Hem MVC hem WebFlux uyumlu
  • Type-safe response body extraction (expectBody(OrderResponse.class))
  • Reactive streams (StepVerifier ile)

MockMvc hâlâ yaygın olarak kullanılır; ama yeni projelerde WebTestClient tercih edilebilir. İki API’yi de bilmek, modern Spring Boot pratisyeni için değerlidir.

Çalışan örnek kodlara erişmek için: » https://gitlab.com/sahin.yelkenci2/tdd-pure-java » https://gitlab.com/sahin.yelkenci2/tdd-spring

» Yaygın Tuzaklar

@WebMvcTest ile karşılaşılan yaygın tuzakları listeleyelim.

Tuzak 1: Security default’u test’leri kırıyor. Security classpath’te varsa, her endpoint authenticated user gerektirir. Çözüm: @WithMockUser veya security exclude.

Tuzak 2: CSRF token unutulması. POST/PUT/DELETE request’leri CSRF token ister; eklenmezse 403 alır. Çözüm: .with(csrf()) veya CSRF disable.

Tuzak 3: ContentType belirtmeme. Request body var ama contentType belirtilmemiş; Spring 415 Unsupported Media Type döner. Çözüm: her zaman .contentType(MediaType.APPLICATION_JSON).

Tuzak 4: Accept header’sız. Bazı endpoint’ler Accept header'ı bekler; eksikse 406 alır. Çözüm: .accept(MediaType.APPLICATION_JSON).

Tuzak 5: JSON path tip uyumsuzluğu. jsonPath("$.amount").value("100.00") vs value(100.00) — string mi numeric mi? Hatalı tip karşılaştırması test'i kırar. Çözüm: response'un gerçek tipini bilin.

Tuzak 6: ObjectMapper kullanmadan JSON yazma. Manuel JSON string yazmak yerine objectMapper.writeValueAsString(request) kullanın; Jackson'ın gerçek serialization'ını kullanır, format farkı olmaz.

Tuzak 7: Mock setup yapmadan use case’i çağırmak. @MockBean ile mock yaratıldıktan sonra, davranış stub'lanmazsa default değer (null) döner; bu beklenmedik exception'lara yol açabilir. Her test'te ilgili mock'ları setup edin.

Tuzak 8: @WebMvcTest’in tüm controller’ları yüklemesi. Eğer @WebMvcTest(SomeController.class) parametre vermezseniz, tüm controller'lar yüklenir; bu performansı düşürür ve test odaklılığını azaltır. Her test sınıfı için spesifik controller belirtin.

» Bölüm 21'e Köprü

Bu bölümde, @WebMvcTest slice'ının tam pratik haritasını çıkardık. Slice anatomisini, MockMvc API'sinin gücünü, JSON serialization sınama tekniklerini, Bean Validation testlerini, exception handler test stratejisini, Spring Security ile nüanslı entegrasyonu, pagination/sorting testlerini, ve Spring REST Docs ile yaşayan dokümantasyon üretimini sistematik olarak işledik. Modern bir REST API pratisyeninin web layer test pratiğinin omurgası, bu bölümde somutlaştı.

Bir sonraki bölümde, **@SpringBootTest slice'ına geçiyoruz. Tam application context'ini yükleyen bu slice, acceptance test'lerin** ana aracıdır. WebEnvironment seçenekleri, TestRestTemplate ve WebTestClient kullanımı, gerçek HTTP isteklerinin test edilmesi, transaction yönetimi farkları, test profile yönetimi — hepsi Bölüm 21'de detaylı işlenecek. Bu bölüm, Bölüm 12'deki Outside-In TDD seansında yazdığımız acceptance test'lerin teorik altyapısını sağlayacak.

» Üç Gözlem Sorusu

Birinci soru: Projenizdeki controller’lar için ne kadar test kapsama oranı var? Validation testleri var mı? Exception handler testleri var mı? Eğer cevap “az” ise, hangi production incident’leri bu testlerle önlenebilirdi? Bir test eklemenin maliyeti vs incident’in maliyeti karşılaştırması, hangi test’lere öncelik vereceğinizi gösterir.

İkinci soru: API’nizin Swagger/OpenAPI dokümantasyonu var mı? Eğer var, dokümantasyon ile kod arasındaki “drift” (sapma) sorunu yaşıyor musunuz? Dokümantasyon eski, kod yeni — bu yaygın bir problemdir. Spring REST Docs ile testten dokümantasyon üretmenin, bu sorunu çözmedeki rolü nedir?

Üçüncü soru: Controller test’lerinizde Spring Security ile entegrasyonu nasıl yönetiyorsunuz? @WithMockUser mı, security exclude mı, başka bir yaklaşım mı? Tercih ettiğiniz yöntem, security konfigürasyon değişikliklerinde testlerin doğru davranıp davranmadığını sınıyor mu? Yoksa "security'yi atla, sadece controller'ı test et" yaklaşımı, gerçek güvenlik bug'larını gizliyor mu?

» Felsefi Mühür

Bu bölümün kapanışı için, Spring Web lead’i Rossen Stoyanchev’in bir konferans konuşmasından, controller test’lerinin niyetini özetleyen bir cümleye dönelim:

“A controller is not just code — it is a contract with the outside world. When you test a controller, you are not just testing logic; you are verifying a contract. Every JSON path expression, every status code, every validation rule in your test is a promise to your API’s clients. Break the promise, and you break their world.”

“Bir controller sadece bir kod parçası değildir; dış dünya ile yapılan bir sözleşmedir. Bir controller’ı test ettiğinizde, sadece mantığı test etmiyorsunuz; bir sözleşmeyi doğruluyorsunuz. Testinizdeki her JSON yol ifadesi, her durum kodu, her doğrulama kuralı, API’nizin kullanıcılarına verdiğiniz bir sözdür. Bu sözü tutmazsanız, onların dünyasını altüst edersiniz.”

  • Rossen Stoyanchev, Spring Web lead, Spring konferanslarından*

Bu cümlenin Türkçesi şudur: bir controller sadece kod değildir — dış dünya ile bir sözleşmedir. Bir controller’ı test ederken, sadece mantığı test etmiyorsunuz; bir sözleşmeyi doğruluyorsunuz. Testinizdeki her JSON path ifadesi, her status code, her validation kuralı, API’nizin istemcilerine bir vaattir. Vaadi bozarsanız, onların dünyasını bozarsınız. Bu cümle, controller test’lerinin gerçek değerini söyler. Bir Spring controller, sadece bir Java sınıfı değildir; bir kontrat noktasıdır. Bu kontrat, frontend developers, mobil developers, başka servisler tarafından güvenilir bir taban olarak kullanılır. Bu güvenin korunması, sistematik web layer test’leri ile sağlanır; ve @WebMvcTest bunun en pratik aracıdır. Her test bir vaat, her test geçişi vaadin korunduğunun kanıtı.

Bir sonraki bölümde, **@SpringBootTest** ile tam slice'a — uçtan uca acceptance test'lere — geçiyoruz. Web, service, persistence katmanlarının birlikte çalıştığı, gerçek HTTP isteklerinin sınandığı, TestContainers ve WireMock ile dış dünyanın yönetildiği bir test pratiği. Bölüm 21, Faz 5'in en kapsamlı pratik bölümü olacak.

**İçindekiler… « Önceki [Bölüm 19 — @DataJpaTest ile Repository Testleri] » Sonraki **[Bölüm 21 — @SpringBootTest ile Tam Integration Testleri] » https://gitlab.com/sahin.yelkenci2/tdd-pure-java » https://gitlab.com/sahin.yelkenci2/tdd-spring


메타데이터
post_id
2bda02448e20
slug
test-driven-development-eğitim-serisi-bölüm-20-webmvctest-ile-controller-testleri-2bda02448e20
url
https://medium.com/@sahinyelkenci/test-driven-development-e%C4%9Fitim-serisi-b%C3%B6l%C3%BCm-20-webmvctest-ile-controller-testleri-2bda02448e20
canonical_url
https://medium.com/@sahinyelkenci/test-driven-development-e%C4%9Fitim-serisi-b%C3%B6l%C3%BCm-20-webmvctest-ile-controller-testleri-2bda02448e20
author_url
https://medium.com/@sahinyelkenci
status
ok
fetched_at
2026-08-27 09:18:55