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
@MockBeanyerine her zaman@MockitoBeankullan -- bu projenin kullandığı Spring Boot 4.1.0'da@MockBeankaldırıldı;@MockitoBeanaynı 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ğinizProblemDetailyerine 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 aileleriMockMultipartFile: 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.