تست نوشتن رو اغلب کسایی آموزش میدهند و توصیه میکنند که ایدهآل گرا هستند و اگر شماهم ایدهآل گرا باشید جمعش میشه اینکه بیخیال اینهمه اصول میشید و حتی تست نویسی رو با کیفیت متوسط هم انجام نمیدید. تجربه یکسال اخیر من توی جیرینگ بهم ثابت کرد تست نوشتن اونقدرم چیز وقت گیر و پیچیدهای نیست و کافیه چنتا مفهوم و تکنیک رو بلد باشید تا بشه باهاش تست نوشت و در اکثر موارد کار با همینا راه میوفته. پس شل کنید و با من همراه باشید :) .
خب همینجور که میدونیم تست واحد سریعتر و کم حجمتر از تست تجمیعیه و این باعث میشه حوصله ما کمتر سر بره و فرایند بیلد هم سریعتر بشه و در نتیجه باعث میشه productivity بهتری داشته باشیم. البته تستهای تجمیعی هم لازم هستن اما جاهایی باید استفاده بشن که واقعا راهی جز اونا نیست.
مثلا برای اینکه بخوایم یک سرویس به نام ProductService که داخلش سعی میکنه یک محصول رو بفروشه و برای این کار با دیتا بیس چک میکنه که تعداد محصول برای فروش کافی هست یا نه شاید در نظر اول اینطور بنظر میاد که برای نوشتن تست باید دیتابیس رو هم بیاریم بالا و در نتیجه تست تجمیعی انجام بدیم. اما اگر بخواهیم تنها و تنها عملکرد خود کلاس ProductService رو تست کنیم نیازی به دیتابیس واقعی نداریم و میتونیم در نظر بگیریم که دیتابیس داره کارش رو درست انجام میده. چطور ؟ بله با Mockito. قبل از اینکه بگم چجوری با ماکیتو میشه کارکرد یه خلاصهای از ساختار تستها در Spring Boot میگم.
نیازمندیهایی که برای این مطلب نیاز دارید به spring boot اضافه کنید اینهاست :
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>
البته test رو خودش اضافه میکنه By Default
در ابتدا میخوام انوتیشنهایی رو توضیح بدم که با باز کردن کلاس تست خود Spring boot اونو میبینید. کلاس به شکل زیر است :
@SpringBootTest class TestingApplicationTests { @Test void contextLoads() { // loads application contex } }
این کلاس در واقع یک integration تست هست که داره کامل اپلیکیشن مارو میاره بالا. بالای هر کلاس تست اگر انوتیشن @SpringBootTest باشد معنیش اینه که اون کلاس کل اپ رو میاره بالا و میشه توش از امکانات اسپرینگ مثل @Autowired و هرچیزی که داخل context هست بصورت واقعی استفاده کرد. و بدرد تستهای تجمیعی میخوره. توی این کلاس میشه تمام قابلیتهای Junit5 رو استفاده کرد و علاوره بر اون خود اسپرینگ کلی قابلیتهای دیگه داره مثلا میتونه اپ رو با پورت رندوم بیاره بالا یا یه پروفایل خاص رو لود کنه و هزارتا چیز دیگه که الان لازم نیست همشو بلد باشیم کافیه موارد پایه رو بدونیم و بعدا باتوجه به شرایط سرچ کنیم و راهحل موردنظر حتما پیدا خواهد شد.
برای اینکه این کلاس تبدیل بشه به یک یونیت تست کافیه اون انوتیشن رو برداریم تا هیچ چیز اضافی قبل از تست لود نشه.
این مورد کمی آزاردهندس چون Junit4 همچنان زندس و خیلی از آموزشها با این ورژن داده شده اما وقتی یک Spring boot جدید میسازید با Junit5 کارمیکنه و یکم دردسرسازه. حواستون به این مورد باشه که وقتتون هدر نره مثلا موقع سرچ حتما به ورژن اشاره کنید به تناسب نیازتون. من اینجا در ادامه سعی میکنم به تفاوتها اشاره کنم. تفاوتها رو میتونید اینجا ببینید
خب تصور کنید ما یک DAO به شکل زیر داریم :
public interface ProductDAO extends JpaRepository<Product, Long> { }
و میخوایم مثلا توی یک سرویس به شکل زیر ازش استفاده کنیم :
@Service public class ProductService { @Autowired private ProductDAO dao; public Boolean addToBasket() { if (dao.count() > 0) { System.out.println("It has been added to basket"); return true; } else { System.out.println("It's not enough to sell"); return false; } } }
خب اینجا ما از DAO استفاده کردیم تا ببینم تعداد کافی محصول برای فروش داریم یا نه.
حالا اگر بخوایم این کد رو تست کنیم در حالت ابتدایی اینکار رو میکنیم:
@SpringBootTest public class ProductServiceTest { @Autowired private ProductService productService; @Test public void addToBasketTest() { Assertions.assertTrue(productService.addToBasket()); } }
که یک تست سادس و بهش گفتیم اپ رو بیار بالا (که دیتابیس در دسترس باشه) بعد انگار که میخوایم از سرویس توی کنترلر استفاده کنیم متد اونو صدا میزنیم و میگیم انتظار داریم چی برگردونه. این کد با Junit5 نوشته شده.
ولی ما شاید به دیتابیس دسترسی نداشته باشیم یا دیتای مورد انتظار تست توش نباشه (ما فقط همین یدونه تست رو نداریم وهزارتا شرایط دیگه باید قابل تست باشه) بنابراین بهتره DAO ماک بشه و چیزی که ما صراحتا بهش میگیم رو برگردونه برای اینکار اینطور کد تغییر میکنه :
@SpringBootTest public class ProductServiceTest { @MockBean private ProductDAO dao; @Autowired private ProductService productService; @Test public void addToBasketTest() { when(dao.count()).thenReturn(2l); Assertions.assertTrue(productService.addToBasket()); } }
جای جذاب کار همینجاس ! @MockBean کاری که میکنه اینه که یه آبجکت ماک از نوع ProductDAO رو جایگزین آبجکت واقعی داخل IOC Container میکنه. حالا پایینتر ، تو متد خط اول میبینید که بهش گفتیم وقتی dao.count() صدا زده شد عدد ۲ برگردانده بشه و بعد کار قبلی رو کردیم و حالا تست پاس شد :).
به کاری که تو خط اول کردیم میگن Stubbing :
Stubbing means simulating the behavior of a mock object’s method. We can stub a method on a mock object by setting up an expectation on the method invocation
اما اگر دقت کرده باشید ما هنوز داریم اپ رو کامل بالامیاریم! چرا ؟ هیچ دلیلی نداره ما نیازی بهش نداریم و فقط داریم وقت هدر میدیم.
برای اینکه اپ لود نشه اولین کار اینه که @SpringBootTest رو از بالای کلاس برداریم. اما برای پاس شدن تست باید کارهای دیگهای هم انجام بشه. کد تغییر یافته رو ببینیم :
@ExtendWith(MockitoExtension.class) public class ProductServiceTest { @Mock private ProductDAO dao; @InjectMocks private ProductService productService; @Test public void addToBasketTest() { when(dao.count()).thenReturn(2l); Assertions.assertTrue(productService.addToBasket()); } }
تغییر اول استفاده از @ExtendWith(MockitoExtension.class) هست که به ما این امکان رو میده که از انوتیشنهای Mockito استفاده کنیم. بعدی @Mock هست که بجای @MockBean داریم ازش استفاده میکنیم فرقشون اینه که mock فقط ماک میسازه و کاری به IOC Container نداره (ما اصلا Container لود نکردیم تو این تست که بخوایم باهاش کاری بکنیم) . تغییر بعدی استفاده از @InjectMocks بجای @Autowired هست که در غیاب Application context وظیفه تزریق وابستگی با ماکهایی که ما ساختیم (که اینجا فقط ProductDAO هست) رو برعهده دارد. حالا تست ما که خییییلی سبک ، سریع و راحت شده پاس میشود :).
همینطور که قبلا گفتیم MockBean بجای کامپوننت واقعی یک ماک از آبجکت میسازه و اونو میذاره داخل IOC Container اما Autowired آبجکت واقعی است (همونیه تو کد معمولی ازش استفاده میکنید). جفتشون استفاده دارن با دونستن تفاوت در جای مناسب میشه ازشون استفاده کرد.
روشهایی که گفتم مرسومترین و راحت ترینش بود اما روش دیگرش استفاده از متد استاتیک mock هست به این شکل :
ArrayList mockList = Mockito.mock(ArrayList.class);
که معادل :
@Mock ArrayList mockList;
این قابلیت واقعا عالیه و از پیچیدگی کد تست جلوگیری میکنه. به این شکل که اگر یک stubbing بشکل زیر بنویسیم :
when( dao.findById(anyLong()) ).thenReturn( Optional.of(new Product()) );
اما در هیججای تست ، متد dao.findById صدا زده نشده باشد یک اکسپشن میگیریم به نام UnnecessaryStubbingException و تست fail میشه و بهمون میگه کدوم stubbing زیادیه و با پاک کردنش مشکل حل میشه.
اگر بخواهیم Integration test بنویسیم که با دیتابیس کارمیکنه بهتره که از دیتابیسی جدا از اونی که داریم برای development استفاده میکینیم استفاده کنیم. معمولا از دیتابیس های In-memory برای تست استفاده میکنن که مرسومترینش تو جامعه جاوایی H2 هست. برای اینکار کافیه در فایل properties که داخل پکیج
/src/test/resources/application.properties
هست این کانفیگهارو برای DataSource قرارا بدیم :
spring.datasource.driver-class-name=org.h2.Driver spring.datasource.url=jdbc:h2:mem:db;DB_CLOSE_DELAY=-1 spring.datasource.username=sa spring.datasource.password=sa spring.jpa.hibernate.ddl-auto=create-drop
و همینظور در داخل فایل POM هم نیازمندی H2 رو اضافه کنید :
<dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>test</scope> </dependency>
حالا برای اجرای تستها از H2 استفاده میشه.
برای مباحث پیشرفتهتر میتونید مطالعهای روی @Spy داشته باشید که نوعی ماک هست. این لینک توضیحات خوبی داره دربارش.
ممنون که همراه بودید برای بحث درباره این مطلب میتونید به توئیتر من سربزنید.
منابع مطلب
https://springframework.guru/mocking-unit-tests-mockito/
https://www.baeldung.com/java-spring-mockito-mock-mockbean
https://howtodoinjava.com/mockito/mockito-mock-injectmocks/
https://www.baeldung.com/junit-5
https://www.baeldung.com/mockito-unnecessary-stubbing-exception