در تست نویسی یکپارچه کامپوننت ها و قسمت های مختلف برنامه رو یکجا در یک گروه تست میکنیم.
همونطور که از این تصویر مشخص هست این نوع تست بعد از تست واحد قرار میگیرد. طبیعتا چون با کامپوننت های زیادتری نسبت به تست نویسی واحد سر و کار داریم، هزینه اجرا و سرعت اجرا هم تغییر میکنند.
در اسپرینگ این هزینه و سرعت در لود شدن و بالا آمدن اپلیکیشن در هر کلاس تست دیده میشود چون بهرحال کانتکس اسپرینگ برای ما کامپوننت ها و bean های مختلف رو فراهم میکنه برخلاف mock کردن که در قسمت قبل دیدیم. البته از ماک میشه تو تست های یکپارچه هم استفاده کرد مثلا اگه از spring security استفاده میکنیم میتونیم آبجکت های این دپندسی مثل Authentication رو ماک کنیم
هدف ما در این قسمت اینه که:
۱- لایه repository رو تست کنیم
۲- لایه service و repository رو تست کنیم
۳- لایه service و repository و controller رو تست کنیم
برای شروع به این سایت میریم و پروژه رو با این شرایط و وابستگی ها ایجاد میکنیم:
پروژه رو دانلود میکنیم و این کلاس هارو ایجاد میکنیم:
Entity:
import lombok.*; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.Id; import java.math.BigDecimal; @Entity @Getter @Setter @ToString @NoArgsConstructor @AllArgsConstructor @Builder public class Product { @Id @GeneratedValue private Long id; private String name; private String description; private BigDecimal price; public Product(String name, String description, BigDecimal price) { this.name = name; this.description = description; this.price = price; } }
Repository:
import ir.darkdeveloper.integration.model.Product; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.util.List; @Repository public interface ProductRepo extends JpaRepository<Product, Long> { @Query("select p from Product p " + "where upper(p.name) like upper(concat('%', :str, '%')) " + "or upper(p.description) like upper(concat('%', :str, '%')) ") List<Product> findAllByNameAndDescriptionContainsIgnoreCase(String str); }
Service:
import ir.darkdeveloper.integration.model.Product; import ir.darkdeveloper.integration.repo.ProductRepo; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import java.util.List; @Service @RequiredArgsConstructor public class ProductService { private final ProductRepo repo; public Product save(Product product) { return repo.save(product); } public List<Product> saveAll(List<Product> products) { return repo.saveAll(products); } public List<Product> getAll() { return repo.findAll(); } public Product getById(Long Id) { return repo.findById(Id).orElse(null); } public List<Product> getByNameAndDescriptionContainsIgnoreCase(String str) { return repo.findByNameAndDescriptionContainsIgnoreCase(str); } public boolean deleteById(Long Id) { try { repo.deleteById(Id); return true; } catch (Exception e) { return false; } } public void deleteAll() { repo.deleteAll(); } }
Controller:
import ir.darkdeveloper.integration.model.Product; import ir.darkdeveloper.integration.service.ProductService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/products") public class ProductController { private final ProductService service; @PostMapping("/save/") public ResponseEntity<Product> saveProduct(@RequestBody Product product) { return ResponseEntity.ok(service.save(product)); } @GetMapping("/{id}/") public ResponseEntity<Product> getProduct(@PathVariable Long id) { return ResponseEntity.ok(service.getById(id)); } @GetMapping("/") public ResponseEntity<List<Product>> getAllProducts() { return ResponseEntity.ok(service.getAll()); } @GetMapping("/search/") public ResponseEntity<List<Product>> searchProducts(@RequestParam("str") String str) { return ResponseEntity.ok(service.getByNameAndDescriptionContainsIgnoreCase(str)); } }
و تنظیمات دیتابیس در فایل application.yml (اگر وجود ندارد در پوشه resources ایجاد کنید و فایل قبلی رو حذف کنید)
spring: jpa: show-sql: true hibernate: ddl-auto: create datasource: driver-class-name: org.h2.Driver url: jdbc:h2:mem:db username: sa password: sa
در پروژه های واقعی حتما از دیتابیس هایی مثل postgresql یا oracle یا mysql استفاده کنید و برای تست کردن از دیتابیس h2 استفاده کنید. برای اینکه کانفیگ دیتابیس تست یا کانفیگ دیتابیس اصلی جدا کنید، یک همچین فایلی رو در مسیر test/resources ایجاد کنید. اسپرینگ از این کانفیگ برای تست ها استفاده خواهد کرد
همونطور که قبلا گفتیم این لایه خیلی از متد های پیشفرضش تست شده هستند و مشکلی ندارند. چیزی که ما تست میکنیم کوئری های کاستومی هستند که بسته به نیازمون در پروژه مینویسیم و لازمه که عملکرد این ها رو تست کنیم. در این مثال بخصوص ما متد findByNameAndDescriptionContainsIgnoreCase توی ریپازیتوری با یک کوئری کاستوم داریم که اومده در نام و توضیحات این product ها جستجو میکنه و لیست محصولاتی که نام و توضیحاتشون شامل این متن هست رو برمیگردونه.
توضیح کوئری هم به این صورت هست که محصولاتی رو انتخاب میکنه که این متن که بهش میدیم یا باید شامل نام یا توضیحات محصول بدون توجه به بزرگی و کوچکی حروف باشد .
چون ما اومدیم یک کوئری کاستوم ساختیم باید تست این رو بنویسیم. برای این اینترفیس یک کلاس تست ایجاد میکنیم و تست ها رو با حالت های مختلف ممکن مینویسیم:
import ir.darkdeveloper.integration.model.Product; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import java.math.BigDecimal; import java.util.List; import static org.junit.jupiter.api.Assertions.*; @DataJpaTest class ProductRepoTest { private final ProductRepo productRepo; @Autowired ProductRepoTest(ProductRepo productRepo) { this.productRepo = productRepo; } @BeforeEach void saveProducts() { var products = List.of( new Product("some_name_Abc", "DesCriPtiOn", new BigDecimal(1)), new Product("NaMeNumber2", "Some_description25", new BigDecimal(1)), new Product("A Name For Product", "Contains Description About Product", new BigDecimal("136.54")), new Product("نام محصول", "توضیحات محصول", new BigDecimal("136.54")) ); productRepo.saveAll(products); } @AfterEach void deleteAll() { productRepo.deleteAll(); } @Test void findByNameAndDescriptionContains1() { var products = productRepo.findByNameAndDescriptionContainsIgnoreCase("name"); assertEquals(3, products.size()); } @Test void findByNameAndDescriptionContains2() { var products = productRepo.findByNameAndDescriptionContainsIgnoreCase("dEscRiptiOn"); assertEquals(3, products.size()); } @Test void findByNameAndDescriptionContains3() { var products = productRepo.findByNameAndDescriptionContainsIgnoreCase("نام"); assertEquals(1, products.size()); } }
ما کلاس رو با انوتیشن DataJpaTest نوشتیم این انوتیشن اپلیکیشن رو بالا میاره و کانتینر ioc رو آماده میکنه لذا ما میتونیم ریپازیتوری رو در کلاس تست تزریق کنیم. بعد ما هر بار قبل از اجرای تست ها، اطلاعات چند تا محصول رو در دیتابیس ذخیره میکنیم و بعد از تموم شدن هر تست این دیتا هارو حذف میکنیم تا برای تست بعدی به اصطلاح یک fresh state داشته باشیم. البته نوشتن این afterEach الزامی نیست چون توی انوتیشن DataJpaTest از انوتیشن Transactional استفاده شده و تمام دیتا هایی که ساخته میشه رو رول بک میشه و برای تست بعدی ذخیره نمیشه.
در سه تست بعدی ما اومدیم حالت های مختلف جستجو رو نوشتیم که ببینیم کوئری ما درست کار میکنه یا نه. مثلا حروف رو بزرگ و کوچک نوشتیم که ببینیم کوئری این بزرگی و کوچکی رو نادیده میگیره یا نه یا مثلا عباراتی رو نوشتیم که ببینیم اون عبارت or در کوئری درست کار میکنه یا نه
برای این لایه یک راه اینه که ریپازیتوری رو ماک کنیم که توصیه میشه. یک راه دیگه اینم که بصورت integration تست کنیم و لایه ریپازیتوری رو هم درگیر کنیم تا بتونیم عملکرد هایی مثل save و update و delete رو هم تست کنیم. برای اینکار لازمه که کلاس تستی بسازیم که بالایش انوتیشن SpringBootTest باشد. این انوتیشن میاد و کانتکس و کانتینر ioc اسپرینگ رو بالا میاره و عملا یکبار اپلیکیشن رو به ازای هر کلاس تست ران میکنه. بر خلاف DataJpaTest این انوتیشن دیتا های سیو شده رو رول بک نمیکنه که میتونیم دو کار انجام بدیم:
۱ - قبل و بعد هر تست دیتا رو بصورت تازه و دست نخورده آماده کنیم
۲ - تست هارو بترتیب از سیو شدن تا دیلیت شدن بنویسیم که سرعت اجرای بیشتری داره نسبت به اولی
بسته به نیاز و صلاح دیدتون میتونین یک روش رو انتخاب کنین که هر دو روش رو بترتیب اینجا پیاده کرده ایم:
import ir.darkdeveloper.integration.model.Product; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; import java.util.stream.IntStream; import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest public class ProductServiceNoOrderTest { private final ProductService service; private List<Long> productIds; @Autowired public ProductServiceNoOrderTest(ProductService service) { this.service = service; } @BeforeEach public void setUp() { service.deleteAll(); var products = List.of( new Product("some_name_Abc", "DesCriPtiOn", new BigDecimal(1)), new Product("NaMeNumber2", "Some_description25", new BigDecimal(1)), new Product("A Name For Product", "Contains Description About Product", new BigDecimal("136.54")), new Product("نام محصول", "توضیحات محصول", new BigDecimal("136.54")) ); service.saveAll(products); productIds = new ArrayList<>(); products.forEach(product -> productIds.add(product.getId())); } @Test void getAll() { var fetchedProducts = service.getAll(); assertThat(fetchedProducts).isNotEmpty(); assertThat(fetchedProducts.size()).isEqualTo(4); IntStream.range(0, fetchedProducts.size()).forEach(i -> assertThat(fetchedProducts.get(i).getId()).isEqualTo(productIds.get(i)) ); } @Test void getById() { var fetchedProduct = service.getById(productIds.get(0)); assertThat(fetchedProduct).isNotNull(); assertThat(fetchedProduct.getId()).isEqualTo(productIds.get(0)); } @Test void getByNameAndDescriptionContainsIgnoreCase() { var fetchProducts = service.getByNameAndDescriptionContainsIgnoreCase("abc"); assertThat(fetchProducts).isNotEmpty(); assertThat(fetchProducts.size()).isEqualTo(1); } @Test() void deleteById() { var deletedProduct = service.deleteById(productIds.get(0)); assertThat(deletedProduct).isTrue(); } }
همونطور که مشخص هست هر بار قبل از هر تست یکبار تیبل محصول رو پاک میکنیم و دیتاهارو دوباره وارد
میکنیم و دوباره آیدی هاشون رو ثبت میکنیم. در تست ها نیز متد های سرویس رو تست میکنیم.
اسپرینگ خودش یک انوتیشنی با عنوان DirtiesContextداره که برای تمیز کردن دیتاهای دیتابیس کاربرد داره که هم برای کلاس میشه اعمال کرد هم برای متد. برای کلاس به اینصورت کار میکنه که اگه همه تست کلاس هارو یکجا ران کنید، دیتا هایی که در یک تست کلاس ایجاد شده در تست کلاس بعدی وجود نخواهد داشت. و برای متد به این صورته که دیتاهایی که در در هر تست متد ایجاد شده اند برای تست متد های بعدی وجود نخواهند داشت. دو تا اول مربوط به کلاس هست و دو تای بعدی مربوط به متد و دوتا آخر روی کلاس نوشته میشوند و قبل و بعد هر متد اعمال میشن :
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_CLASS) @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) // default @DirtiesContext(methodMode = DirtiesContext.MethodMode..BEFORE_METHOD) @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) // default @DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD) @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
گاهی اوقات ممکنه تست کلاس ها به قدری سریع اجرا بشن که حالت دیفالت عمل نکنه. پس بهتره همه کلاس هایی که لازمه رو به حالت before_class تنظیم کنید.
روش دوم به این صورت هست :
import ir.darkdeveloper.integration.model.Product; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; import java.util.stream.IntStream; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; @SpringBootTest @TestMethodOrder(MethodOrderer.OrderAnnotation.class) class ProductServiceInorderTest { private final ProductService service; private static final List<Long> productIds = new ArrayList<>(); @Autowired public ProductServiceInorderTest(ProductService service) { this.service = service; } @Test @Order(1) void saveAll() { var products = List.of( new Product("some_name_Abc", "DesCriPtiOn", new BigDecimal(1)), new Product("NaMeNumber2", "Some_description25", new BigDecimal(1)), new Product("A Name For Product", "Contains Description About Product", new BigDecimal("136.54")), new Product("نام محصول", "توضیحات محصول", new BigDecimal("136.54")) ); var savedProducts = service.saveAll(products); assertNotNull(savedProducts); assertThat(savedProducts).isNotEmpty(); products.forEach(product -> productIds.add(product.getId())); } @Test @Order(2) void getAll() { var fetchedProducts = service.getAll(); assertThat(fetchedProducts).isNotEmpty(); assertThat(fetchedProducts.size()).isEqualTo(4); IntStream.range(0, fetchedProducts.size()).forEach(i -> assertThat(fetchedProducts.get(i).getId()).isEqualTo(productIds.get(i)) ); } @Test @Order(3) void getById() { var fetchedProduct = service.getById(productIds.get(0)); assertThat(fetchedProduct).isNotNull(); assertThat(fetchedProduct.getId()).isEqualTo(productIds.get(0)); } @Test @Order(4) void getByNameAndDescriptionContainsIgnoreCase() { var fetchProducts = service.getByNameAndDescriptionContainsIgnoreCase("abc"); assertThat(fetchProducts).isNotEmpty(); assertThat(fetchProducts.size()).isEqualTo(1); } @RepeatedTest(4) @Order(5) void deleteById() { var deletedProduct = service.deleteById(productIds.get(0)); productIds.remove(0); assertThat(deletedProduct).isTrue(); } }
که کلاس رو با انوتیشن TestMethodOrder(MethodOrderer.OrderAnnotation.class) مینویسم و هر متد تست را به انوتیشن Order و شماره ای که ترتیب آنها رو نشون میده مینویسیم (به ایمپورت های این انوتیشن ها دقت کنید)
یک انوتیشن دیگری به نام RepeatedTest داریم که یک عددی میگیره و این تست رو به این تعداد اجرا میکنه
توی این مدل تست میخواهیم با یک تیر دو نشون بزنیم. به این صورت که تست های کنترلر رو مینویسیم و با هربار اجرای موفق، داکیومنت هایی برای rest api هامون بصورت خودکار جنریت میشه. تست لایه کنترلر بصورت یکپارچه هست. هدفی که داریم اینه که مثل دنیای واقعی یک http ریکوئست به کنترلمون بزنیم و دیتا رد و بدل کنیم. قبلا شاید از ابزاری مثل postman استفاده میکردید که برای خود من طاقت فرسا بود مثلا مواقعی بود که باید یکبار لاگین میکردن بعد توکن رو به ریکوئست بعدی میدادم و یک چیزی رو سیو میکردم بعد ... همه این مراحل رو دستی انجام میدادم که یک فانکشنالیتی ساده رو تست کنم. اما دیگه رفتم سراغ تست و الان به ندرت از پست من استفاده میکنم :
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import ir.darkdeveloper.integration.model.Product; import org.json.JSONObject; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.restdocs.RestDocumentationContextProvider; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.WebApplicationContext; import java.math.BigDecimal; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @SpringBootTest @TestMethodOrder(MethodOrderer.OrderAnnotation.class) @AutoConfigureRestDocs("docs/product") class ProductControllerTest { private final WebApplicationContext webApplicationContext; private final RestDocumentationContextProvider restDocumentation; private MockMvc mockMvc; private static Long productId; @Autowired ProductControllerTest(WebApplicationContext webApplicationContext, RestDocumentationContextProvider restDocumentation) { this.webApplicationContext = webApplicationContext; this.restDocumentation = restDocumentation; } @BeforeEach void setupMockMvc() { mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext) .apply(documentationConfiguration(restDocumentation)) .alwaysDo(document("{methodName}")) .build(); } @Test @Order(1) void saveProduct() throws Exception { var product = Product.builder() .name("productName") .description("productDescription") .price(BigDecimal.valueOf(102.5)) .build(); mockMvc.perform(post("/api/v1/products/save/") .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON) .content(mapToJson(product)) ) .andDo(print()) .andExpect(status().isOk()) // isMap means is it an object? .andExpect(jsonPath("$").isMap()) .andExpect(jsonPath("$.name").value("productName")) .andExpect(jsonPath("$.description").value("productDescription")) .andExpect(jsonPath("$.price").value(BigDecimal.valueOf(102.5))) .andDo(result -> { var object = new JSONObject(result.getResponse().getContentAsString()); // your assertions here productId = object.getLong("id"); }); } @Test @Order(2) void getProduct() throws Exception { mockMvc.perform(get("/api/v1/products/{id}/", productId) .accept(MediaType.APPLICATION_JSON) ) .andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$").isMap()) .andExpect(jsonPath("$.id").value(productId)) .andExpect(jsonPath("$.name").value("productName")) .andExpect(jsonPath("$.description").value("productDescription")) .andExpect(jsonPath("$.price").value(BigDecimal.valueOf(102.5))); } @Test @Order(3) void getAllProducts() throws Exception { mockMvc.perform(get("/api/v1/products/") .accept(MediaType.APPLICATION_JSON) ) .andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$").isArray()) .andExpect(jsonPath("$.[0].id").value(productId)) .andExpect(jsonPath("$.[0].name").value("productName")) .andExpect(jsonPath("$.[0].description").value("productDescription")) .andExpect(jsonPath("$.[0].price").value(BigDecimal.valueOf(102.5))); } @Test @Order(4) void searchProducts() throws Exception { mockMvc.perform(get("/api/v1/products/search/") .accept(MediaType.APPLICATION_JSON) .param("str", "descript") ) .andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$").isArray()) .andExpect(jsonPath("$.[0].id").value(productId)) .andExpect(jsonPath("$.[0].name").value("productName")) .andExpect(jsonPath("$.[0].description").value("productDescription")) .andExpect(jsonPath("$.[0].price").value(BigDecimal.valueOf(102.5))); } private String mapToJson(Object obj) throws JsonProcessingException { return new ObjectMapper().writeValueAsString(obj); } }
در ظاهر میدونم یزرع بزرگ به نظر میاد اما مطمن باشید خیلی هم سخت نیس و خود من از این تست ها بیشتر خوشم میاد.
خب در مرحله اول یک تست یکپارچه داریم که با springBootTest مشخصش کردیم. بعد تست هارو بصورت ترتیبی اجرا میکنیم.
و انوتیشینی که دپندسی restdocs رو برای ما آماده میکنه و پوشه ای که قراره داکیومنت هامون در اونجا قرار بگیره رو مشخص کردیم(این پوشه از روت پروژه ایجاد میشه)
دو تا bean لازم داریم برای کانفیگ کردن MockMvc یکیش برای آماده سازی کانتکس وب هست یکیش هم برای rest docs .
یک آبجکت MockMvc داریم که در متد setupMockMvc هر بار قبل از هر تست مقدار دهی میکنیم.
و یک مقدار لانگ که قراره آیدی این محصول رو برای ما نگه داره.
در تست اول یک آبجکت پروداکت ساختیم و مقدار دهیش کردیم. در خط بعدی با mockMvc اومدیم و یک ریکوئست post ایجاد کردیم و uri ای که قراره استفاده بشه رو وارد کردیم. در دو خط بعدی محتوای ارسالی و محتوای دریافتی رو از نوع JSON قرار داده ایم. در متد content اومدیم یک متد دیگه ای که در همین کلاس تعریف شده ایجاد کردیم و کارش اینه که آبجکتی رو بگیره و به استرینگ json تبدیل کنه.
در این حال ریکوئست ما تکمیل شد. اما چطور محتوای دریافتی یا ریسپانس رو تست کنیم؟
در ادامه همین متد perform متد هایی مثل andDo و andExpect داریم که اولی همونطور که از اسمش مشخص است برای انجام دادن کاری روی محتوای ریسپانس هست که اومدیم و گفتیم این ریسپانس رو چاپ کنه. خروجی در کنسول همچین چیزی هست:
متد بعدی andExpect هست که ما انتظاراتمون رو از ریسپانس دریافتی در این متد مشخص میکنیم مثلا در اولین مورد انتظار داریم که وضعیت ریسپانس ۲۰۰ یا اوکی باشد.
در مورد های بعدی از متد jsonPath استفاده کردیم و میخواهیم بصورت مستقیم و جاوااسکریپ طور ، مقادیر json ای که در ریسپانس برگردانده شده هست رو بررسی و تحلیل کنیم. این متد یک رشته میگیرد که شروعش با علامت دلار هست و با نقطه به داخل json میرود. در ادامه با یک andDo دیگر اومدیم ریسپانس رو گرفتیم و آیدی رو از اون استخراج کردیم و در متغیری که بصورت گلوبال تعریف کرده بودیم ریخته ایم.
بقیه تست ها هم شبیه این موردی که توضیح دادم هستند فقط یک مورد دیگر که لازمه توضیح بدم در آخرین تست یعنی searchProducts هست که اینجا چون مقدار بازگشتی ما یک json ای هست که با لیست شروع شده است، برای دسترسی به اعضای این لیست از علامت دسترسی به آرایه استفاده کردیم. هرجا که ریسپانس به یک لیست برسد باید از این علامت استفاده کنیم.
لینک پروژه: LINK
در پایان مثل همیشه نمیشه خیلی از کارایی که با mock mvc میشه کرد رو در اینجا تشریح کرد و بطور کامل توضیح داد. بهترین کار خوندن داکیومنت ها و best practice های mock mvc هست. هرجا سوال، پیشنهاد، اصلاحی داشتید ممنون میشم در کامنتا من و بقیه رو مطلع کنید ?