
یه مجموعه آموزش داریم تحت عنوان using JUnit5 and Mockito که اومدیم توی چنتا سرفصل تقسیمش کردیم .
این آموزش به دو بخش تقسیم شده مفاهیم کلی تست و مثال ها و انوتیشن ها mockito و junit5.
امیدوارم مبحث مفاهیم کلی تست رو خونده باشید ، توی این آموزش میایم انوتیشن ها و متدهایی که قرار توی مثال های این آموزش استفاده کنیم رو توضیح میدیم و بعد هم مثال هامون رو میزنیم .
توی این آموزش به موارد زیر میپردازیم :
1- متدها و انوتیشن ها.
2- تفاوت و موارد استفاده هر انوتیشن و متدهایی که گفتیم.
3- مثال هامون و توضیحاتش:
- یه نمونه تست ساده
- یه نمونه RestApi (میایم از mock برای دیتاهامون و کلاس هامون استفاده میکنیم)
- یه نمونه mock هزارتایی از دیتابیس
قبل از اینکه بریم مثال هامون رو بزنیم اول انوتیشن ها و متدهایی که قراره توی این آموزش استفاده کنیم رو بیاریم و بگیم چی هستن .
چون خیلی زیاد و طولانی میشه نمیشه همه متدها و انوتیشن هارو بررسی کرد بنابراین میایم چیزایی که معمولا استفاده میشه توی تست ها یا حداقل چیزایی که خودم روزمرره استفاده میکنم رو میارم .
@WebMvcTest()
@Test
@DisplayName
@BeforEach
@AfterEach
@BeforAll
@AfterAll
assertionMethods(assertThat,assertEqual,...)
@Mock
@InjectMocks
@Captor
@Spy
given()
verify()
when()
doReturn()
@WebMvcTest()
یک annotation در فریمورک Spring Boot است که برای تست کنترلرهای MVC (Model-View-Controller) استفاده میشود. این annotation به طور خاص برای تست لایهی وب در برنامههای Spring طراحی شده است و امکان تست کنترلرها را به صورت ایزوله فراهم میکند.
فقط اجزای مربوط به لایه وب (مانند کنترلرها، کنترلرهای exception، فیلترهای MVC، و کانفیگهای مرتبط با وب) را بارگذاری کند. این کار باعث میشود که تستهای شما سبکتر و سریعتر اجرا شوند، زیرا نیازی به بارگذاری کل برنامه Spring ندارید.
وقتی از @WebMvcTest استفاده میکنید، Spring Boot فقط اجزای ضروری برای تست کنترلرها را بارگذاری میکند. این شامل موارد زیر است:
WebMvcConfigurer)MockMvc برای ارسال درخواستهای HTTP و بررسی پاسخهابه طور پیشفرض، @WebMvcTest اجزای دیگری مانند سرویسها، ریپازیتوریها، و سایر لایههای برنامه را بارگذاری نمیکند. این کار ایزولهسازی تستها را تضمین میکند و باعث میشود که تستهای شما فقط بر رفتار کنترلر متمرکز شوند.
حالا چرا باید ازش استفاده کنیم ، البته بایدی توش نیست میتونید استفاده نکنید راهکار برای تست زیاد هست :
MockMvc نوشتن تستها را بسیار سادهتر میکند. شما میتوانید به راحتی درخواستها را شبیهسازی کنید و پاسخها را بررسی کنید.@WebMvcTest(PostsController.class) // مشخص کردن کنترلر مورد نظر برای تست public class PostsControllerTest { @Autowired private MockMvc mockMvc; @Test public void testGetEndpoint() throws Exception { mockMvc.perform(MockMvcRequestBuilders.get("/api/posts")) .and } }
@Test
بالا سر هر متد تست باید این انوتیشن رو بزارید و گرنه به عنوان تست اجرا نمیشه.
@DisplayName
این انوتیشن به شما این امکان را میدهد که یک نام قابل فهم و توصیفی برای تستها تعیین کنید تا گزارشهای تست خواناتر و مفهومیتر باشند.
@BeforEach
این انوتیشن برای اجرای کد قبل از هر تست استفاده میشود. کدی که در این متد قرار میگیرد، پیش از هر تست اجرا میشود.
@AfterEach
این انوتیشن برای اجرای کد بعد از هر تست استفاده میشود. معمولاً برای تمیزکاریها یا بازنشانی دادهها کاربرد دارد.
@BeforAll
این انوتیشن برای اجرای کد قبل از اجرای تمامی تستها استفاده میشود و معمولاً برای انجام عملیاتهای سنگین (مانند راهاندازی پایگاه داده یا ارتباطات شبکه) به کار میرود.
@AfterAll
مشابه @BeforAll ، این انوتیشن برای کدهایی استفاده میشود که باید پس از اجرای تمام تستها اجرا شوند. معمولاً برای آزادسازی منابع یا تمیزکاریهای عمومی استفاده میشود.
@Mock
این انوتیشن برای Mock کردن کلاسها و وابستگیها استفاده میشود. این انوتیشن به Mockito میگوید که یک شی از کلاس هدف ایجاد کند، که رفتار آن قابل شبیهسازی باشد.
@InjectMocks
این انوتیشن به Mockito میگوید که وابستگیهای Mock شده را به صورت خودکار به کلاس هدف تزریق کند.
@Captor
این انوتیشن برای تعریف یک کپی از آرگومانهای ورودی به متدها یا مقادیر خروجی استفاده میشود. معمولاً برای بررسی دقیقتر پارامترهای ارسال شده به متدهای Mock شده کاربرد دارد.
@Spy
این انوتیشن برای ایجاد یک Spy (نمونه واقعی) از یک شی استفاده میشود. برخلاف Mock، Spyها رفتار واقعی شی را حفظ میکنند اما میتوانند رفتار خاصی را شبیهسازی کنند یا تغییر دهند.
given()
این متد برای شبیهسازی رفتار یک شی Mock شده استفاده میشود. شما میتوانید با استفاده از given(...) مشخص کنید که وقتی یک متد خاص فراخوانی میشود، چه پاسخی باید داده شود.
متد given() بخشی از کتابخانه AssertJ است که شبیه when() عمل میکند، اما به خاطر شکل نوشته شدنش خیلی به روان بودن تست و قابل فهم تر شدن تست کمک میکنه .
given(myService.performAction()).willReturn("Expected Value");
verify()
این متد برای تایید اینکه یک متد خاص در Mock فراخوانی شده است یا نه، استفاده میشود.
when()
مشابه given(...) ، این متد برای تعریف رفتار یک Mock به کار میرود، اما در نسخههای قدیمیتر Mockito معمولاً از when(...).thenReturn(...) استفاده میشد. در نسخههای جدیدتر ماکیتو given بیشتر به کار میرود.
متد when یک ابزار برای شبیهسازی رفتار متدهای اشیاء Mock شماست.
زمانی استفاده میشود که میخواهید بگویید یک متد خاص باید چه مقدار یا چه چیزی را برگرداند.
MyService myService = mock(MyService.class); when(myService.performAction()).thenReturn("Expected Value");
حالا توی این مثال thenReturn چیکار میکنه :
متد thenReturn() به شما این امکان را میدهد که مقداری را که باید در پاسخ به یک فراخوانی متد مشخص شده برگشت داده شود، تعریف کنید.
doReturn()
متد doReturn() مشابه when().thenReturn() است، اما استفاده از آن امکان شبیهسازی متدهایی که عوارض جانبی دارند یا متدهایی که void هستند، را نیز فراهم میآورد.
زمانی استفاده میشود که میخواهید از متد spy با رفتار واقعی استفاده کنید یا میخواهید یک متد void را شبیهسازی کنید.
doReturn("Expected Value").when(myService).performAction();
بعضی هارو که به ذهنم در این لحظه رسید رو میگم .
در Mockito، بین given و when تفاوتی در استفاده وجود دارد، اما هر دو به طور کلی برای تعریف رفتار اشیاء Mock استفاده میشوند
when()
به این ساختار دقت کنید :
when(mock.someMethod()).thenReturn(someValue);
when برای تعیین آنچه که باید در هنگام فراخوانی یک متد خاص بر روی شی Mock اتفاق بیفتد استفاده میکنید.thenReturn یا متد های دیگر موجود ، میتوانید تعیین کنید که چه مقدار باید برگرداند.given()
به این ساختار هم یه نیگا بندازید:
given(mock.someMethod()).willReturn(someValue);
willReturn برای تعیین مقداری که باید هنگام فراخوانی متد برگردانده شود استفاده میشود.given خیلی خواناتر و برای همه دوولوپر های یک پروژه میتونه راحت تر و قابل فهم تر باشه.when خیلی به given نزدیکه شکل ساختاریش اما چون توی ساختارش بین متد ها یه حالت chain وجود داره یکم پیچیدگی ایجاد میکنه.در نهایت، انتخاب بین given و when بستگی به سلیقه شخصی شما و تیم توسعه دارد. اگر میخواهید به کدی با خوانایی بیشتر نزدیک شوید، ممکن است given را ترجیح دهید. با این حال، برای اکثر تستها هر دو روش به خوبی کار میکنه .
when یا given رفتار موردنظر خود را تعیین کنید.mock چیزی شبیهسازی میکنید، یعنی وابستگی های یک کلاس یا یک شی به طور کامل پیاده سازی نشده و همه چیز فیک هست . این به معنای این است که هیچ رفتاری واقعی در کلاس وجود نداره ، مگر اینکه صراحتاً آن را تعریف کنید.mock، هیچ پیادهسازی از متدها وجود ندارد مگر اینکه آنها را شبیهسازی کنید.@Mock private MyService myService; when(myService.performAction()).thenReturn(expectedValue);
spy شما از یک شیء واقعی استفاده میکنید، به این معنی که بسیاری از رفتارهای واقعی آن حفظ میشود. شما میتوانید متدها را شبیهسازی کنید یا به رفتار واقعی شیء رو داشته باشید.spy، میتوانید متدها را به صورت واقعی اجرا کرده و فقط برخی از آنها را شبیهسازی کنید. spy ممکن است کمی کندتر باشد چون میاد متد های واقعی رو هم اجرا میکنه .@Spy private MyService myService = new MyServiceImpl(); // شما میتوانید بخشی از رفتار را شبیهسازی کنید doReturn(mockedValue).when(myService).someMethod();
spy استفاده کنید.mock گزینه بهتری است.خلاصه
Mock: برای ایجاد رفتار خاص و شبیهسازی وابستگیها استفاده میشود. هیچ جزئیاتی از رفتار واقعی نخواهد داشت.
Spy: از یک شی واقعی بهره میگیرد و میتوانید رفتار برخی از متدها را شبیهسازی کرده و بقیه را بهصورت واقعی اجرا کند.
استفاده از mock و spy به موقعیت و نیاز شما بستگی دارد. اگر نیاز دارید که یک رفتار خاص را بهطور کامل شبیهسازی کنید، از mock استفاده کنید. اما اگر بخواهید در کنار شبیهسازی، از عملکرد واقعی شیء نیز بهره ببرید، spy گزینه مناسبتری است.
اول کار بیاید این dependency هارو اضافه کنید :
<!-- already added by Spring Initializr--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <!-- add JUnit5 (Jupiter) --> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-engine</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-api</artifactId> <version>5.7.1</version> <scope>test</scope> </dependency> <!-- add Mockito --> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-core</artifactId> <scope>test</scope> </dependency>
میخوایم مثل همه جاهای دیگه ما هم یه متد بیاریم که یه عمل جمع رو انجام میده و بعد تستش کنیم :
public class Calculator { public int add(int a, int b) { return a + b; } }
حالا بیایم تست رو بنویسیم :
import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; public class CalculatorTest { @Test public void testAdd() { Calculator calculator = new Calculator(); int result = calculator.add(2, 3); assertEquals(5, result, "2 + 3 should equal 5"); } }
شرح کاری که کردیم :
1- انوتیشن @Test : نشاندهنده این است که این متد یک تست است.
2- متد assertEquals(expected, actual) : برای مقایسهی مقدار مورد انتظار (expected) و مقدار واقعی (actual) استفاده میشود.
3- پیام "2 + 3 should equal 5" در صورتی که تست شکست بخورد، نمایش داده میشود.
در JUnit انواع مختلفی از assertions برای بررسی شرایط مختلف داریم:
public class Calculator { public int divide(int a, int b) { if (b == 0) { throw new ArithmeticException("Cannot divide by zero"); } return a / b; } }
بریم تستش کنیم :
import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; public class CalculatorTest { @Test public void testDivide() { Calculator calculator = new Calculator(); int result = calculator.divide(10, 2); assertEquals(5, result, "10 / 2 should equal 5"); } @Test public void testDivideByZero() { Calculator calculator = new Calculator(); ArithmeticException exception = assertThrows(ArithmeticException.class, () -> { calculator.divide(10, 0); }); assertEquals("Cannot divide by zero", exception.getMessage()); } }
خوب توی این تست نیاز داریم exception رو در صورت اتفاق افتادن بررسی کنیم بنابراین در متد testDivideByZero از assertThrows استفاده میکنیم تا اطمینان حاصل کنیم که در صورتی که بخواهیم تقسیم بر صفر انجام دهیم، استثنای ArithmeticException پرتاب میشود.
توی تست های بالا دیدید که برای ایجاد یک نمونه از Calculator اومدیم داخل متد تستمون به ازای هر متد تست یه بار instance ساختیم .
این کار مشکلی نداره اما اگر قرار باشه چندین متد تست داشته باشیم که بخوایم توی هر کدوم از متد های یک instance از این کلاس داشته باشیم اونوقت زیاد جالب نیست .
حالا باید چیکار کنیم : در تست های گروهی میشه از after و before استفاده کرد
import org.junit.jupiter.api.*; public class CalculatorTest { private Calculator calculator; @BeforeEach public void setUp() { calculator = new Calculator(); } @Test public void testAdd() { int result = calculator.add(2, 3); assertEquals(5, result); } @AfterEach public void tearDown() { // عملیات پاکسازی، اگر نیاز باشد } }
یکم جولو تر از mock استفاده میکنیم و روال یکم پیچیده تر میشه . (برای تستهای پیچیدهتر که نیاز به تعامل با پایگاهدادهها، APIها یا سرویسهای خارجی دارند. )
لطفا تست هارو خیلی سختش نکنید . موقع تست شلکس کنید .
هر تست باید مستقل از دیگر تستها و تغییرات یک تست نباید بر تستهای دیگه تأثیر بگذارد.
سعی کنید تمام قسمتهای مهم کد تحت پوشش بیمه حضرت تست قرار بدید.
import static org.mockito.Mockito.*; import org.junit.jupiter.api.Test; public class CalculatorServiceTest { @Test public void testCalculateWithMock() { Calculator calculator = mock(Calculator.class); when(calculator.add(2, 3)).thenReturn(5); int result = calculator.add(2, 3); assertEquals(5, result); } }
- یه نمونه RestApi (میایم از mock برای دیتاهامون و کلاس هامون استفاده میکنیم)
میخوایم یه controller بنویسیم که اطلاعات کاربر رو توی یه api خیلی ساده دریافت و ذخیره میکنه . توی این تیکه کدی که میزنیم لایه سرویس هم داریم که طبیعتا توی لایه سرویس ریپازیتوری داریم و ...
@RestController @RequestMapping("/user/profile") public class UserProfileController { @Autowired private UserProfileService userProfileService; @PostMapping public UserProfile createUser(@RequestBody UserProfile user){ return userProfileService.addUser(user); } @GetMapping public List<UserProfile> findAllUsers(){ return userProfileService.findAllUsers(); } }
خوب همه چیز فکر کنم مشخص باشه حالا بریم تست رو بنویسیم :
@ExtendWith(MockitoExtension.class) public class UserProfileControllerTest { @InjectMocks private UserProfileController userProfileController; @Mock private UserProfileService userProfileService; @DisplayName("Test - Insert New userProfile - test without exception") @Test public void insertNewUserProfile(){ UserProfile user = new UserProfile(); user.setId(1); user.setFirstName("mehrdad"); user.setLastName("khanzadi"); user.phoneNumber("09120909090") given(userProfileController.createUser(user)).willReturn(user); UserProfile userProfile = userProfileController.createUser(user); assertThat(userProfile.getId()).isEqualTo(user.getId()); assertThat(userProfile.getFirstName()).isEqualTo(user.getFirstName()); assertThat(userProfile.getLastName()).isEqualTo(user.getLastName()); assertThat(userProfile.getPhoneNumber()).isEqualTo(user.getPhoneNumber()); } @DisplayName("Test - findAll and getAll userProfiles - test without exception") @Test public void findAllData(){ UserProfile firstUser = new UserProfile(); firstUser.setId(1); firstUser.setFirstName("mehrdad"); firstUser.setLastName("khanzadi"); firstUser.phoneNumber("09120909090") UserProfile secondUser = new UserProfile(); secondUser.setId(2); secondUser.setFirstName("sadegh"); secondUser.setLastName("khanzadi"); secondUser.phoneNumber("09126959493") List<UserProfile> users = new ArrayList<>; users.add(firstUser); users.add(secondUser) given(userProfileController.findAllData()).willReturn(allPosts); List<UserProfile> results = postController.findAllUsers(); assertThat(results).hasSize(2); users.stream.forEach(R -> { assertThat(R.getFirstName()).isEqualTo("mehrdad"); assertThat(R.getPhoneNumber()).isEqualTo("09030909090"); }); } }
در نسخههای جدید JUnit (JUnit 5) برای اتصال Mockito به سیستم تست JUnit استفاده میشود. این انوتیشن به صورت خودکار mockها را ایجاد کرده و تزریق میکند، به طوری که نیاز به انجام تنظیمات دستی نیست.
این انوتیشن یک نمونه از کلاسی که شما در حال تست آن هستید ایجاد میکند و mockهایی که با انوتیشن @Mock مشخص شدهاند را به صورت خودکار در آن تزریق میکند. در واقع، @InjectMocks به Mockito میگوید که تمام mockهای ساخته شده باید به این کلاس تزریق شوند تا بهطور خودکار تستها را بر اساس نیاز شما انجام دهد.
این انوتیشن برای ایجاد پیادهسازی mock برای کلاسها یا وابستگیهایی که در تست به آنها نیاز دارید استفاده میشود. به عبارت سادهتر، هر زمانی که شما نیاز به شبیهسازی یک شیء از کلاس خاصی برای تست دارید، از این انوتیشن استفاده میکنید. @Mock به Mockito میگوید که یک نسخه جعلی از این کلاس بسازد که بتوانید رفتار آن را کنترل و بررسی کنید.
نکته : دوستان این روش تست برای restApi هامون خوبه ولی خیلی جاها میبینید که با استفاده از متد های موجود در controller میان و تست رو انجام میدن اینجا هم ما از userProfileController استفاده کردیم و یه ماک ساختیم ازش :
@InjectMocks
private UserProfileController userProfileController;
حالا میرید یه جایی میبینید به جای اینکه بیان unit by unit تست کنن میان integration test انجام میدن و این بستگی به جنس کاری شما داره ، یه جایی لازم دارید کل روال کار با صدا زدن یه سرویس تا انتهای ذخیره سازی توی دیتابیس پیش بره و جواب صحیح و یا خطا برگشت داده بشه ، میشه به صورت container بیایم با docker در زمان test دیتابیس و کش و ... رو بیاریم بالا و تست هارو بزنیم وقتی تست تمام شد container دیتابیس و ... بسته میشه و دوشواری هم نداریم . (مبحث integrationTest رو توی یه آموزش دیگه باید بررسی کنیم)
-یه نمونه mock هزارتایی از دیتابیس:
خوب اول بیایم برای مثال قبل که زدیم برای تست repository یه مدل تست بریم :
لایه repository:
public interface UserProfileRepository extend JpaRepository<User,Long> { User findById(Long userId); }
لایه service:
@Service public class UserProfileService { private final UserProfileRepository userRepository; @Autowired public UserService(UserProfileRepository userRepository) { this.userRepository = userRepository; } public UserProfile getUserById(Long userId) { return userRepository.findById(userId).convertToDto(); } }
بریم تستش رو داشته باشیم :
import static org.mockito.Mockito.*; import static org.junit.jupiter.api.Assertions.*; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; public class UserServiceTest { @Mock UserProfileRepository userRepository; @InjectMocks UserProfileService userService; @BeforeEach public setUp() { MockitoAnnotations.openMocks(this); } @Test public void testGetUserById() { User mockUser = new User(1L, "Sadegh", "Khanzadi", "09030909090"); when(userRepository.findById(1L)).thenReturn(mockUser); UserProfile user = userService.getUserById(1L); assertThat(user.getFirstName()).isEqualTo("Sadegh"); } }
اگر دقت کنید این بالا یدونه دیتا داشتیم حالا اگر بخوایم 1000 تا user داشته باشیم چی میشه ؟؟
خودتونم میتونید راهکار داشته باشیدا این راهکار منه
public class UserServiceTest { @Mock UserProfileRepository userRepository; @InjectMocks UserProfileService userService; @BeforeEach public void setUp() { MockitoAnnotations.openMocks(this); } @Test public void testGetUserByIdWithMultipleUsers() { List<User> mockUsers = TestDataGenerator.generateUserList(1000); when(userRepository.findById(500L)).thenReturn(mockUsers.get(499)); // کاربر شماره 500 UserProfile user = userService.getUserById(500L); assertEquals("User 500", user.getName()); } public static List<User> generateUserList(int count) { List<User> users = new ArrayList<>(); for (int i = 1; i <= count; i++) { users.add(new User((long) i, "User " + i)); } return users; } }
متد generateUserList میاد 1000 تا کاربر میسازه اول کار و بعد کار با اون 1000 کاربر پیش میره.
امیدوارم راضی بوده باشید
تلاش میکنم در یه آموزش جدا بیام InegrationTest رو توضیح بدم .
با تشکر از نگاه های زیباتون .