Spring MVC'de Test Yazmak

MockMvc ve @WebMvcTest ile web katmanı testleri; @MockitoBean ile bağımlılıkları sahteleme; model(), view() ve jsonPath() ile doğrulama; request body, path variable/query param, validation hataları ve multipart dosya yükleme testleri; bu projenin gerçek HomeController'ı ve TopicController'ı için gerçek testler.

İleri 45 dk
EN

Spring MVC'de Test Yazmak

Bu, Spring MVC kategorisinin son dersi -- ve bir bakıma hepsini bir araya getiriyor. Şu ana kadar @Controller/@RestController, @RequestBody/ ResponseEntity, @Valid/ProblemDetail, Thymeleaf view'ları, HandlerInterceptor/CORS/multipart, ve DTO/pagination/idempotency desenlerini yazdık -- ama hiçbirini gerçekten çalıştırıp doğrulamadık. Bu ders, MockMvc ve @WebMvcTest ile bu kodun gerçekten söylediğini yaptığını, gerçek bir sunucu ayağa kaldırmadan, hızlı ve tekrarlanabilir bir şekilde nasıl kanıtlayacağımızı ele alıyor.

Spring MVC'de Test Katmanları Nedir?

Bir Spring MVC uygulamasını test etmenin tek bir yolu yok -- amaca göre değişen birkaç katman var:

// Üç farklı test, üç farklı hız/gerçekçilik dengesi:
// 1) Saf birim testi: yeni TopicController(...).show(...) -- Spring hiç yok.
// 2) Slice testi: @WebMvcTest + MockMvc -- yalnızca web katmanı yüklü.
// 3) Entegrasyon testi: @SpringBootTest -- gerçek uygulama, gerçek DB (ya da test container'ı).

Bu ders esas olarak ortadaki katmana odaklanıyor: MockMvc ile @WebMvcTest. Saf birim testi çok hızlı ama HTTP'nin kendisini (path matching, header'lar, serialization) hiç doğrulamaz; @SpringBootTest gerçekçi ama yavaş ve bir veritabanı gerektirir. @WebMvcTest, ikisi arasında -- gerçek HTTP isteği işleme mekaniğini, gerçek bir sunucu ya da veritabanı olmadan test eder.

Neden Var?

Bir controller'ı elle (curl ile ya da tarayıcıdan) test etmek, her değişiklikte tekrar tekrar yapılması gereken, unutulması kolay, otomasyona uygun olmayan bir iştir. spring-mvc-fundamentals dersinden bu yana yazdığımız her controller -- path matching, model attribute'ları, JSON serileştirme, validation, hata gövdeleri -- otomatik olarak, her kod değişikliğinde yeniden doğrulanabilir olmalı. MockMvc, bunu gerçek bir HTTP sunucusu açmadan (soket yok, port yok) yapmayı mümkün kılıyor -- bu da testleri hem hızlı hem de CI ortamında güvenilir kılıyor.

Tarihçe

Spring Test MVC, başlangıçta Spring Framework'ün ana gövdesinin dışında, ayrı bir spring-test-mvc projesi olarak (2012 civarı) başladı; MockMvc ve andExpect zincirleme API'si buradan geldi. Spring 3.2 ile bu proje Spring Framework'ün kendisine (spring-test modülüne) taşındı. Spring Boot 1.4 (2016), @WebMvcTest ve kardeşi @DataJpaTest gibi "slice test" annotation'larını tanıttı -- amaç, tüm ApplicationContext'i değil, testin gerçekten ihtiyaç duyduğu dilimi yüklemekti. @MockBean, uzun süre bu dilimlerdeki eksik bağımlılıkları doldurmanın standart yoluydu; Spring Boot 3.4 (2024) ile deprecated edildi ve yerini, Spring Framework'ün kendi test altyapısına taşınan @MockitoBean'e bıraktı -- bu proje Spring Boot 4.1.0 kullandığı için burada yalnızca @MockitoBean kullanıyoruz.

Unit Test vs Slice Test vs Integration Test: @WebMvcTest Nerede Durur?

@WebMvcTest, spring-mvc-fundamentals dersinin "Bu Projenin Kendi Controller'ları: Gerçek Bir Spring MVC Örneği" bölümünde gördüğümüz gerçek HandlerMapping/HandlerAdapter/ViewResolver mekanizmasını, DispatcherServlet'in front controller deseniyle birlikte gerçekten çalıştırır -- ama @Service/ @Repository katmanını ve gerçek bir veritabanı bağlantısını YÜKLEMEZ. Bu, onu üç seçenek arasında bilinçli bir orta nokta yapar: saf birim testinden daha gerçekçi (gerçek HTTP request/response mekaniği çalışır), tam @SpringBootTest'ten daha hızlı (veritabanı yok, tüm bean'ler yok). Bu dersin geri kalanı, tam olarak bu orta noktaya -- @WebMvcTest ve MockMvc'ye -- odaklanıyor.

@WebMvcTest ve MockMvc: Yalnızca Web Katmanını Yüklemek

@WebMvcTest'in ne yüklediğini, ne yüklemediğini somut bir örnekle görelim:

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.stereotype.Controller;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.GetMapping;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// @WebMvcTest loads a SLICE of the application, not the whole thing: DispatcherServlet,
// HandlerMapping/HandlerAdapter, message converters, the given controller (and other
// @Controller/@ControllerAdvice/@Converter/HandlerInterceptor/WebMvcConfigurer beans) --
// but NOT @Service/@Repository/@Component beans, and NOT a real database connection.
// This is a "run via `mvn test`" example, not a plain main() program -- @WebMvcTest
// needs JUnit's test runner and a Spring TestContext to do its work.
@WebMvcTest(WebMvcTestSliceExample.PingController.class)
class WebMvcTestSliceExample {

    // A tiny controller defined right here, just to keep this example self-contained --
    // in a real test this would be an existing @Controller/@RestController class.
    @Controller
    static class PingController {
        @GetMapping("/ping")
        @org.springframework.web.bind.annotation.ResponseBody
        String ping() {
            return "pong";
        }
    }

    private final MockMvc mockMvc;

    WebMvcTestSliceExample(MockMvc mockMvc) {
        // MockMvc is one of the few beans @WebMvcTest auto-configures and lets you
        // inject directly -- no manual setup needed.
        this.mockMvc = mockMvc;
    }

    @Test
    void pingReturnsPong() throws Exception {
        mockMvc.perform(get("/ping"))
                .andExpect(status().isOk());
        // If PingController depended on a @Service, this test would fail at context
        // startup with "no qualifying bean" -- @WebMvcTest deliberately does NOT wire
        // up @Service/@Repository beans; see "@MockitoBean ile Bağımlılıkları
        // Sahtelemek" for how to supply the ones a real controller needs.
    }
}

@WebMvcTest(PingController.class), DispatcherServlet, mesaj converter'ları, ve belirtilen controller'ı (bir de varsa @ControllerAdvice/HandlerInterceptor/WebMvcConfigurer bean'lerini) yükler -- ama bir @Service bağımlılığı olsaydı, context başlatma anında "no qualifying bean" hatasıyla patlardı. MockMvc, bu daraltılmış context'in otomatik olarak inject edebildiği birkaç bean'den biri.

İlk MockMvc Testi: perform, andExpect, status()

MockMvc'yi, hatta bir Spring context'i bile beklemeden, en yalın haliyle görelim:

import org.springframework.stereotype.Controller;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.GetMapping;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// MockMvcBuilders.standaloneSetup(...) inşa eder MockMvc'yi bir Spring ApplicationContext
// OLMADAN -- sadece verilen controller(lar)ı DispatcherServlet benzeri bir pipeline'a
// elle bağlar. Bu yüzden bu dosya, projenin main()-ile-çalıştırılabilir kuralına uyarak
// plain main() ile çalışabilir; gerçek @WebMvcTest/@SpringBootTest testleri (aşağıdaki
// diğer örneklerde) bir JUnit runner + Spring TestContext gerektirir.
public class FirstMockMvcTestExample {

    @Controller
    static class GreetingController {
        @GetMapping("/greeting")
        @org.springframework.web.bind.annotation.ResponseBody
        String greeting() {
            return "Merhaba, MockMvc!";
        }
    }

    public static void main(String[] args) throws Exception {
        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new GreetingController()).build();

        // perform(): sahte bir HTTP isteği gönderir (gerçek soket açılmaz, gerçek port
        // dinlenmez -- her şey aynı JVM içinde, servlet API'sinin sahte implementasyonlarıyla
        // çalışır). andExpect(): zincirlenebilir doğrulamalar; biri başarısız olursa
        // AssertionError fırlatır ve zincirin geri kalanı çalışmaz.
        mockMvc.perform(get("/greeting"))
                .andExpect(status().isOk())
                .andExpect(content().string("Merhaba, MockMvc!"));

        System.out.println("Tum andExpect() dogrulamalari basarili -- gercek bir HTTP");
        System.out.println("sunucusu hic acilmadi.");

        // status(): HTTP durum kodunu doğrular -- isOk() (200), isNotFound() (404),
        // isBadRequest() (400) gibi okunabilir yardımcı metotlarla.
        // content(): yanıt gövdesini doğrular -- string(), contentType(), json() vb.
    }
}

MockMvcBuilders.standaloneSetup(...), verilen controller(lar)ı bir Spring ApplicationContext OLMADAN, elle bir mini pipeline'a bağlar -- bu yüzden bu örnek, projenin main() ile çalıştırma kuralına uyarak plain main() ile çalışabiliyor. perform(...) sahte bir istek gönderir (gerçek soket açılmaz), andExpect(...) zincirlenebilir doğrulamalar yapar ve biri başarısız olursa AssertionError fırlatır.

@MockitoBean ile Bağımlılıkları Sahtelemek

@WebMvcTest'in @Service/@Repository bean'lerini yüklemediğini gördük -- peki controller bunlara gerçekten bağımlıysa ne olur?

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.stereotype.Controller;
import org.springframework.stereotype.Service;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// NOT: @MockBean, Spring Boot 3.4'ten beri deprecated ve 4.0'da kaldırılması planlanıyordu;
// bu proje Spring Boot 4.1.0 kullandığı için burada ve sonraki tüm örneklerde
// SADECE @MockitoBean (org.springframework.test.context.bean.override.mockito.MockitoBean)
// kullanılıyor. Bu, bir JUnit test sınıfıdır -- main() ile çalışmaz, `mvn test` gerektirir.
@WebMvcTest(MockitoBeanExample.GreeterController.class)
class MockitoBeanExample {

    // Gerçek uygulamada bir @Service olurdu; burada örneği self-contained tutmak için
    // dosyanın içinde tanımlı.
    interface GreetingService {
        String greetingFor(String name);
    }

    @Service
    static class RealGreetingService implements GreetingService {
        @Override
        public String greetingFor(String name) {
            throw new UnsupportedOperationException("Gerçek implementasyon burada önemli değil");
        }
    }

    @Controller
    static class GreeterController {
        private final GreetingService greetingService;

        GreeterController(GreetingService greetingService) {
            this.greetingService = greetingService;
        }

        @GetMapping("/greet")
        @org.springframework.web.bind.annotation.ResponseBody
        String greet() {
            return greetingService.greetingFor("Cem");
        }
    }

    @Autowired
    private MockMvc mockMvc;

    // @MockitoBean: context'e GreetingService türünde bir Mockito sahtesi ekler (veya
    // varsa gerçek bean'in yerine geçirir). @WebMvcTest zaten @Service'leri yüklemediği
    // için, GreeterController'ın bağımlılığı bu olmadan "no qualifying bean" hatasıyla
    // context başlatma anında patlardı.
    @MockitoBean
    private GreetingService greetingService;

    @Test
    void greetUsesMockedService() throws Exception {
        when(greetingService.greetingFor("Cem")).thenReturn("Merhaba, Cem!");

        mockMvc.perform(get("/greet"))
                .andExpect(status().isOk())
                .andExpect(content().string("Merhaba, Cem!"));

        // RealGreetingService hiç çalışmadı -- sadece sahte nesnenin döndürdüğü değer
        // kullanıldı. Bu, testi RealGreetingService'in implementasyon detaylarından
        // (örn. bir veritabanı çağrısından) tamamen izole eder.
    }
}

@MockitoBean, context'e ilgili türden bir Mockito sahtesi ekler (ya da varsa gerçek bean'in yerine geçirir) -- GreeterController'ın bağımlılığı olan GreetingService, bu olmadan context başlatma anında "no qualifying bean" hatasıyla patlardı. Not: @MockBean (Spring Boot 3.4'ten beri deprecated, bu projenin kullandığı 4.1.0'da kaldırıldı) yerine, burada ve bu dersin geri kalanında kesinlikle @MockitoBean kullanıyoruz.

Bu Projenin Kendi HomeController'ını Test Etmek: Gerçek Bir Örnek

Kurgu bir controller değil, spring-mvc-fundamentals dersinin "Bu Projenin Kendi Controller'ları: Gerçek Bir Spring MVC Örneği" bölümünde tanıttığımız gerçek HomeController'ı test edelim:

import com.cdurgun.learning.controller.HomeController;
import com.cdurgun.learning.service.NavigationService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import java.util.List;

import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;

// Bu projenin GERÇEK HomeController'ının gerçek bir @WebMvcTest testi -- kurgu bir
// controller değil. HomeController'ın tek bağımlılığı NavigationService, bu yüzden
// tek bir @MockitoBean yeterli. JUnit test sınıfı, `mvn test` ile çalışır.
@WebMvcTest(HomeController.class)
class HomeControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private NavigationService navigationService;

    @Test
    void indexReturnsIndexViewWithNavigationModel() throws Exception {
        // buildNavigation gerçek DB'ye gitmiyor -- boş liste dönmesi bile yeterli,
        // çünkü burada test edilen şey NavigationService'in DAVRANIŞI değil,
        // HomeController'ın onu nasıl ÇAĞIRDIĞI ve model'e nasıl koyduğu.
        when(navigationService.buildNavigation(org.mockito.ArgumentMatchers.any())).thenReturn(List.of());

        mockMvc.perform(get("/"))
                .andExpect(status().isOk())
                .andExpect(view().name("index"))
                .andExpect(model().attributeExists("language"))
                .andExpect(model().attributeExists("nav"));

        // Gerçek controller kodunu okuyunca görürsünüz: `language`,
        // LocaleContextHolder.getLocale() üzerinden çözülüyor -- bu test bunu HTTP
        // isteğinin kendi varsayılan locale'iyle (test ortamında genelde Locale.getDefault())
        // doğal olarak sağlıyor; belirli bir dili zorlamak isterseniz `.locale(Locale.forLanguageTag("tr"))`
        // ekleyebilirsiniz.
        verify(navigationService).buildNavigation(org.mockito.ArgumentMatchers.any());
    }
}

HomeController'ın tek bağımlılığı NavigationService olduğu için tek bir @MockitoBean yeterli. buildNavigation(...)'ın döndürdüğü gerçek listeye (ya da içeriğine) hiç önem vermiyoruz -- burada test edilen şey NavigationService'in davranışı değil, HomeController'ın onu doğru çağırıp çağırmadığı ve model'e doğru attribute'ları koyup koymadığı.

Model ve View Adını Doğrulamak: model(), view()

Klasik (JSON döndürmeyen) bir @Controller için content()'ten daha anlamlı olan iki matcher:

import org.springframework.stereotype.Controller;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;

// model()/view(): sadece HTTP durumuna ve gövdesine değil, controller'ın Model'e
// hangi attribute'ları koyduğuna ve hangi view adını döndürdüğüne bakar -- klasik
// (JSON döndürmeyen, Thymeleaf ile render edilen) bir @Controller için bu genelde
// content()'ten daha anlamlıdır, çünkü render edilmiş HTML'i değil, controller'ın
// SÖZLEŞMESİNİ (hangi view, hangi veriyle) doğrular.
public class ModelAndViewAssertionExample {

    @Controller
    static class ProfileController {
        @GetMapping("/profile")
        String profile(Model model) {
            model.addAttribute("username", "cdurgun");
            model.addAttribute("topicCount", 25);
            return "profile";
        }
    }

    public static void main(String[] args) throws Exception {
        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new ProfileController()).build();

        mockMvc.perform(get("/profile"))
                .andExpect(status().isOk())
                // view().name(): dönen mantıksal view adını doğrular (fiziksel
                // profile.html dosyasının render edilip edilmediğini DEĞİL --
                // standaloneSetup'ta bir ViewResolver/template motoru yok).
                .andExpect(view().name("profile"))
                // model().attribute(...): bir attribute'ın DEĞERİNİ doğrular.
                .andExpect(model().attribute("username", "cdurgun"))
                .andExpect(model().attribute("topicCount", 25))
                // model().attributeExists(...): sadece VARLIĞINI doğrular, değerini değil --
                // değeri test edip etmeyeceğinize önem vermediğiniz durumlarda kullanışlı.
                .andExpect(model().attributeExists("username", "topicCount"));

        System.out.println("Model ve view dogrulamalari basarili.");
    }
}

view().name(...), dönen mantıksal view adını doğrular -- fiziksel HTML dosyasının render edilip edilmediğini değil (standaloneSetup'ta bir ViewResolver/template motoru yok). model().attribute(...) bir attribute'ın değerini, model().attributeExists(...) ise yalnızca varlığını doğrular.

@RestController Test Etmek: JSON Gövdesini jsonPath ile Doğrulamak

@RestController'larda view/model yok -- yanıt doğrudan JSON, ve onu doğrulamanın aracı jsonPath(...):

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// @RestController'lar JSON döndürür; view()/model() burada anlamsızdır (view yok).
// jsonPath(...) yanıt gövdesinin İÇİNE, bir JSONPath ifadesiyle bakar -- tüm gövdeyi
// elle string karşılaştırmaya (content().json(...)) tercihen, tek tek alan doğrulamak
// için kullanışlıdır; özellikle gövdenin bir kısmını (örn. sunucu tarafından üretilen
// bir zaman damgasını) görmezden gelmek istediğinizde.
public class JsonPathAssertionExample {

    record BookResponse(String title, String author, int pageCount, boolean available) {
    }

    @RestController
    static class BookController {
        @GetMapping("/books/{id}")
        BookResponse book(@PathVariable String id) {
            return new BookResponse("Effective Java", "Joshua Bloch", 412, true);
        }
    }

    public static void main(String[] args) throws Exception {
        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new BookController()).build();

        mockMvc.perform(get("/books/1"))
                .andExpect(status().isOk())
                // "$.alan": kök nesnenin bir alanı.
                .andExpect(jsonPath("$.title").value("Effective Java"))
                .andExpect(jsonPath("$.author").value("Joshua Bloch"))
                .andExpect(jsonPath("$.pageCount").value(412))
                .andExpect(jsonPath("$.available").value(true))
                // jsonPath(...).exists() / doesNotExist(): alanın varlığını, değerine
                // hiç bakmadan doğrular.
                .andExpect(jsonPath("$.isbn").doesNotExist());

        System.out.println("JSON gövde alanlari jsonPath ile dogrulandi.");

        // Not: bir liste dönseydi (örn. List<BookResponse>), "$[0].title" gibi bir
        // dizi indeksleme ifadesi, "$.length()" ise eleman sayısı için kullanılabilir.
    }
}

jsonPath("$.alan"), yanıt gövdesinin İÇİNE bakar -- tüm gövdeyi elle string karşılaştırmaya (content().json(...)) tercihen, tek tek alan doğrulamak, özellikle gövdenin bir kısmını (örn. sunucu tarafından üretilen bir zaman damgasını) görmezden gelmek istediğinizde kullanışlıdır. jsonPath(...).exists()/doesNotExist() ise bir alanın değerine hiç bakmadan varlığını doğrular.

Request Body Göndermek: content() ve contentType()

POST/PUT/PATCH gövdesi göndermek için iki parça gerekir: gövdenin kendisi ve tipi:

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// POST/PUT/PATCH gövdesi göndermek için content(...) ile ham baytları/string'i, ve
// contentType(...) ile Content-Type header'ını vermeniz gerekir -- Content-Type
// verilmezse Spring, hangi HttpMessageConverter'ın kullanılacağını bilemez ve
// isteği reddedebilir (415 Unsupported Media Type).
public class RequestBodyTestExample {

    record CreateNoteRequest(String title, String body) {
    }

    record NoteResponse(long id, String title) {
    }

    @RestController
    static class NoteController {
        @PostMapping("/notes")
        NoteResponse create(@RequestBody CreateNoteRequest request) {
            return new NoteResponse(1L, request.title());
        }
    }

    public static void main(String[] args) throws Exception {
        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new NoteController()).build();
        ObjectMapper objectMapper = new ObjectMapper();

        String requestJson = objectMapper.writeValueAsString(
                new CreateNoteRequest("Toplantı Notu", "Spring MVC testing bölümünü bitir"));

        mockMvc.perform(post("/notes")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(requestJson))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.title").value("Toplantı Notu"));

        System.out.println("POST govdesi gonderildi ve yanit dogrulandi.");

        // content(requestJson) burada elle ObjectMapper ile serileştirildi -- gerçek
        // projelerde bu genelde küçük bir yardımcı metoda (örn. asJsonString(Object))
        // çıkarılır, çünkü hemen her yazma testinde tekrar eder.
    }
}

content(requestJson) ham baytları/string'i, contentType(...) ise Content-Type header'ını verir -- Content-Type verilmezse Spring hangi HttpMessageConverter'ın kullanılacağını bilemez ve isteği reddedebilir (415 Unsupported Media Type). ObjectMapper ile elle serileştirme, gerçek projelerde genelde küçük bir yardımcı metoda çıkarılır çünkü hemen her yazma testinde tekrar eder.

Path Variable ve Query Parametrelerini Test Etmek

Path variable'lar URL'in içinde, query parametreleri ise .param(...) ile eklenir:

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// Path variable'lar URL'in kendi içinde ({id} gibi) verilir; query parametreleri ise
// .param(ad, deger) ile eklenir -- ?page=0&size=10 formatındaki gerçek query string'i
// elle oluşturmaya gerek yoktur, MockMvc bunu sizin için kurar.
public class PathVariableQueryParamTestExample {

    record TopicSummary(String slug, int page, int size, String difficulty) {
    }

    @RestController
    static class TopicSearchController {
        @GetMapping("/api/categories/{categorySlug}/topics")
        TopicSummary search(@PathVariable String categorySlug,
                             @RequestParam(defaultValue = "0") int page,
                             @RequestParam(defaultValue = "20") int size,
                             @RequestParam(required = false) String difficulty) {
            return new TopicSummary(categorySlug, page, size, difficulty);
        }
    }

    public static void main(String[] args) throws Exception {
        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TopicSearchController()).build();

        mockMvc.perform(get("/api/categories/{categorySlug}/topics", "spring-mvc")
                        .param("page", "1")
                        .param("size", "5")
                        .param("difficulty", "ADVANCED"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.slug").value("spring-mvc"))
                .andExpect(jsonPath("$.page").value(1))
                .andExpect(jsonPath("$.size").value(5))
                .andExpect(jsonPath("$.difficulty").value("ADVANCED"));

        // difficulty verilmeden de çalıştığını doğrula -- @RequestParam(required = false)
        // olduğu için 400 değil, null ile controller'a girer.
        mockMvc.perform(get("/api/categories/{categorySlug}/topics", "spring-mvc"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.page").value(0))
                .andExpect(jsonPath("$.size").value(20))
                .andExpect(jsonPath("$.difficulty").doesNotExist());

        System.out.println("Path variable ve query parametre testleri basarili.");
    }
}

get("/api/categories/{categorySlug}/topics", "spring-mvc") şeklindeki placeholder doldurma, @PathVariable ile eşleşir; .param("page", "1") gibi çağrılar ise ?page=1&size=5 formatındaki gerçek query string'i sizin için kurar. @RequestParam(required = false) olan bir parametre verilmediğinde, isteğin 400 değil, null ile controller'a girdiğini de ayrıca doğruluyoruz.

Validation Hatalarını Test Etmek: 400 ve ProblemDetail

standaloneSetup(...), Bean Validation classpath'te olduğu için varsayılan bir validator kurar -- ama Validation & Exception Handling dersinin "Global Hata Yönetimi: @RestControllerAdvice" bölümünde gördüğümüz gibi, @ControllerAdvice sınıfları otomatik taranmaz:

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import java.util.stream.Collectors;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// standaloneSetup(...), Bean Validation classpath'te olduğu için VARSAYILAN OLARAK bir
// validator kurar -- yani @Valid ek bir .setValidator(...) çağrısı olmadan çalışır.
// Ancak @ControllerAdvice sınıfları OTOMATİK taranmaz: geçerli bir hata gövdesi (400 +
// ProblemDetail) almak için advice'ı .setControllerAdvice(...) ile elle eklemeniz gerekir
// -- bkz. "Validation ve Exception Handling" dersindeki "@RestControllerAdvice: Global
// Hata Yönetimi" bölümü.
public class ValidationErrorTestExample {

    record CreateTopicRequest(@NotBlank String slug, @Min(1) int estimatedMinutes) {
    }

    @RestController
    static class TopicCreationController {
        @PostMapping("/api/topics")
        String create(@Valid @RequestBody CreateTopicRequest request) {
            return "created: " + request.slug();
        }
    }

    @RestControllerAdvice
    static class ValidationAdvice {
        @ExceptionHandler(MethodArgumentNotValidException.class)
        ProblemDetail handleValidation(MethodArgumentNotValidException e) {
            ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
            problem.setProperty("errors", e.getBindingResult().getFieldErrors().stream()
                    .map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
                    .collect(Collectors.toList()));
            return problem;
        }
    }

    public static void main(String[] args) throws Exception {
        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TopicCreationController())
                .setControllerAdvice(new ValidationAdvice())
                .build();
        ObjectMapper objectMapper = new ObjectMapper();

        // slug boş VE estimatedMinutes 0 -- iki alan da ihlalde.
        String invalidJson = objectMapper.writeValueAsString(new CreateTopicRequest("", 0));

        mockMvc.perform(post("/api/topics")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(invalidJson))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.detail").value("Validation failed"))
                .andExpect(jsonPath("$.errors.length()").value(2));

        System.out.println("Gecersiz govde 400 + ProblemDetail ile reddedildi.");

        // Geçerli bir istekle karşılaştır: aynı controller, aynı advice, farklı sonuç.
        String validJson = objectMapper.writeValueAsString(new CreateTopicRequest("spring-mvc-testing", 45));
        mockMvc.perform(post("/api/topics")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(validJson))
                .andExpect(status().isOk());

        System.out.println("Gecerli govde ise 200 ile kabul edildi.");
    }
}

Geçerli bir hata gövdesi almak için advice'ı .setControllerAdvice(...) ile elle eklemek gerekiyor. Buradaki ValidationAdvice, MethodArgumentNotValidException'ı yakalayıp aynı dersin "ProblemDetail: RFC 7807 ile Standart Hata Gövdesi" bölümündeki desenle bir ProblemDetail üretiyor -- geçersiz ve geçerli iki farklı istekle, aynı controller + aynı advice'ın iki farklı sonucunu karşılaştırıyoruz.

Multipart Dosya Yüklemeyi Test Etmek: MockMultipartFile

Advanced Spring MVC dersinin "Multipart File Upload: @RequestParam ile MultipartFile Almak" bölümündeki MultipartUploadControllerExample, main-scope olduğu için MultipartFile'ı elle implemente etmişti -- burada test scope'ta olduğumuz için gerçek MockMultipartFile'ı doğrudan kullanabiliyoruz:

import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// MockMultipartFile, spring-test'in kendisidir (spring-boot-starter-test ile gelir,
// test scope) -- "Advanced Spring MVC" dersindeki MultipartUploadControllerExample'ın
// aksine (o örnek main-scope olduğu için MultipartFile'ı elle implemente etmişti),
// burada test scope olduğumuz için gerçek MockMultipartFile'ı doğrudan kullanabiliyoruz.
public class MultipartUploadTestExample {

    record UploadResult(String filename, long size, String contentType) {
    }

    @RestController
    static class UploadController {
        @PostMapping("/api/uploads")
        UploadResult upload(@RequestParam("file") MultipartFile file) {
            return new UploadResult(file.getOriginalFilename(), file.getSize(), file.getContentType());
        }
    }

    public static void main(String[] args) throws Exception {
        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new UploadController()).build();

        MockMultipartFile file = new MockMultipartFile(
                "file",                          // @RequestParam adıyla eşleşen part adı
                "notes.txt",                      // orijinal dosya adı
                "text/plain",                     // content type
                "spring-mvc-testing notlari".getBytes());

        // multipart(...): normal get()/post() yerine, multipart/form-data gövdesi
        // kuran özel bir request builder.
        mockMvc.perform(multipart("/api/uploads").file(file))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.filename").value("notes.txt"))
                .andExpect(jsonPath("$.contentType").value("text/plain"))
                .andExpect(jsonPath("$.size").value(file.getSize()));

        System.out.println("Multipart dosya yukleme testi basarili.");

        // Boyut sınırı ihlali gibi senaryolar için (bkz. Advanced Spring MVC dersindeki
        // "Multipart Boyut Sınırları" bölümü), MaxUploadSizeExceededException'ı yakalayan
        // bir @RestControllerAdvice, ValidationErrorTestExample'daki desenin aynısıyla
        // eklenebilir.
    }
}

MockMultipartFile, spring-boot-starter-test ile gelen (test scope) gerçek bir spring-test sınıfı. multipart(...), normal get()/post() yerine, multipart/form-data gövdesi kuran özel bir request builder'dır -- .file(file) ile eklenen dosya, controller'daki @RequestParam("file") MultipartFile parametresiyle eşleşir. Boyut sınırı ihlali gibi senaryolar (bkz. aynı dersin "Multipart Yapılandırması ve Boyut Sınırları" bölümü), ValidationErrorTestExample'daki desenin aynısıyla, ilgili exception'ı yakalayan bir advice eklenerek test edilebilir.

Best Practices

  • @MockBean yerine her zaman @MockitoBean kullan -- bu projenin kullandığı Spring Boot 4.1.0'da @MockBean kaldırıldı; @MockitoBean aynı işi görür ve Spring Framework'ün kendi test altyapısının bir parçası (bkz. "@MockitoBean ile Bağımlılıkları Sahtelemek").
  • @WebMvcTest'i, gerçekten test etmek istediğin controller'a daralt (@WebMvcTest(HomeController.class) gibi) -- boş bırakmak tüm controller'ları yükler ve testi yavaşlatır, ayrıca hangi bağımlılığın sahtelenmesi gerektiğini belirsizleştirir (bkz. "Bu Projenin Kendi HomeController'ını Test Etmek: Gerçek Bir Örnek").
  • JSON yanıtlarda tüm gövdeyi string karşılaştırmak yerine jsonPath(...) ile tek tek alan doğrula -- gövde şekli küçük bir şekilde değiştiğinde (yeni bir alan eklendiğinde gibi) testin kırılmaz olmasını sağlar (bkz. "@RestController Test Etmek: JSON Gövdesini jsonPath ile Doğrulamak").
  • standaloneSetup(...) kullanırken @ControllerAdvice'ı elle eklemeyi unutma -- aksi hâlde hata senaryoları, gerçek uygulamada göreceğiniz ProblemDetail yerine ham bir exception ile sonuçlanır (bkz. "Validation Hatalarını Test Etmek: 400 ve ProblemDetail").

Yaygın Hatalar

1. @WebMvcTest ile bir @Service bağımlılığını sahteleme (@MockitoBean) unutmak. Context, "no qualifying bean" hatasıyla başlatma anında patlar -- @WebMvcTest'in @Service/@Repository katmanını hiç yüklemediğini unutmak, bu dersteki en sık karşılaşılan hata (bkz. "@WebMvcTest ve MockMvc: Yalnızca Web Katmanını Yüklemek").

2. POST/PUT isteklerinde contentType(...) eklemeyi unutmak. Gövde content(...) ile verilse bile, Content-Type header'ı olmadan Spring hangi HttpMessageConverter'ın kullanılacağını bilemez ve istek 415 ile reddedilebilir (bkz. "Request Body Göndermek: content() ve contentType()").

3. jsonPath(...)'i, dizi mi nesne mi döndüğünü kontrol etmeden yazmak. Bir liste için $.title değil $[0].title gerekir -- yanlış ifade, alanın hiç bulunamamasıyla sonuçlanan kafa karıştırıcı bir hataya yol açar (bkz. "@RestController Test Etmek: JSON Gövdesini jsonPath ile Doğrulamak").

4. standaloneSetup(...) ile @Valid'in çalıştığını varsayıp, @ControllerAdvice'ı eklemeyi atlamak. Validator varsayılan olarak kurulur ve MethodArgumentNotValidException fırlatılır, ama bu exception'ı düzgün bir ProblemDetail'e çeviren advice elle eklenmediği sürece, test beklenmedik bir 500 ile karşılaşır (bkz. "Validation Hatalarını Test Etmek: 400 ve ProblemDetail").

5. @WebMvcTest'te gerçek bir veritabanına erişmeye çalışmak. Bu dilim, kasıtlı olarak @Repository bean'lerini yüklemez -- bir repository'ye ihtiyaç duyan controller, o repository @MockitoBean ile sahtelenmediği sürece çalışmaz (bkz. "Bu Projenin Kendi HomeController'ını Test Etmek: Gerçek Bir Örnek").

Özet, Cheat Sheet ve Terimler Sözlüğü

Spring MVC'de test yazmak, üç katman arasında bilinçli bir seçim yapmakla başlıyor -- saf birim testi, @WebMvcTest slice testi, ya da tam @SpringBootTest entegrasyon testi. Öne çıkan noktalar:

  • @WebMvcTest: yalnızca web katmanını (DispatcherServlet, controller'lar, converter'lar) yükleyen, @Service/@Repository'i hariç tutan bir slice test annotation'ı
  • MockMvc: gerçek bir HTTP sunucusu açmadan sahte istekler gönderen test aracı
  • MockMvcBuilders.standaloneSetup(...): Spring context olmadan, elle bir controller pipeline'ı kuran alternatif kurulum
  • @MockitoBean: context'e bir Mockito sahtesi ekleyen/gerçek bean'in yerine geçiren annotation (@MockBean'in yerini aldı)
  • perform()/andExpect(): sırasıyla isteği gönderen ve zincirlenebilir doğrulamalar yapan MockMvc metotları
  • status()/view()/model()/jsonPath()/content()/header(): farklı yanıt yönlerini (durum kodu, view adı, model attribute'ları, JSON alanları, gövde, header'lar) doğrulayan matcher aileleri
  • MockMultipartFile: multipart/form-data testleri için gerçek bir spring-test sınıfı (test scope)

Hızlı referans:

@WebMvcTest(TopicController.class)
class TopicControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private TopicRepository topicRepository;

    @Test
    void unknownSlugReturns404() throws Exception {
        when(topicRepository.findBySlugWithCategoryAndCourse("x"))
                .thenReturn(Optional.empty());

        mockMvc.perform(get("/topics/x"))
                .andExpect(status().isNotFound());
    }
}

Terimler Sözlüğü

@WebMvcTest — Yalnızca Spring MVC web katmanını yükleyen, @Service/ @Repository bean'lerini hariç tutan bir Spring Boot test slice annotation'ı.

MockMvc — Gerçek bir sunucu/soket açmadan sahte HTTP istekleri gönderip yanıtları doğrulamayı sağlayan test aracı.

standaloneSetup — Bir Spring ApplicationContext olmadan, verilen controller'ları elle bir MockMvc pipeline'ına bağlayan kurulum yöntemi.

@MockitoBean — Test context'ine bir Mockito sahtesi ekleyen ya da gerçek bir bean'in yerine geçiren annotation; @MockBean'in yerini aldı.

jsonPath — Bir JSON yanıt gövdesinin belirli bir alanını, bir JSONPath ifadesiyle doğrulayan matcher.

MockMultipartFile — Multipart dosya yükleme testleri için kullanılan, spring-test kütüphanesinin sağladığı sahte dosya sınıfı.

Ek: Mini Proje — Bu Projenin Kendi TopicController'ı İçin Kapsamlı Bir Test Paketi

Bu dersteki tüm teknikleri, bu projenin gerçek TopicController'ı (altı bağımlılığın tamamı @MockitoBean ile sahtelenmiş) üzerinde birleştiriyoruz:

import com.cdurgun.learning.domain.Category;
import com.cdurgun.learning.domain.Course;
import com.cdurgun.learning.domain.Difficulty;
import com.cdurgun.learning.domain.Language;
import com.cdurgun.learning.domain.Topic;
import com.cdurgun.learning.domain.TopicTranslation;

// Bu projenin gerçek entity'leri (Topic, Category, Course, TopicTranslation) Lombok
// @Builder kullanıyor -- ekstra bir test kütüphanesi gerekmeden, okunabilir "fixture"
// (test verisi) üretmek için doğrudan kullanılabilirler. Bu sınıf, TR/EN çeviri ve
// tüm ManyToOne ilişkileriyle birlikte KENDİ İÇİNDE TUTARLI bir Topic ağacı kurar --
// aşağıdaki TopicControllerWebMvcTest bu yardımcıları kullanır.
public class TopicTestFixtures {

    public static Course sampleCourse() {
        return Course.builder()
                .id(1L)
                .name("Java")
                .slug("java")
                .build();
    }

    public static Category sampleCategory(Course course) {
        return Category.builder()
                .id(1L)
                .course(course)
                .name("Spring MVC")
                .slug("spring-mvc")
                .sortOrder(1)
                .build();
    }

    public static Topic sampleTopic(Category category) {
        return Topic.builder()
                .id(9L)
                .category(category)
                .slug("spring-mvc-testing")
                .difficulty(Difficulty.ADVANCED)
                .estimatedMinutes(40)
                .sortOrder(9)
                .build();
    }

    public static TopicTranslation sampleTranslation(Topic topic, Language language, boolean published) {
        return TopicTranslation.builder()
                .id(language == Language.TR ? 91L : 92L)
                .topic(topic)
                .language(language)
                .title(language == Language.TR ? "Spring MVC'de Test Yazmak" : "Testing in Spring MVC")
                .summary(language == Language.TR ? "MockMvc ve @WebMvcTest ile web katmanı testleri." : "Web layer testing with MockMvc and @WebMvcTest.")
                .published(published)
                .build();
    }

    public static void main(String[] args) {
        Course course = sampleCourse();
        Category category = sampleCategory(course);
        Topic topic = sampleTopic(category);
        TopicTranslation translation = sampleTranslation(topic, Language.TR, true);

        // Zincirleme ilişkinin gerçekten kurulduğunu doğrula -- entity'lerin builder'la
        // üretilmesi, aralarındaki referansları ELLE bağlamayı ortadan kaldırmaz.
        System.out.println(translation.getTopic().getCategory().getCourse().getName());
        // Java
        System.out.println(translation.getTopic().getSlug() + " -> " + translation.getTitle());
        // spring-mvc-testing -> Spring MVC'de Test Yazmak
    }
}
import com.cdurgun.learning.controller.TopicController;
import com.cdurgun.learning.domain.Category;
import com.cdurgun.learning.domain.Course;
import com.cdurgun.learning.domain.Language;
import com.cdurgun.learning.domain.Topic;
import com.cdurgun.learning.domain.TopicTranslation;
import com.cdurgun.learning.repository.TopicRepository;
import com.cdurgun.learning.repository.TopicTranslationRepository;
import com.cdurgun.learning.service.ContentResolver;
import com.cdurgun.learning.service.MarkdownService;
import com.cdurgun.learning.service.NavigationService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.MessageSource;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import java.util.List;
import java.util.Optional;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;

// Bu projenin GERÇEK TopicController'ı için @WebMvcTest -- 6 bağımlılığın TAMAMI
// @MockitoBean ile sahtelenir (bkz. TopicController'ın constructor'ı). TopicTestFixtures
// bu testin fixture'larını (Course/Category/Topic/TopicTranslation) üretmek için kullanılıyor.
@WebMvcTest(TopicController.class)
class TopicControllerWebMvcTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private TopicRepository topicRepository;
    @MockitoBean
    private TopicTranslationRepository topicTranslationRepository;
    @MockitoBean
    private ContentResolver contentResolver;
    @MockitoBean
    private MarkdownService markdownService;
    @MockitoBean
    private NavigationService navigationService;
    @MockitoBean
    private MessageSource messageSource;

    @Test
    void unknownSlugReturns404() throws Exception {
        when(topicRepository.findBySlugWithCategoryAndCourse("does-not-exist"))
                .thenReturn(Optional.empty());

        // Controller kodunda bkz: ResponseStatusException(HttpStatus.NOT_FOUND, ...) --
        // gerçek bir @ControllerAdvice olmadan bile, Spring'in varsayılan exception
        // çözümü ResponseStatusException'ı doğru HTTP durumuna çevirir.
        mockMvc.perform(get("/topics/does-not-exist"))
                .andExpect(status().isNotFound());
    }

    @Test
    void invalidLangParamReturns400() throws Exception {
        Course course = TopicTestFixtures.sampleCourse();
        Category category = TopicTestFixtures.sampleCategory(course);
        Topic topic = TopicTestFixtures.sampleTopic(category);

        when(topicRepository.findBySlugWithCategoryAndCourse("spring-mvc-testing"))
                .thenReturn(Optional.of(topic));

        // Anasayfa'nın aksine, TopicController açıkça verilmiş ama bilinmeyen bir `lang`
        // için kasıtlı olarak 400 döndürür (bkz. controller'daki Language.fromCode
        // catch bloğu) -- bu davranış farkı, "Path Variable ve Query Parametrelerini
        // Test Etmek" bölümündeki required=false senaryosundan kasıtlı olarak farklıdır.
        mockMvc.perform(get("/topics/spring-mvc-testing").param("lang", "fr"))
                .andExpect(status().isBadRequest());
    }

    @Test
    void publishedTopicRendersWithContent() throws Exception {
        Course course = TopicTestFixtures.sampleCourse();
        Category category = TopicTestFixtures.sampleCategory(course);
        Topic topic = TopicTestFixtures.sampleTopic(category);
        TopicTranslation trTranslation = TopicTestFixtures.sampleTranslation(topic, Language.TR, true);

        when(topicRepository.findBySlugWithCategoryAndCourse("spring-mvc-testing"))
                .thenReturn(Optional.of(topic));
        when(topicTranslationRepository.findByTopicIdAndLanguage(topic.getId(), Language.TR))
                .thenReturn(Optional.of(trTranslation));
        when(topicTranslationRepository.findByTopicIdAndLanguage(topic.getId(), Language.EN))
                .thenReturn(Optional.empty());
        when(contentResolver.resolve(eq("spring-mvc-testing"), eq(Language.TR)))
                .thenReturn(Optional.of("# Spring MVC'de Test Yazmak\n\nIcerik burada."));
        when(markdownService.render(any(), eq("spring-mvc-testing")))
                .thenReturn(new MarkdownService.MarkdownRenderResult("<h1>Spring MVC'de Test Yazmak</h1>", List.of()));
        when(navigationService.buildNavigation(Language.TR)).thenReturn(List.of());
        when(navigationService.buildCourseSequence(course.getId(), Language.TR)).thenReturn(List.of());

        mockMvc.perform(get("/topics/spring-mvc-testing"))
                .andExpect(status().isOk())
                .andExpect(view().name("topic"))
                .andExpect(model().attribute("contentAvailable", true))
                .andExpect(model().attribute("otherLanguageAvailable", false));

        // Not: burada mock'lanan her değer, TopicController'ın PRODUCTION'da gerçek
        // servislerden aldığı değerlerle aynı tiptedir (gerçek Topic, gerçek
        // MarkdownRenderResult record'u) -- bu yüzden templates/topic.html, gerçek bir
        // isteği işliyormuş gibi normal şekilde render edilir.
    }
}

TopicTestFixtures, gerçek Course/Category/Topic/TopicTranslation entity'lerini (hepsi Lombok @Builder kullanıyor) tutarlı bir ağaç olarak kuran yardımcı metotlar sağlıyor. TopicControllerWebMvcTest üç senaryoyu kapsıyor: bilinmeyen bir slug için 404, geçersiz bir lang parametresi için 400, ve tam yayınlanmış bir konu için gerçek topic.html template'i üzerinden 200 -- son senaryoda mock'lanan her değer, controller'ın production'da gerçek servislerden aldığı değerlerle aynı tipte (gerçek Topic, gerçek MarkdownService.MarkdownRenderResult), bu yüzden template gerçek bir isteği işliyormuş gibi normal şekilde render ediliyor.

Ek: Mini Proje — Bir Interceptor'ı MockMvc ile Test Etmek

Son mini proje, Advanced Spring MVC dersinin "HandlerInterceptor Arayüzü: preHandle, postHandle, afterCompletion" bölümündeki yaşam döngüsünü, WebMvcConfigurer: Interceptor'ı Kaydetmek bölümündeki gibi bir konfigürasyon sınıfı hiç yazmadan, doğrudan MockMvc ile izole test ediyor:

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.servlet.HandlerInterceptor;

// Küçük, gerçekçi bir HandlerInterceptor -- "Advanced Spring MVC" dersindeki
// HandlerInterceptorLifecycleExample ile aynı yaşam döngüsünü (preHandle/postHandle/
// afterCompletion) kullanır, ama burada amaç interceptor'ı kendi başına test etmek
// olduğu için mümkün olduğunca sade tutuldu: her istekte X-Response-Time-Ms header'ı
// ekler.
public class TimingInterceptorForTest implements HandlerInterceptor {

    private static final String START_ATTRIBUTE = "requestStartNanos";

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        request.setAttribute(START_ATTRIBUTE, System.nanoTime());
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
                                 Object handler, Exception ex) {
        Object startValue = request.getAttribute(START_ATTRIBUTE);
        if (startValue instanceof Long startNanos) {
            long elapsedMs = (System.nanoTime() - startNanos) / 1_000_000;
            response.setHeader("X-Response-Time-Ms", String.valueOf(elapsedMs));
        }
        // Not: header'ı postHandle yerine afterCompletion'da eklemek kasıtlı -- afterCompletion
        // handler bir exception fırlatsa BİLE her zaman çalışır, postHandle ise çalışmaz
        // (bkz. "Advanced Spring MVC" dersindeki "HandlerInterceptor Yaşam Döngüsü" bölümü).
    }
}
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// standaloneSetup(...).addInterceptors(...): bir interceptor'ı, onu kayıt eden
// WebMvcConfigurer'ı (InterceptorRegistrationExample'daki gibi) hiç yazmadan, doğrudan
// MockMvc'ye takar -- interceptor'ı İZOLE olarak test etmek için idealdir, çünkü tüm
// uygulamanın konfigürasyonunu (path pattern'ler, diğer interceptor'lar) devreye
// sokmaz.
public class TimingInterceptorMockMvcTest {

    @RestController
    static class PingController {
        @GetMapping("/ping")
        String ping() {
            return "pong";
        }
    }

    public static void main(String[] args) throws Exception {
        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new PingController())
                .addInterceptors(new TimingInterceptorForTest())
                .build();

        mockMvc.perform(get("/ping"))
                .andExpect(status().isOk())
                // header().exists(...): yanıtta bu header'ın var olduğunu doğrular --
                // değeri her koşuda değişeceği (gerçek geçen süre) için exists() burada
                // string(...) ile tam eşleşme aramaktan daha doğru bir seçimdir.
                .andExpect(header().exists("X-Response-Time-Ms"));

        System.out.println("Interceptor, standaloneSetup ile izole test edildi.");

        // addInterceptors(...) burada TÜM path'lere uygulanır -- gerçek uygulamada
        // olduğu gibi belirli bir path pattern'ine sınırlamak isterseniz
        // MockMvcBuilders'ın standalone API'si bunu doğrudan desteklemez; bu durumda
        // interceptor'ın kendi içindeki path kontrolünü (varsa) test etmeniz gerekir.
    }
}

TimingInterceptorForTest, her isteğe X-Response-Time-Ms header'ı ekleyen küçük, gerçekçi bir HandlerInterceptor. TimingInterceptorMockMvcTest, standaloneSetup(...).addInterceptors(...) ile bu interceptor'ı doğrudan MockMvc'ye takıyor -- tüm uygulamanın konfigürasyonunu (path pattern'ler, diğer interceptor'lar) hiç devreye sokmadan, interceptor'ı KENDİ BAŞINA doğruluyor.