مقایسۀ JUnit5، Junit4 و Spock

خلاصهای از یک سخنرانی در مورد مقایسه سه کتابخانه تست واحد همراه با مثالهای ساده، مقایسۀ حجم کد نوشتهشده در هر کدام و کیفیت پیام خطای حاصل از هر کتابخانه و …، محتوای این مقاله را تشکیل میدهد. کارکرد اسپاک را ببیند و متعجب شوید!
این مقاله توسط Anton R. Yuste نوشته شده و در سایت DZone منتشر شده است.
اخیرا، در JUG محلی[1] ارائهای در مورد تست واحد[2] داشتم. در این ارائه در مورد فریمورکهای رایج این کار صحبت کرده و سه کتابخانۀ Junit4، Junit5 و Spock را مرور کردم.
بسیاری از شنوندهها از دیدن تفاوتها متعجب شده بودند. در این جا بخش assert، تکرار تست برای لیستی از پارامترها و بحث mocking را خلاصه خواهم کرد.
دوست دارم مفاهیم را با مثال نشان دهم، به این منظور در این جا از الگوریتم سادۀ فیبوناچی استفاده خواهم کرد که یک الگوریتم ساده برای تولید عدد است و هر عدد از مجموع دو عدد قبلی محاسبه میشود:
1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, …
من از یک پیادهسازی معمولی و نه چندان خوب استفاده کردم که به نظر میرسد برای مثالهای ما کافی باشد:
private static int fibonacci(int n) { if (n <= 1) return n; else return fibonacci(n-1) + fibonacci(n-2); }
Junit 4
بحث را با شرح Junit4 شروع میکنیم، به این دلیل که این کتابخانه تقریبا رایجترین فریمورکی است که در حال حاضر مورد استفاده قرار میگیرد و بسیاری از کتابخانههای دیگر از آن ایده گرفتهاند. ارائۀ بحث را با assertTrue شروع کرده و سپس کاربردهای پیشرفتهتری مثل assertEquals را بررسی کردم.
@Test public void improvedFibonacciTestSimple() { FibonacciWithJUnit4 f = new FibonacciWithJUnit4(); assertEquals(f.fibonacci(4), 3); }
اگر تست فوق غلط باشد، پیغام خطایی که دریافت میکنیم به شکل زیر است:
java.lang.AssertionError: Expected :3 Actual :2
شکل پیغام خیلی عالی نیست، ولی میشود گفت اطلاعات کافی نمایش داده شده است.
گاهی لازم است عملکرد یک متد را به ازای پارامترهای مختلفی بررسی کنیم. سادهترین کار این است که به ازای هر مقدار یک تست بنویسیم، اما اگر تعداد تستها زیاد باشد این روش منطقی نیست. در این موارد میتوانیم یک لیست از پارامترها تهیه کنیم و از فریمورک تست بخواهیم تست را به ازای هر مقدار در لیست، یک بار تکرار کند.
در ادامه، عملکرد سه فریمورک مختلف را در این مورد بررسی خواهیم کرد. روشی که Junit4 در اختیار قرار میدهد خیلی ساده نیست، چرا که باید ابتدا یک Collection بسازیم و از Parameters@ برای مشخص کردن آن استفاده کنیم. به مثال زیر دقت کنید:
@Parameters public static Collection<Object[]> data() { return Arrays.asList( new Object[][] { { 0, 0 }, { 1, 1 }, { 2, 1 }, { 3, 2 }, { 4, 3 }, { 5, 5 }, { 6, 8 } }); }
سپس برای استفاده از آن در کلاس تست به یک متغیر محلی و یک متدِ سازنده[3] نیاز داریم:
private int fInput; private int fExpected; public ParametrizedFibonacciJUnit4(int input, int expected) { fInput = input; fExpected = expected; }
و در نهایت میتوانیم assertEquals را به شکل زیر استفاده کنیم:
@Test public void test() { FibonacciWithJUnit4 f = new FibonacciWithJUnit4(); assertEquals(fExpected, f.fibonacci(fInput)); }
خب! قبول دارید که برای این کار، کد کمی ننوشتیم و روش Junit4 چندان خلاصه نیست. اگر تست رد شود، پیامی که دریافت میکنیم به اندازهی کافی خوب نیست. به عنوان مثال اندیس پارامتری که تست در آن رد شده را دریافت نخواهیم کرد. (هر چند IDEها و افزونههای آن احتمالا اطلاعات بیشتری به شما خواهند داد.)
java.lang.AssertionError: Expected :0 Actual :1
از آنجایی که معمولا هنگام استفاده از Junit4 برای Mocking از کتابخانهای به نام Mockito استفاده میشود، این بحث را اینجا شرح نمیدهیم. البته باید گفت که واقعا Mockito کتابخانه خوبی است ولی متاسفانه این مقاله گنجایش شرح آن را ندارد.
Junit5
نسخه پایدار JUnit5 در سپتامبر2017 منتشر شد. این نسخه با جاوا 8 و لامداها سازگاری بیشتری دارد و به همین دلیل باید نسبت به نسخههای قدیمیتر ترجیح داده شود. همچنین با نسخۀ 4 نیز سازگار است و مهاجرت تدریجی را سادهتر میکند. این نسخه کلاسهای اجرا کننده[4] جدیدتری ارائه کرده و تعامل با دیگر فریمورکها[5] را بهینهتر کرده است. در بررسی JUnit5 نیز روال مشابهی با آنچه در مورد JUnit4 انجام دادیم را پیش خواهیم برد. ابتدا مثالی از assertEquals را بررسی میکنیم:
@Test public void bestFibonacciTestSimple() { FibonacciWithJUnit5 f = new FibonacciWithJUnit5(); Assertions.assertEquals(f.fibonacci(4), 3); }
در مورد متدهای assert، بهینهسازیهای خوبی انجام گرفته است و به عنوان مثال در مورد timeoutها تغییراتی داده شده است. اما متدِ assertEqual با پارامتر از نوع Int مشابه نسخۀ قبلی است. پیغام خطایی که دریافت میکنیم نیز تغییر نکرده و به شکل زیر است:
org.opentest4j.AssertionFailedError: Expected :3 Actual :2
در مورد تکرار تست برای لیستی از پارامترها تغییرات مهمی به وجود آمده. ابتدا از ParameterizedTest@ استفاده میشود. در داخل این annotation میتوانیم پارامترها را به شکل {paramName} نامگذاری کنیم تا پارامترهای مهم مثل index در مثال زیر قابل دستیابی باشند:
@ParameterizedTest(name = "run #{index} with [{arguments}]")
حالا باید هر مجموعه دادهای که یک تست باید با آن اجرا شود را تعریف کنیم. من به این منظور از CsvSource@ استفاده خواهم کرد. هر آیتم، جایگزین پارامترهای متدِ تست خواهد شد که در مثالِ ما، اعداد با نامهای input و expected هستند.
@CsvSource({"1, 1", "4, 3"}) public void test2(int input , int expected) { FibonacciWithJUnit5 f = new FibonacciWithJUnit5(); Assertions.assertEquals(f.fibonacci(input), expected); }
همانطور که میبینید، کد بالا بسیار بهتر از چیزی است که برای JUnit4 نوشته شده بود. علاوه بر خلاصه بودن کد در این حالت، اگر تست رد شود پیغام خطای بهتری نیز دریافت خواهیم کرد که شامل اندیس دادهای که تست با آن پاس نشده، مقدار پارامترها در این حالت و … خواهد بود.
در مورد Mocking در JUnit5 نیز معمولا از یک کتابخانۀ خارجی استفاده میشود، در نتیجه این جا از توضیح آن عبور میکنیم.
Spock
موضوع آخری که در مورد آن بحث شد، فریمورک Spock است که بر پایه Apache Groovy است و اجازه میدهد با کد کمتر و واضحتری کارتان را انجام دهید. Groovy واقعا قدرتمند است و ما از چند سال پیش شروع به استفاده از آن در بخشهای «غیر حساس» توسعه، مانند تست، مدیریت وابستگیها، CI، تست لود بالا و … کردیم. تقریبا هر جایی که نیاز به تنظیم و کانفیگ داشتیم و میخواستم از xml و json دور باشیم، آن را به کار میگرفتیم. هنوز هم هستهی مرکزی نرمافزارمان را با جاوا توسعه میدهیم و همزمان از groovy نیز استفاده میکنیم، این دو زبان با هم سازگارند. در واقع اگر شما با جاوا آشنایید، پس Groovy را هم میشناسید… . ما تقریبا از بهترین محصولات هر دو دنیا (جاوا و Groovy) استفاده میکنیم.
نوشتن تست در Spock کاملا متفاوت است، به این مثال توجه کنید:
def "Simple test"() { setup: BadFibonacci f = new BadFibonacci() expect: f.fibonacci(1) == 1 assert f.fibonacci(4) == 3 }
معمولا از «def» زمانی استفاده میکنیم که «نوع» برایمان مهم نیست. نام تابع بین کاراکترهای ” ” نوشته می شود. این مسئله به ما اجازه میدهد نامهای بهتری برای متدها انتخاب کنیم و علاوه بر این، کلمات خاصی مانند setup، when، expect و and و … را در دسترس داریم و میتوانیم با استفاده از آنها، تستها را ساختارمند کنیم. این شیوه باعث میشود تستها چندان نیاز به توضیح نداشته باشند و خوانایی بالایی به آنها داده میشود. کلید واژهی assert نیز بخشی از خود زبان است. پیغام خطایی که از رد شدن تست در این فریمورک حاصل میشود به شکل زیر است. به استفاده از نمادها و شیوه نمایش مقادیر در هنگام اجرای تست دقت کنید.
Condition not satisfied: f.fibonacci(4) == 2 | | | | 3 false BadFibonacci@23c30a20 Expected :2 Actual :3
این پیغام خطا حاوی تمام اطلاعاتی است که لازم داریم: مقدار برگردانده شده (Actual)، مقداری که انتظار داشتیم (Expected)، نام تابع، مقدار پارامتر هنگام اجرا و … . استفاده از assert در Groovy بسیار خوش دست و کاربردی است.
حالا نگاهی به تستهای چند پارامتری بیندازیم، یک مثال معمولی میتواند به این شکل باشد:
def "parametrized test"() { setup: BadFibonacci f = new BadFibonacci() expect: f.fibonacci(index) == fibonacciNumber where: index | fibonacciNumber 1 | 1 2 | 1 3 | 2 }
پس از این که این تکه کد را به حاضرین در ارائه نشان دادم، صدای تعجب تعدادی از آنها را شنیدم. جادوی کد بالا این است که نیازی نیست آن را شرح دهید، خودِ کد کاملا این کار را انجام میدهد. جدولی در بخش where وجود دارد که نامگذاری نیز شدهاند و برای اجرا با نامهای مناسب در بخش مربوط به expect جایگذاری میشوند. اگر تست رد شود، پیغام خطا کاملا شرایط را شرح میدهد:
Condition not satisfied: f.fibonacci(index) == fibonacciNumber | | | | | | 2 3 | 4 | false BadFibonacci@437da279 Expected :4 Actual :2
سپس به سرعت مفهوم Mockها و stubها را شرح دادم. Mock شیای است که وقتی نمیخواهیم از شی واقعی استفاده کنیم، آن را به کار میگیریم. به عنوان مثال هنگام تست نمیخواهیم واقعا یک درخواست در شبکه وب ارسال کنیم، یا مثلا یک صفحه را پرینت کنیم، در این صورت میتوانیم از اشیای بدلیِ یک واسط یا شی واقعی استفاده کنیم.
Subscriber subscriber = Mock() def "mock example"() { when: publisher.send("hello") then: 1 * subscriber.receive("hello")
شیء بدلی یا Mock
به صورت کلی روش کار در اسپاک به این شکل است: یک واسط subscriber به عنوان شی بدلی ساخته میشود و سپس از متدهای آن استفاده میکنیم. علامت «1 *» که در سطر آخر دیده میشود یکی از ویژگیهای جالب اسپاک است و نشان میدهد چند پیام باید دریافت کنید.
Stub
گاهی نیاز داریم چیزی را که متدهای شی بدلی برمیگردانند تعریف کنیم. به این منظور میتوان یک Stub ساخت:
def "stub example"() { setup: subscriber.receive(_) >> "ok" when: publisher.send("message1") then: subscriber.receive("message1") == 'ok' }
به سطر دارای علامت «<<» دقت کنید، در این سطر میگوییم، متدِ receive، مستقل از ورودی آن، باید «ok» را برگرداند. کاراکتر «_» در این سطر به معنیِ «هر ورودی» است. این تست بدون هیچ مشکلی پاس میشود.
خلاصه
من چندان ترجیح و توصیۀ یک کتابخانه نسبت به دیگری را دوست ندارم، چرا که هر کتابخانهای برای شرایطی بهینه شده و کاربردهای خاص خود را دارد. به روشنی گزینههای بسیار خوب و متنوعی در دنیای جاوا در دسترس داریم و من تنها به چند مثال از آنها اشاره کردم. حالا نوبت شماست که تصمیم بگیرید کدام یک برای شما بهترین گزینه است. «تست بنویسید و به کتابخانهای که انتخاب کردهاید مسلط شوید، این کار شما را به برنامهنویس بهتری تبدیل خواهد کرد» این تنها توصیۀ من به شماست.
اگر میخواهید نگاه دقیقتری به مثالها بیندازید میتوانید در گیتهاب به آنها دسترسی داشته باشید.
[1] Local Java User Group
[2] Unit Testing
[3] Constructor
[4] Runner Classes
[5] Integrators
مترجم: بر اساس JUnit API Documentation در پارامترهای متد assertEquals به ترتیب اول باید مقدار expected و بعد مقدار actual وارد شود. در برخی از مثالهای مقاله، این ترتیب اشتباه نوشته شده است و اگر در آن حالت تست رد شود، پیغام خطای تولیدشده صحیح نمیباشد.