Libraries & Ecosystem · کتابخانهها و اکوسیستم متوسطIntermediate ~45 دقیقه مطالعه~39 min read
تست: JUnit 5، Mockito، AssertJ و TestcontainersTesting: JUnit 5, Mockito, AssertJ, Testcontainers
از هرم تست و چرخهٔ حیات JUnit 5 تا ماک/اسپای/استاب در Mockito، ادعاهای روان AssertJ و تستهای یکپارچگی واقعی با Testcontainers را طوری یاد میگیری که تستهایت هم سریع باشند هم واقعاً باگ بگیرند.From the test pyramid and JUnit 5 lifecycle to Mockito mock/spy/stub, AssertJ fluent assertions, and real integration tests with Testcontainers — you will learn to write tests that are fast and actually catch bugs.
سلام. بیا رک شروع کنیم: خیلی از ما تست را مثل «مالیات» میبینیم؛ چیزی که باید بدهیم تا کسی غر نزند. کد را مینویسیم، بعد چند تا assertEquals سرِ هم میکنیم که «پوشش» بالا برود، و رد میشویم. بعد شش ماه بعد، یک باگ در پروداکشن میترکد که ۹۰٪ کدش تست داشت. چطور؟ چون تست داشتن با تستِ خوب داشتن دو چیز کاملاً متفاوت است.
این فصل میخواهد نگاهت را عوض کند. تست، شبکهٔ ایمنی توست؛ همان چیزی که به تو اجازه میدهد جمعهٔ ساعت پنج یک ریفکتور بزرگ بزنی و با خیال راحت بروی خانه، چون اگر چیزی بشکند، یک چراغ قرمز قبل از مشتری به تو میگوید. ما از صفر شروع میکنیم — هرم تست چیست، چرا سریع بودن مهم است — و قدمبهقدم میرسیم به ابزارهای مدرن اکوسیستم جاوا: JUnit 5 برای اجرای تست، Mockito برای جدا کردن واحدها، AssertJ برای ادعاهای خوانا، و Testcontainers برای تست روی دیتابیس و Kafkaِ واقعی داخل داکر.
۱) هرم تست و اینکه چرا اکثر تستهایت باید واحد (unit) و سریع باشند. ۲) JUnit 5: معماری سهتکهای، چرخهٔ حیات، انوتیشنها، تست پارامتری، تستهای تودرتو. ۳) ادعاها (assertions): از assertEquals تا assertThrows و assertAll. ۴) Mockito: تفاوت mock/spy/stub، when()، verify()، ArgumentCaptor و دامهای ماککردن. ۵) AssertJ: ادعاهای روان و زنجیرهای که خطای خواناتر میدهند. ۶) Testcontainers: تست یکپارچگی روی Postgres و Kafkaِ واقعی. ۷) تستهای نامنظم (flaky)، پوشش در برابر تست معنادار، و مبانی TDD.
چرا اصلاً تست خودکار؟
بیا با یک تصویر شروع کنیم. فرض کن آشپز یک رستوران هستی. هر بار که یک غذای جدید به منو اضافه میکنی، باید مطمئن شوی که آشپزخانه هنوز درست کار میکند: اجاق روشن میشود، یخچال سرد است، و غذاهای قدیمی هنوز همانطور که باید در میآیند. اگر بخواهی هر بار همهٔ اینها را دستی امتحان کنی، دیوانه میشوی و باز هم یک چیزی از قلم میافتد.
تست خودکار یعنی یک دستیارِ خستگیناپذیر که هر بار قبل از باز کردنِ درِ رستوران، در چند ثانیه همهٔ اینها را چک میکند و میگوید «همهچیز سرِ جایش است» یا «صبر کن، سسِ شمارهٔ ۳ خراب شده». در نرمافزار، این دستیار به تو اجازه میدهد بدون ترس تغییر بدهی. بدون تست، هر تغییری یک قمار است.
آدمهای تازهکار فکر میکنند ترمز برای این است که ماشین کند برود. ولی ترمز دقیقاً همان چیزی است که به تو اجازه میدهد سریع برانی. چون میدانی هر لحظه میتوانی بایستی. تستِ خوب هم سرعتِ تیم را زیاد میکند، نه کم — چون به همه اجازه میدهد با اطمینان و سریع تغییر بدهند.
هرم تست: همهٔ تستها برابر نیستند
حالا یک سؤال مهم: چه نوع تستی، چقدر بنویسیم؟ اینجاست که مفهوم هرم تست (Test Pyramid) — که مایک کوهن معرفی کرد — به کار میآید. هرم سه طبقه دارد:
- پایین (پهن): تستهای واحد (unit tests). یک کلاس یا متد را بهتنهایی و جدا از بقیهٔ دنیا تست میکنند. بسیار سریع (میلیثانیه)، بدون دیتابیس، بدون شبکه. باید اکثریت تستهایت اینجا باشند.
- وسط: تستهای یکپارچگی (integration tests). چند تکه با هم کار میکنند — مثلاً کد تو با یک دیتابیس واقعی. کندتر (دهها یا صدها میلیثانیه)، تعدادشان کمتر.
- بالا (باریک): تستهای سرتاسری (end-to-end). کل سیستم از دید کاربر. بسیار کند و شکننده، پس کم و فقط برای مسیرهای حیاتی.
چرا شکلِ هرم؟ چون تستهای واحد ارزان، سریع و دقیقاند: وقتی میشکنند، دقیقاً میدانی کجا را نگاه کنی. تستهای سرتاسری گران، کند و مبهماند: وقتی قرمز میشوند، ممکن است هرجایی خراب باشد.
اگر تیمی بیشترِ تستهایش سرتاسری و دستی باشد و تعداد کمی تست واحد داشته باشد، هرم را وارونه کرده و یک بستنی قیفی (ice-cream cone) ساخته است. نتیجه: سوییتِ تست کُند، نامنظم و بیاعتماد که هیچکس دوستش ندارد و همه دورش میزنند. اگر اجرای تستهایت ده دقیقه طول میکشد، توسعهدهندهها اجرایشان نمیکنند — و تستی که اجرا نشود، ارزشش صفر است.
| نوع تست | سرعت | حوزه | تعداد | وقتی میشکند |
|---|---|---|---|---|
| واحد (unit) | میلیثانیه | یک کلاس، جدا | خیلی زیاد | دقیقاً میدانی کجا |
| یکپارچگی (integration) | دهها ms | چند جزء + دیتابیس/صف | متوسط | یک منطقهٔ محدود |
| سرتاسری (e2e) | ثانیهها | کل سیستم | کم | یکجایی، نامعلوم |
JUnit 5: موتور اجرای تست
JUnit استانداردِ عملیِ اجرای تست در جاوا است. نسخهٔ ۵ (که در سال ۲۰۱۷ آمد) یک بازنویسی کامل بود و برخلاف چیزی که از اسمش برمیآید، «یک کتابخانه» نیست، بلکه از سه بخش ساخته شده:
۱) JUnit Platform — پایهای که تستها را روی JVM اجرا میکند و ابزارها (IDE، Maven، Gradle) با آن حرف میزنند. ۲) JUnit Jupiter — مدل برنامهنویسی و انوتیشنهای جدید (@Test، @BeforeEach، ...) که تو با آنها مینویسی. ۳) JUnit Vintage — یک موتور سازگاری که اجازه میدهد تستهای قدیمی JUnit 3/4 هم روی همین پلتفرم اجرا شوند. وقتی میگویند «JUnit 5»، معمولاً منظورشان نوشتن با Jupiter است.
پرکاربردترین خط JUnit 5 امروز نسخهٔ 5.13.x است. از سال ۲۰۲۵ خانوادهٔ بعدی یعنی JUnit 6 هم منتشر شده (آخرین پایدار حدود 6.1.2، جولای ۲۰۲۶) که حداقل جاوا ۱۷ را میخواهد و همان مدل برنامهنویسی Jupiter را با بهبودهایی ادامه میدهد. چیزی که در این فصل یاد میگیری تقریباً بیکموکاست در JUnit 6 هم صادق است؛ فقط شمارهٔ نسخه عوض میشود.
اولین تست
بیا یک کلاس ساده را تست کنیم:
public class Calculator {
public int add(int a, int b) {
return a + b;
}
public int divide(int a, int b) {
if (b == 0) {
throw new IllegalArgumentException("division by zero");
}
return a / b;
}
}
و تستش با JUnit Jupiter:
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class CalculatorTest {
private final Calculator calc = new Calculator();
@Test
@DisplayName("۲ بهعلاوهٔ ۳ باید ۵ شود")
void addsTwoNumbers() {
int result = calc.add(2, 3);
assertEquals(5, result);
}
@Test
void throwsOnDivideByZero() {
assertThrows(IllegalArgumentException.class, () -> calc.divide(10, 0));
}
}
چند نکته: متدهای تست public لازم نیستند (در JUnit 5 حتی package-private کافی است)، @Test از پکیج org.junit.jupiter.api میآید (نه org.junit قدیمی)، و @DisplayName اجازه میدهد اسم تست را به زبان انسان — حتی فارسی — بنویسی که در گزارشها خوانا باشد.
چرخهٔ حیات: قبل و بعدِ هر تست
اغلب قبل از هر تست باید یک چیزی را آماده کنی و بعدش تمیز کنی. JUnit 5 چهار قلاب (hook) به تو میدهد:
import org.junit.jupiter.api.*;
class LifecycleTest {
@BeforeAll
static void initAll() {
// یکبار قبل از همهٔ تستهای این کلاس (باید static باشد)
System.out.println("راهاندازی سنگین یکباره، مثل باز کردن یک اتصال");
}
@BeforeEach
void init() {
// قبل از هر تست جداگانه اجرا میشود — اینجا وضعیتِ تازه بساز
System.out.println("آمادهسازی تازه برای این تست");
}
@Test
void testOne() { /* ... */ }
@Test
void testTwo() { /* ... */ }
@AfterEach
void tearDown() {
// بعد از هر تست — منابعِ آن تست را آزاد کن
}
@AfterAll
static void tearDownAll() {
// یکبار بعد از همه
}
}
JUnit بهطور پیشفرض برای هر متد تست، یک نمونهٔ تازه از کلاس تست میسازد (چرخهٔ حیات PER_METHOD). این عمدی است: تستها نباید به هم وابسته باشند. اگر تست A یک لیستِ مشترک را پر کند و تست B انتظار داشته باشد خالی باشد، به هم آلوده شدهاند و ترتیب اجرا رفتارشان را عوض میکند — سرمنشأ کلاسیک تستهای نامنظم. @BeforeEach تضمین میکند هر تست از یک نقطهٔ صفرِ تمیز شروع شود. برای همین هم @BeforeAll/@AfterAll باید static باشند: چون قبل از ساختِ هر نمونه اجرا میشوند.
اگر واقعاً بخواهی یک نمونهٔ مشترک برای همهٔ تستهای کلاس داشته باشی (مثلاً راهاندازی سنگین)، میتوانی با @TestInstance(TestInstance.Lifecycle.PER_CLASS) این رفتار را عوض کنی؛ آنوقت @BeforeAll لازم نیست static باشد. ولی این را با احتیاط استفاده کن، چون درِ آلودگیِ حالت را باز میکند.
انوتیشنهای پرکاربرد
@Disabled("reason")— تست را موقتاً غیرفعال میکند (بهجای کامنتکردن یا پاککردن).@Tag("slow")— برچسب میزند تا بتوانی گروهی از تستها را فیلتر و جدا اجرا کنی.@Nested— کلاسهای تست تودرفو برای گروهبندی سناریوها (پایینتر میبینیم).@RepeatedTest(10)— یک تست را ده بار اجرا میکند (برای شکار تستهای نامنظم عالی است).@Timeout(2)— اگر تست بیش از ۲ ثانیه طول کشید، شکست بده.
اگر با JUnit 4 کار کردهای، چند اسم عوض شده: @Before → @BeforeEach، @After → @AfterEach، @BeforeClass → @BeforeAll، @AfterClass → @AfterAll، @Ignore → @Disabled، و @RunWith/@Rule جای خود را به مدل انعطافپذیرِ @ExtendWith و اکستنشنها دادهاند. دیگر expected و timeout داخل @Test نداریم؛ بهجایش assertThrows و @Timeout هست.
ادعاها (Assertions): چطور بگوییم «درست» یعنی چه
قلبِ هر تست یک ادعا است: جملهای که میگوید «انتظار دارم اینطور باشد». اگر ادعا برقرار نباشد، تست شکست میخورد. JUnit در کلاس Assertions مجموعهای پایه دارد:
import static org.junit.jupiter.api.Assertions.*;
@Test
void coreAssertions() {
assertEquals(4, 2 + 2); // برابری
assertNotEquals(5, 2 + 2);
assertTrue(3 > 2, "سه باید از دو بزرگتر باشد"); // پیام اختیاری
assertNull(null);
assertNotNull("hello");
assertSame(Boolean.TRUE, Boolean.TRUE); // همان شیء (==)
assertArrayEquals(new int[]{1, 2}, new int[]{1, 2});
}
assertThrows: تست کردنِ خطاها
یکی از مهمترین کارها این است که مطمئن شوی کد در شرایط بد، خطای درست پرتاب میکند:
@Test
void divideByZeroThrows() {
IllegalArgumentException ex = assertThrows(
IllegalArgumentException.class,
() -> calc.divide(10, 0)
);
assertEquals("division by zero", ex.getMessage());
}
assertThrows استثنای پرتابشده را برمیگرداند تا بتوانی پیام یا فیلدهایش را هم بررسی کنی.
assertAll: نگذار اولین شکست بقیه را پنهان کند
مشکل ادعاهای معمولی این است که بهمحضِ اولین شکست، تست میایستد و بقیهٔ ادعاها را نمیبینی. assertAll همهٔ ادعاها را اجرا میکند و همهٔ شکستها را با هم گزارش میدهد:
@Test
void personHasRightFields() {
Person p = new Person("Ali", 30, "Tehran");
assertAll("person",
() -> assertEquals("Ali", p.name()),
() -> assertEquals(30, p.age()),
() -> assertEquals("Tehran", p.city())
);
}
assertAll عالی است ولی بهانه نیست که ده چیز نامربوط را در یک تست بریزی. یک قانون سادهٔ خوب: هر تست باید یک رفتار را بررسی کند و اسمش باید همان رفتار را توضیح دهد (throwsOnDivideByZero، نه test3). وقتی تستی میشکند، باید فقط از روی اسمش بفهمی چه چیزی خراب شده، بیآنکه بدنش را باز کنی.
تستهای پارامتری: یک تست، چند ورودی
خیلی وقتها میخواهی یک منطق را با ورودیهای مختلف بسنجی. کپیکردن تست برای هر ورودی احمقانه است. @ParameterizedTest این را حل میکند:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.stream.Stream;
import org.junit.jupiter.params.provider.Arguments;
import static org.junit.jupiter.params.provider.Arguments.arguments;
class ParameterizedExamples {
@ParameterizedTest
@ValueSource(ints = {2, 4, 6, 100})
void allEven(int number) {
assertTrue(number % 2 == 0);
}
@ParameterizedTest
@CsvSource({
"2, 3, 5",
"0, 0, 0",
"-1, 1, 0"
})
void addsCorrectly(int a, int b, int expected) {
assertEquals(expected, new Calculator().add(a, b));
}
@ParameterizedTest
@MethodSource("provideStrings")
void checksLength(String input, int expectedLength) {
assertEquals(expectedLength, input.length());
}
static Stream<Arguments> provideStrings() {
return Stream.of(
arguments("hi", 2),
arguments("hello", 5)
);
}
}
منابع دیگری هم هست: @EnumSource برای پیمایش مقادیر یک enum، @NullSource و @EmptySource برای مرزها، و @CsvFileSource برای خواندن از فایل. تست پارامتری هم کد را کوتاه میکند و هم تو را وسوسه میکند که حالتهای مرزی بیشتری را پوشش دهی.
تستهای تودرتو با @Nested
وقتی رفتار یک کلاس در شرایط مختلف فرق میکند، @Nested اجازه میدهد سناریوها را مثل پوشهبندی سازمان بدهی:
class BankAccountTest {
BankAccount account;
@BeforeEach
void setUp() { account = new BankAccount(100); }
@Nested
@DisplayName("وقتی موجودی کافی است")
class WhenSufficientBalance {
@Test
void withdrawSucceeds() {
account.withdraw(50);
assertEquals(50, account.balance());
}
}
@Nested
@DisplayName("وقتی موجودی کافی نیست")
class WhenInsufficientBalance {
@Test
void withdrawThrows() {
assertThrows(InsufficientFundsException.class,
() -> account.withdraw(200));
}
}
}
خروجی در IDE بهصورت درختی نمایش داده میشود و خواندنِ گزارش شکست را خیلی راحت میکند.
Mockito: جدا کردنِ واحد از دنیا
حالا یک مشکل واقعی. فرض کن یک OrderService داری که برای ثبت سفارش باید به یک PaymentGateway (که با یک سرور بانک حرف میزند) و یک InventoryRepository (که به دیتابیس وصل است) وصل شود. اگر بخواهی این سرویس را «واحد» تست کنی، نمیخواهی واقعاً به بانک زنگ بزنی یا دیتابیس بالا بیاوری. میخواهی این همسایهها را با بدلهای کنترلشده جایگزین کنی. اینجاست که Mockito وارد میشود.
اول بیا واژهها را از صفر بسازیم، چون همه اینها را قاتی میکنند:
اینها انواعِ دابلِ تست (test double) هستند؛ یعنی بدلهایی که جای وابستگیهای واقعی مینشینند.
- Stub (استاب): یک بدل که فقط جوابِ آماده میدهد. «هر وقت
findUser(5)را صدا زدی، این کاربر را برگردان.» به تعامل کاری ندارد، فقط داده تغذیه میکند. - Mock (ماک): یک بدل که علاوه بر جوابدادن، رفتار را هم زیر نظر دارد و بعداً میتوانی ازش بپرسی «آیا فلان متد صدا زده شد؟». ماک برای بررسیِ تعامل است.
- Spy (اسپای): یک روکش روی شیء واقعی. متدها بهطور پیشفرض واقعی اجرا میشوند، ولی میتوانی بعضیشان را جدا (stub) کنی و همچنان فراخوانیها را زیر نظر بگیری.
در Mockito، خودِ آبجکتی که
mock()میسازد میتواند هم نقش استاب را بازی کند (باwhen) هم نقش ماک را (باverify) — برای همین مردم اسمها را قاتی میکنند.
راهاندازی با JUnit 5
Mockito 5 (آخرین نسخه حدود 5.23) یک اکستنشن برای JUnit 5 دارد:
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
PaymentGateway paymentGateway; // یک بدلِ خودکار
@Mock
InventoryRepository inventory;
@InjectMocks
OrderService orderService; // ماکهای بالا داخلش تزریق میشوند
@Test
void placesOrderWhenPaymentSucceeds() {
// ترتیبدادن (arrange): به ماکها یاد بده چه بگویند
when(inventory.isAvailable("book-1", 2)).thenReturn(true);
when(paymentGateway.charge(anyString(), eq(20.0))).thenReturn(PaymentResult.ok());
// عمل (act)
OrderResult result = orderService.place(new OrderRequest("book-1", 2, "user-9"));
// ادعا (assert)
assertTrue(result.isSuccessful());
// بررسی تعامل: مطمئن شو موجودی واقعاً کم شد
verify(inventory).decrease("book-1", 2);
}
}
@Mock یک بدلِ خالی میسازد، @InjectMocks نمونهٔ واقعیِ کلاسِ تحتِ تست را میسازد و ماکها را از طریق سازنده یا فیلد داخلش میگذارد، و MockitoExtension همهٔ اینها را قبل از هر تست سیمکشی میکند.
when() و thenReturn(): برنامهریزیِ استاب
الگوی when(...).thenReturn(...) میگوید «وقتی این متد با این آرگومانها صدا زده شد، این را برگردان». حالتهای دیگر:
// برگرداندن مقدار
when(repo.findById(1L)).thenReturn(Optional.of(user));
// پرتاب استثنا
when(repo.findById(99L)).thenThrow(new EntityNotFoundException());
// جوابهای متوالی: بار اول این، بار دوم آن
when(repo.count()).thenReturn(1L, 2L, 3L);
// جواب پویا بر اساس ورودی
when(repo.save(any(User.class))).thenAnswer(inv -> {
User u = inv.getArgument(0);
return new User(42L, u.name()); // انگار دیتابیس id داده
});
when(mock.doThing()).thenReturn(...) وقتی کار میکند که متد یک مقدار برگرداند. برای متدهای void یا وقتی روی یک اسپای کار میکنی، این نحو خطرناک است، چون Mockito اول متد واقعی را صدا میزند تا آرگومان را ارزیابی کند. بهجایش از doReturn/doThrow/doNothing استفاده کن:
doThrow(new RuntimeException()).when(mock).voidMethod();
doReturn(realValue).when(spy).someMethod(); // امن روی اسپای
قانون سرانگشتی: روی اسپایها همیشه doReturn().when() بنویس، نه when().thenReturn().
verify(): آیا تعامل درست اتفاق افتاد؟
بعضی رفتارها خروجی ندارند؛ فقط باید مطمئن شوی که یک کار انجام شد (مثل ارسال ایمیل یا نوشتن در لاگ). اینجا از verify استفاده میکنی:
verify(emailService).send(eq("user@x.com"), anyString()); // دقیقاً یک بار
verify(emailService, times(2)).send(any(), any()); // دقیقاً دو بار
verify(emailService, never()).send(eq("blocked@x.com"), any()); // هرگز
verify(emailService, atLeastOnce()).send(any(), any());
verify(auditLog, timeout(1000)).record(any()); // در بازهٔ زمانی (برای async)
// مطمئن شو هیچ تعامل ناخواستهٔ دیگری نبوده
verifyNoMoreInteractions(emailService);
ArgumentCaptor: چه چیزی دقیقاً پاس داده شد؟
گاهی نمیخواهی فقط بدانی متدی صدا زده شد، بلکه میخواهی آرگومانِ واقعیِ پاسشده را بررسی کنی. ArgumentCaptor آن آرگومان را «گیر میاندازد» تا رویش ادعا بزنی:
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
@Captor
ArgumentCaptor<EmailMessage> emailCaptor;
@Test
void sendsWelcomeEmailWithRightBody() {
userService.register(new SignupRequest("Sara", "sara@x.com"));
verify(emailService).send(emailCaptor.capture());
EmailMessage sent = emailCaptor.getValue();
assertEquals("sara@x.com", sent.to());
assertTrue(sent.body().contains("Welcome, Sara"));
}
یک اشتباه رایج: از ArgumentCaptor برای منطبقکردنِ یک استاب استفاده کردن. Captor برای بعد از فراخوانی است تا ببینی چه چیزی رد شد. اگر میخواهی پیش از فراخوانی رفتار را شرطی کنی، از matcherها مثل eq(), any(), argThat(...) استفاده کن. یادت باشد اگر برای یک آرگومان matcher بهکار بردی، باید برای همهٔ آرگومانهای آن فراخوانی matcher بگذاری — نمیشود charge("id", eq(20.0)) نوشت؛ باید charge(eq("id"), eq(20.0)) باشد.
strictness: چرا Mockito از استابِ بیمصرف شکایت میکند
MockitoExtension بهطور پیشفرض حالت strict stubs را روشن میکند. یعنی اگر یک when(...) بنویسی که هیچوقت در تست استفاده نمیشود، Mockito با UnnecessaryStubbingException تست را میشکند. این آزاردهنده به نظر میرسد ولی یک هدیه است: استابِ بیمصرف معمولاً نشانهٔ این است که تست چیزی را که فکر میکنی نمیسنجد، یا کد عوض شده و تست بهروز نشده.
دامهای ماککردن
- ماککردن چیزی که نباید: به کلاسهای ارزشی (value object) مثل
String، لیستها، یا DTOها ماک نزن — نمونهٔ واقعیشان را بساز. ماک را برای مرزها (دیتابیس، شبکه، سرویس خارجی) نگه دار. - ماککردن نوعی که مالکش نیستی: ماکزدن به کتابخانهٔ شخص ثالث خطرناک است، چون فرضِ تو دربارهٔ رفتارش ممکن است غلط باشد و ماک آن غلط را تأیید میکند. بهجایش یک آداپتور نازکِ خودت بساز و آن را تست کن.
- بیشازحد ماککردن: اگر یک تست ده تا
whenو پنج تاverifyدارد، احتمالاً کلاسِ تحتِ تست مسئولیت زیادی دارد. تست به تو داد میزند که طراحی را ساده کن. - تست پیادهسازی بهجای رفتار: اگر هر ریفکتورِ کوچک، انبوهی از تست را میشکند بیآنکه رفتار عوض شده باشد، داری جزئیاتِ داخلی را تست میکنی نه قراردادِ بیرونی. تست باید به «چه» کار کند بند باشد، نه «چطور».
- ماککردن static/final بیدلیل: Mockito 5 بهطور پیشفرض با موتورِ inline (بر پایهٔ Byte Buddy) میآید و میتواند کلاسهای final و متدهای static را با
mockStatic(...)ماک کند. ولی نیازِ مکرر به این کار معمولاً نشانهٔ طراحیِ سفت و سخت است؛ اول ببین میشود وابستگی را تزریق کرد.
AssertJ: ادعاهایی که مثل جمله خوانده میشوند
ادعاهای خودِ JUnit کار میکنند، ولی دو ضعف دارند. اول، ترتیبشان گیجکننده است: در assertEquals(expected, actual) کدام اول است؟ همه یکبار اشتباه میکنند. دوم، برای چیزهای پیچیده (لیستها، فیلدهای تودرتو) زشت و کمگویا میشوند. AssertJ (آخرین نسخه 3.27.x) یک کتابخانهٔ ادعای روان (fluent) است که هر دو را حل میکند: با assertThat(...) شروع میکنی و بعد ادعاها را زنجیر میکنی.
import static org.assertj.core.api.Assertions.*;
@Test
void assertjBasics() {
String name = "Testcontainers";
assertThat(name)
.isNotNull()
.startsWith("Test")
.endsWith("ers")
.hasSize(15)
.isEqualToIgnoringCase("testcontainers");
assertThat(2 + 2).isEqualTo(4).isPositive().isEven();
}
چون همهچیز بعد از assertThat(actual) میآید، ترتیب دیگر مبهم نیست، و IDE بعد از نقطه به تو ادعاهای مناسبِ همان نوع را پیشنهاد میدهد.
قدرت واقعی: کالکشنها و آبجکتها
List<String> fruits = List.of("apple", "banana", "cherry");
assertThat(fruits)
.hasSize(3)
.contains("banana")
.doesNotContain("mango")
.containsExactly("apple", "banana", "cherry") // با همین ترتیب
.allMatch(f -> f.length() > 4);
// استخراج یک فیلد از هر آبجکت و ادعا رویش
List<Person> people = List.of(new Person("Ali", 30), new Person("Sara", 25));
assertThat(people)
.extracting(Person::name)
.containsExactlyInAnyOrder("Sara", "Ali");
// مقایسهٔ کاملِ دو آبجکت بدون نیاز به equals سفارشی
assertThat(actualPerson)
.usingRecursiveComparison()
.isEqualTo(expectedPerson);
تست استثنا با AssertJ
assertThatThrownBy(() -> calc.divide(1, 0))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("zero");
// یا شکل خواناترِ دیگر
assertThatExceptionOfType(IllegalArgumentException.class)
.isThrownBy(() -> calc.divide(1, 0))
.withMessage("division by zero");
حتی کتابخانههای تست هم از باگ امنیتی مصون نیستند. در نسخهٔ 3.27.7 (اوایل ۲۰۲۶)، AssertJ یک آسیبپذیریِ XXE (تزریق موجودیت خارجیِ XML) در ادعای isXmlEqualTo را وصله کرد (CVE-2026-24400). درسش این است: کتابخانهها را بهروز نگه دار — حتی وابستگیهای «فقط تست» — و پارسکردنِ XML همیشه باید موجودیتهای خارجی را غیرفعال کند. ضمناً نسخهٔ بزرگِ بعدی یعنی AssertJ 4.0 در راه است و بدهیِ فنیِ یک دهه را پاکسازی میکند.
خیلی از تیمها اجرای تست را به JUnit 5 میسپارند ولی برای ادعاها AssertJ را ترجیح میدهند، چون خطاهای شکستش خیلی گویاترند. مثلاً وقتی containsExactly میشکند، AssertJ دقیقاً میگوید کدام عنصر جا افتاده یا اضافه است — چیزی که با assertEquals(list1, list2) باید خودت چشمی پیدا کنی. اینها رقیب هم نیستند؛ مکملاند.
Testcontainers: تستِ یکپارچگی روی زیرساختِ واقعی
تا اینجا واحدها را جدا کردیم. ولی بعضی چیزها را فقط با چیزِ واقعی میشود درست تست کرد: آیا کوئری SQLِ من واقعاً روی Postgres کار میکند؟ آیا مایگریشنها اجرا میشوند؟ آیا مصرفکنندهٔ Kafkaِ من پیام را درست پردازش میکند؟
تا سالها مردم یا از دیتابیسهای درونحافظهای مثل H2 استفاده میکردند (که دقیقاً مثل Postgres رفتار نمیکند و باگهای واقعی را پنهان میکند)، یا فرض میکردند یک دیتابیس روی ماشین توسعهدهنده نصب است (شکننده و ناهماهنگ). Testcontainers این را حل کرد: یک کتابخانه که در طول تست، بهصورت برنامهنویسی کانتینرهای داکرِ واقعی را بالا میآورد و بعد از تست پاکشان میکند.
فرض کن قبل از هر بار پختِ یک دستور جدید، یک آشپزخانهٔ کامل و کاملاً تازه — با همان مارک اجاق و یخچالی که در رستوران واقعی داری — در چند ثانیه برایت سرِ هم میشد، تو تستت را میکردی، و بعد کل آشپزخانه دود میشد و میرفت. Testcontainers دقیقاً همین است: هر تست یک Postgres یا Kafkaِ واقعی و تازه میگیرد که با پروداکشن یکی است، و بعد نابود میشود. هیچ آلودگی بین تستها، هیچ «روی ماشین من کار میکرد».
یک تست یکپارچگیِ Postgres
import org.junit.jupiter.api.Test;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer; // پکیج جدید در Testcontainers 2.x
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
class UserRepositoryIT {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:17-alpine")
.withDatabaseName("shop")
.withUsername("test")
.withPassword("test");
@Test
void savesAndLoadsUser() {
// آدرس اتصال را کانتینر خودش میدهد (پورت تصادفی)
String jdbcUrl = postgres.getJdbcUrl();
var repo = new UserRepository(jdbcUrl, postgres.getUsername(), postgres.getPassword());
repo.save(new User("Ali", "ali@x.com"));
assertThat(repo.findByEmail("ali@x.com"))
.isPresent()
.get()
.extracting(User::name)
.isEqualTo("Ali");
}
}
نکتههای کلیدی: @Testcontainers چرخهٔ حیات کانتینرها را به JUnit وصل میکند، @Container میگوید این فیلد یک کانتینر است. اگر static باشد، یکبار برای کل کلاس بالا میآید (سریعتر)؛ اگر نمونهای باشد، برای هر تست از نو (منزویتر). و getMappedPort/getJdbcUrl آدرس واقعی را میدهند، چون Testcontainers پورتها را به یک پورتِ تصادفیِ آزاد روی هاست نگاشت میکند تا تستهای موازی به هم نخورند.
نسخهٔ بزرگِ Testcontainers 2.0 (۲۰۲۵/۲۰۲۶، آخرین حدود 2.0.5) یک تغییرِ شکنندهٔ مهم داشت: آرتیفکتهای ماژولها با پیشوندِ testcontainers- استاندارد شدند (مثلاً org.testcontainers:mysql شد org.testcontainers:testcontainers-mysql) و کلاسهای کانتینر به پکیجهای org.testcontainers.<module> جابهجا شدند (مثلاً PostgreSQLContainer حالا زیر org.testcontainers.postgresql). اگر مستند قدیمیتری میبینی که org.testcontainers.containers.PostgreSQLContainer را import میکند، آن مربوط به خط 1.x است.
تست یک مصرفکنندهٔ Kafka
import org.testcontainers.kafka.KafkaContainer;
@Testcontainers
class OrderEventsIT {
@Container
static KafkaContainer kafka = new KafkaContainer("apache/kafka:latest");
@Test
void consumesOrderPlacedEvent() {
String bootstrap = kafka.getBootstrapServers();
var producer = buildProducer(bootstrap);
var consumer = buildConsumer(bootstrap, "orders");
producer.send(new ProducerRecord<>("orders", "order-1", "{\"amount\":50}"));
var records = consumer.poll(Duration.ofSeconds(5));
assertThat(records.count()).isEqualTo(1);
}
}
همین الگو برای Redis، RabbitMQ، Elasticsearch، MongoDB و حتی هر ایمیجِ دلخواه با GenericContainer کار میکند.
Wait strategy: صبر کن تا واقعاً آماده شود
یک دامِ ظریف: کانتینر «شروع» میشود ولی سرویسِ داخلش هنوز آمادهٔ اتصال نیست. Testcontainers استراتژیهای انتظار دارد تا قبل از اجرای تست مطمئن شود سرویس بالا آمده:
GenericContainer<?> app = new GenericContainer<>("my-api:latest")
.withExposedPorts(8080)
.waitingFor(Wait.forHttp("/health").forStatusCode(200));
ماژولهای آماده (مثل PostgreSQLContainer) استراتژی انتظارِ درست را از پیش تنظیم دارند؛ برای GenericContainer خودت باید مشخصش کنی.
اگر با Spring Boot 3.1 یا بالاتر کار میکنی، انوتیشن @ServiceConnection روی یک بین کانتینر، بهطور خودکار spring.datasource.url و بقیهٔ تنظیمات را از روی کانتینر پر میکند — دیگر لازم نیست دستی @DynamicPropertySource بنویسی. این یکپارچگی تستهای Spring را چند خط کوتاهتر میکند و از خطای دستی جلوگیری میکند.
هر کانتینر یعنی راهاندازی داکر، pull ایمیج، و مصرف حافظه. اگر برای هر تستِ واحد یک Postgres بالا بیاوری، سوییتت کند و شکننده میشود و به همان بستنیِ قیفی میرسی. قاعده: تستهای واحد را با ماک سریع نگه دار؛ Testcontainers را برای تستهای یکپارچگی که واقعاً به دیتابیس/صف نیاز دارند بگذار، کانتینرها را static (اشتراکی در کلاس) کن، و برای اجرای محلیِ سریعتر ویژگیِ reuse را روشن کن. به داکر هم روی CI نیاز داری.
تستهای نامنظم (Flaky): دشمنِ خاموشِ اعتماد
یک تستِ نامنظم تستی است که گاهی سبز و گاهی قرمز میشود، بیآنکه کد عوض شده باشد. اینها از خودِ باگ خطرناکترند، چون اعتماد را از بین میبرند: وقتی تیم یاد بگیرد شکستها را با «دوباره اجرا کن» رد کند، دیگر هیچ شکستی را جدی نمیگیرد و یک باگ واقعی هم لای همان نویز گم میشود.
- وابستگی به زمان: استفاده از
Thread.sleepامیدوارانه، یاLocalDateTime.now()واقعی. بهجایش یکClockتزریق کن و در تست ثابتش کن. - ترتیب و حالتِ مشترک: تستهایی که به یک استاتیک یا دیتابیسِ مشترک تکیه دارند و به ترتیب اجرا حساساند. هر تست باید حالتِ خودش را بسازد و تمیز کند.
- همروندی و مسابقه (race): ادعا زدن قبل از تمامشدنِ کار async. از انتظارِ شرطی (مثل Awaitility یا
verify(..., timeout(...))) استفاده کن، نه sleep ثابت. - وابستگی به شبکه/منابع بیرونی: فراخوانیِ سرویسِ واقعی که ممکن است کند یا قطع باشد. با ماک یا Testcontainers جدا کن.
- ترتیبِ نامعیّن مجموعهها: ادعا روی ترتیبِ عناصرِ یک
HashSet. یا مرتب کن یا ازcontainsExactlyInAnyOrderاستفاده کن.
راهِ شکارشان: تست مشکوک را با @RepeatedTest(50) بارها اجرا کن تا خودش را لو بدهد، و بهمحضِ دیدن یک تستِ نامنظم آن را همانجا درست کن — نه اینکه با @Disabled قایمش کنی که همیشه بماند.
پوشش (Coverage) در برابر تستِ معنادار
پوشش کد یعنی چند درصد از خطوط (یا شاخهها) هنگام اجرای تستها اجرا شدند. ابزارِ رایجش در جاوا JaCoCo است. پوشش مفید است، ولی یک تله دارد.
میشود ۱۰۰٪ پوشش داشت و هیچ چیز را واقعاً تست نکرد: کافی است همهٔ متدها را صدا بزنی و هیچ assertی ننویسی. آنوقت هر خط «اجرا» شده ولی هیچ رفتاری بررسی نشده. این قانونِ گودهارت است: «وقتی یک معیار به هدف تبدیل شود، دیگر معیارِ خوبی نیست». پوشش را بهعنوان یک کاشفِ نقاطِ فراموششده ببین (کدام شاخه اصلاً اجرا نشده؟)، نه بهعنوان نمرهای که باید ماکزیمم شود. فرقِ بین «خطِ کد اجرا شد» و «رفتارِ درست تأیید شد» همهچیز است.
پوششِ شاخهای (branch coverage) از پوششِ خطی معنادارتر است، چون میگوید آیا هر دو سمتِ if را تست کردهای یا نه. و اگر بخواهی واقعاً کیفیت تست را بسنجی، به سراغِ تستِ جهش (mutation testing) با ابزاری مثل PIT برو: عمداً باگهای کوچک در کد میکارد و میبیند آیا تستهایت آنها را میگیرند — این خیلی از «درصدِ پوشش» صادقانهتر است.
TDD: تست اول، کد بعد
توسعهٔ آزمونمحور (TDD) یعنی تست را قبل از کد بنویسی. چرخهاش سهگام و کوتاه است:
۱) قرمز (Red): یک تستِ کوچک برای رفتاری که هنوز وجود ندارد بنویس. اجرا کن؛ شکست میخورد (چون هنوز کدی نیست). این یعنی تستت واقعاً چیزی را میسنجد. ۲) سبز (Green): سادهترین کدی که تست را سبز میکند بنویس — حتی اگر زشت باشد. ۳) ریفکتور (Refactor): حالا که شبکهٔ ایمنی سبز است، کد را تمیز کن با اطمینان از اینکه چیزی نمیشکند. بعد تکرار.
سود واقعیِ TDD فقط «تست داشتن» نیست؛ این است که تو را مجبور میکند اول از دیدِ مصرفکنندهٔ کد فکر کنی («این API چطور باید صدا زده شود؟») و طراحیِ آزمونپذیرتری بسازی. اگر نوشتن تست برای یک کلاس سخت است، معمولاً یعنی طراحیِ آن کلاس بد است — و TDD این را زود به تو میگوید.
هر تست را در سه بخش بنویس: Arrange (ترتیبدادن ورودیها و ماکها)، Act (اجرای همان یک کار)، Assert (ادعا روی نتیجه). این ساختار تست را خوانا میکند و کمک میکند هر تست فقط یک رفتار را بسنجد. نامِ تست را هم توصیفی بگذار: returnsEmptyWhenUserNotFound، نه test1.
پرسشهای مصاحبه
هر سه «دابلِ تست» هستند. Stub فقط جوابِ آماده میدهد و رفتار را زیر نظر نمیگیرد؛ برای تغذیهٔ داده. Mock علاوه بر جوابدادن، تعامل را ثبت میکند تا بعداً با verify بپرسی «آیا این متد صدا زده شد؟»؛ برای بررسیِ تعامل. Spy یک روکش روی شیء واقعی است که متدها پیشفرض واقعی اجرا میشوند مگر آنها را جدا کنی. در Mockito، شیءِ mock() میتواند نقشِ استاب (با when) و ماک (با verify) را همزمان بازی کند؛ برای همین این اسمها در گفتار قاتی میشوند. نکتهٔ عملی: روی اسپایها برای stub کردن از doReturn().when() استفاده کن، نه when().thenReturn()، چون دومی متد واقعی را صدا میزند.
از سه بخش: JUnit Platform (پایهای که تستها را روی JVM اجرا میکند و رابطِ ابزارها و IDEهاست)، JUnit Jupiter (مدل برنامهنویسی و انوتیشنهای جدید که تو با آن تست مینویسی)، و JUnit Vintage (موتور سازگاری برای اجرای تستهای قدیمیِ JUnit 3/4 روی همان پلتفرم). این جدایی عمدی است: Platform یک API استاندارد برای «موتورهای تست» تعریف میکند، طوری که حتی فریمورکهای دیگر (مثل Spock یا Cucumber) هم میتوانند روی همان پلتفرم اجرا شوند و ابزارها فقط با یک پایهٔ مشترک کار کنند.
برای انزوا (isolation). چرخهٔ حیات پیشفرض PER_METHOD است: قبل از هر متد تست، یک نمونهٔ کاملاً تازهٔ کلاس ساخته میشود تا فیلدهای نمونه بین تستها نشت نکنند. این جلوی وابستگیِ تستها به هم و حساسیت به ترتیبِ اجرا را میگیرد — که یکی از سرچشمههای اصلیِ تستهای نامنظم است. اگر با @TestInstance(PER_CLASS) این را عوض کنی، یک نمونهٔ مشترک برای همه ساخته میشود (و آنوقت @BeforeAll لازم نیست static باشد)، ولی باید مراقبِ آلودگیِ حالت باشی.
با assertThrows در JUnit یا assertThatThrownBy در AssertJ. هر دو یک لامبدا میگیرند که کدِ خطاساز را اجرا میکند و بررسی میکنند نوعِ استثنا درست است. مزیت اینها بر روشِ قدیمیِ @Test(expected=...) این است که استثنای پرتابشده را برمیگردانند تا بتوانی روی پیام و فیلدهایش هم ادعا بزنی، و دقیقاً مشخص میکنند کدام خط باید خطا بدهد — نه هر جای متد.
ArgumentCaptor آرگومانِ واقعیِ پاسشده به یک ماک را «گیر میاندازد» تا بعد از فراخوانی رویش ادعا بزنی — مفید وقتی میخواهی یک آبجکتِ پیچیده که کد ساخته و به وابستگی داده را بازرسی کنی (مثلاً بدنهٔ ایمیلی که ساخته شد). فرقش با matcher: matcher (مثل eq, any, argThat) برای پیش از فراخوانی است تا استاب را شرطی کنی یا verify را منطبق کنی. اگر فقط میخواهی چک کنی آرگومان شرطی را برآورده میکند، argThat تمیزتر است؛ Captor را وقتی بیاور که میخواهی مقدارِ دقیق را استخراج و روی چند فیلدش ادعا بزنی.
سه دلیل. اول خوانایی و ترتیب: assertThat(actual).isEqualTo(expected) ابهامِ «کدام آرگومان expected است» را از بین میبرد. دوم پیامهای شکستِ گویا: وقتی یک ادعای کالکشن میشکند، AssertJ دقیقاً میگوید کدام عنصر جا افتاده یا اضافه است، نه فقط «لیستها برابر نیستند». سوم قدرتِ زنجیرهای: میتوانی چند ادعا را روی یک شیء زنجیر کنی، فیلدها را با extracting بیرون بکشی، و دو آبجکت را با usingRecursiveComparison بدونِ نوشتنِ equals سفارشی مقایسه کنی. اینها رقیبِ JUnit نیستند؛ معمولاً JUnit را برای اجرا و AssertJ را برای ادعا با هم به کار میبرند.
Testcontainers در طول تست، کانتینرهای داکرِ واقعی (Postgres، Kafka، Redis، ...) را بالا میآورد و بعد پاک میکند، تا تستهای یکپارچگی روی همان زیرساختی اجرا شوند که در پروداکشن داری. مشکلِ دیتابیسهای درونحافظهای مثل H2 این است که رفتارشان با Postgres واقعی یکی نیست — نوعِ دادهها، توابع، رفتارِ تراکنش و SQLِ خاصِ فروشنده فرق دارد — پس تست ممکن است سبز شود ولی کوئری در پروداکشن بشکند. Testcontainers این فاصله را میبندد: «همان چیزی که تست کردی، همان چیزی است که اجرا میشود». هزینهاش نیاز به داکر و کندیِ نسبی است، پس برای تست یکپارچگی مناسب است، نه هر تست واحد.
اگر فیلدِ کانتینر را static بگذاری، یکبار برای کل کلاسِ تست بالا میآید و بین همهٔ تستهای آن کلاس به اشتراک گذاشته میشود — سریعتر، ولی باید مراقب باشی تستها دادههای هم را آلوده نکنند (بینشان پاکسازی کن). اگر نمونهای (non-static) باشد، برای هر تست از نو ساخته و نابود میشود — منزویتر ولی خیلی کندتر. در عمل معمولاً کانتینرها را static میگذارند و حالتِ داده را در @BeforeEach ریست میکنند، چون راهاندازیِ مکررِ کانتینر گران است.
تستی که بدون تغییرِ کد گاهی سبز و گاهی قرمز میشود. خطرش این است که اعتماد را نابود میکند: تیم یاد میگیرد شکستها را با «دوباره اجرا کن» رد کند و در نتیجه یک باگ واقعی هم لای نویز گم میشود. سرچشمههای رایج: وابستگی به زمان (sleep, now())، حالتِ مشترک و حساسیت به ترتیب، مسابقه در کدِ async، اتکا به شبکهٔ بیرونی، و ادعا روی ترتیبِ نامعیّنِ مجموعهها. رفع: Clock را تزریق و ثابت کن، هر تست حالتِ خودش را بسازد و تمیز کند، بهجای sleep از انتظارِ شرطی (Awaitility) استفاده کن، وابستگیها را با ماک/Testcontainers جدا کن، و از containsExactlyInAnyOrder برای مجموعهها بهره بگیر. تست مشکوک را با @RepeatedTest لو بده و همانجا درستش کن، نه اینکه غیرفعالش کنی.
نه. پوشش فقط میگوید کدام خطوط هنگام تست اجرا شدند، نه اینکه رفتارشان بررسی شد. میشود ۱۰۰٪ پوشش داشت بدون حتی یک assert — همهٔ متدها اجرا میشوند ولی هیچ چیز تأیید نمیشود. طبق قانونِ گودهارت، وقتی پوشش به هدف تبدیل شود دیگر معیار خوبی نیست و مردم تستهای بیادعا مینویسند تا عدد بالا برود. پوشش را بهعنوان کاشفِ نقاطِ فراموششده ببین (کدام شاخه اصلاً اجرا نشد؟). برای سنجشِ واقعیِ کیفیت، پوششِ شاخهای معنادارتر از خطی است، و تستِ جهش (mutation testing با PIT) صادقانهترین معیار است چون عمداً باگ میکارد تا ببیند تستهایت میگیرندشان.
TDD یعنی تست را قبل از کد بنویسی، در چرخهٔ قرمز-سبز-ریفکتور: یک تستِ شکستخورده بنویس (قرمز)، سادهترین کدی که سبزش کند بنویس (سبز)، سپس با اطمینانِ شبکهٔ ایمنی، کد را تمیز کن (ریفکتور). سودِ اصلیاش فقط پوشش نیست؛ این است که تو را وادار میکند اول از دیدِ مصرفکنندهٔ API فکر کنی و در نتیجه طراحیِ سادهتر و آزمونپذیرتری بسازی. اگر تستنوشتن برای یک کلاس سخت باشد، معمولاً نشانهٔ طراحیِ بد است (وابستگیِ زیاد، مسئولیتِ زیاد) — و TDD این را زود بازخورد میدهد، نه ماهها بعد.
با تزریقِ وابستگی به منبعِ آن نامعیّنی، بهجای فراخوانیِ مستقیمِ static. بهجای LocalDateTime.now()، یک java.time.Clock تزریق کن و در تست با Clock.fixed(...) ثابتش کن؛ حالا زمان قابلپیشبینی است. بهجای new Random()، یک Random با seed ثابت یا یک اینترفیسِ تولیدِ عدد تزریق کن. همین اصل برای UUID و منابع بیرونیِ دیگر صادق است. این کار هم تست را قطعی (deterministic) میکند و هم طراحی را تمیزتر — چون وابستگیهای پنهان را آشکار و قابلکنترل میکند.
چون MockitoExtension پیشفرض در حالتِ strict stubs است: هر when(...)ی که در تست هرگز استفاده نشود، یک استابِ بیمصرف است و Mockito تست را میشکند. نه، معمولاً نباید خاموشش کنی — این هشدار ارزشمند است. استابِ بیمصرف اغلب یعنی: تست چیزی را که فکر میکنی نمیسنجد، یا کد عوض شده و تست بهروز نشده، یا copy-pasteِ اضافی. بهجای خاموشکردنِ strictness، استابِ زائد را حذف کن. فقط در موارد خاص (مثلاً یک @BeforeEach که استابِ مشترکی میسازد که همهٔ تستها لازمش ندارند) میشود با lenient() یا Strictness.LENIENT موضعی از آن گذشت.
ماک را برای مرزهای سیستم نگه دار: دیتابیس، شبکه، صفِ پیام، سرویس خارجی، ساعت. اینها کُند، غیرقطعی یا خارج از کنترل تواند. برای چیزهای درونیِ سریع و قطعی — کلاسهای ارزشی (value object)، DTOها، کالکشنها، توابعِ خالص — نمونهٔ واقعی بساز، نه ماک. ماکزدن به نوعی که مالکش نیستی (کتابخانهٔ ثالث) هم خطرناک است، چون فرضِ غلطِ تو دربارهٔ رفتارش را «تأییدشده» جلوه میدهد؛ بهجایش یک آداپتورِ نازک بساز و آن مرز را با تست یکپارچگی بسنج. و اگر یک تست دهها ماک لازم دارد، این بوی طراحیِ بد است: کلاس مسئولیتِ زیادی دارد.
تستِ خوب شبکهٔ ایمنیای است که به تو اجازه میدهد سریع و بیترس تغییر بدهی. هرم تست میگوید اکثر تستهایت باید واحد و سریع باشند، یک لایه یکپارچگی، و اندکی سرتاسری — نه برعکسِ آن (بستنیِ قیفی). JUnit 5 با سهتکهاش (Platform/Jupiter/Vintage) موتور اجراست: چرخهٔ حیاتِ @BeforeEach/@AfterEach، انزوای هر تست، تستهای پارامتری و تودرتو، و ادعاهایی مثل assertThrows و assertAll. Mockito واحد را از دنیا جدا میکند؛ فرقِ mock/spy/stub را بدان، روی اسپایها doReturn().when() بزن، تعامل را با verify و آرگومان را با ArgumentCaptor بسنج، و از over-mock کردن و تستِ پیادهسازی بپرهیز. AssertJ ادعاها را خوانا و پیامهای شکست را گویا میکند. Testcontainers با کانتینرهای داکرِ واقعی تست یکپارچگی را با پروداکشن یکی میکند (و در 2.0 نامها عوض شدند). مراقبِ تستهای نامنظم باش، پوشش را کف بدان نه هدف، و با TDD اول از دیدِ مصرفکننده فکر کن. تستی که اجرا نشود یا به آن اعتماد نباشد، ارزشش صفر است — پس سریع، قطعی و معنادار نگهش دار.
Let's be honest up front: many of us treat testing like a tax — something we pay so nobody complains. We write the code, then bolt on a few assertEquals calls to push "coverage" up, and move on. Six months later a bug detonates in production in code that had 90% coverage. How? Because having tests and having good tests are two completely different things.
This chapter is here to change how you see it. A test suite is your safety net — the thing that lets you make a big refactor at 5pm on a Friday and go home calmly, because if something breaks, a red light tells you before the customer does. We start from zero — what the test pyramid is, why speed matters — and build up to the modern Java tooling: JUnit 5 to run tests, Mockito to isolate units, AssertJ for readable assertions, and Testcontainers to test against a real database and Kafka inside Docker.
- The test pyramid and why most of your tests should be fast unit tests. 2) JUnit 5: its three-part architecture, lifecycle, annotations, parameterized tests, nested tests. 3) Assertions: from
assertEqualstoassertThrowsandassertAll. 4) Mockito: mock vs spy vs stub,when(),verify(),ArgumentCaptor, and mocking pitfalls. 5) AssertJ: fluent, chainable assertions with better failure messages. 6) Testcontainers: integration tests against real Postgres and Kafka. 7) Flaky tests, coverage vs meaningful tests, and the basics of TDD.
Why automated tests at all?
Start with a picture. You're the chef of a restaurant. Every time you add a new dish to the menu, you need to be sure the kitchen still works: the stove lights, the fridge is cold, and the old dishes still come out right. If you had to verify all of that by hand every time, you'd go mad — and still miss something.
Automated tests are a tireless assistant who, before you open the doors each day, checks all of that in a few seconds and says "everything's in place" or "hold on, sauce #3 is off." In software, this assistant lets you change things without fear. Without tests, every change is a gamble.
Beginners think brakes are for going slow. But brakes are exactly what let you drive fast — because you know you can stop at any moment. Good tests speed a team up, not down, because they let everyone change things confidently and quickly.
The test pyramid: not all tests are equal
Now the important question: what kind of test, and how many of each? This is where the Test Pyramid (popularized by Mike Cohn) comes in. It has three tiers:
- Bottom (wide): unit tests. They test one class or method in isolation from the rest of the world. Very fast (milliseconds), no database, no network. The majority of your tests belong here.
- Middle: integration tests. Several pieces working together — e.g. your code against a real database. Slower (tens/hundreds of ms), fewer of them.
- Top (narrow): end-to-end tests. The whole system from the user's point of view. Very slow and brittle, so keep them few and only for critical paths.
Why a pyramid shape? Because unit tests are cheap, fast, and precise: when they break, you know exactly where to look. End-to-end tests are expensive, slow, and vague: when they go red, anything could be broken.
If a team has mostly end-to-end and manual tests and only a handful of unit tests, they've inverted the pyramid into an ice-cream cone. The result is a slow, flaky, untrusted suite nobody likes and everyone routes around. If your tests take ten minutes, developers won't run them — and a test that isn't run has zero value.
| Test type | Speed | Scope | Count | When it breaks |
|---|---|---|---|---|
| Unit | milliseconds | one class, isolated | very many | you know exactly where |
| Integration | tens of ms | several parts + DB/queue | moderate | a bounded area |
| End-to-end | seconds | whole system | few | somewhere, unclear |
JUnit 5: the test-running engine
JUnit is the de-facto standard for running tests in Java. Version 5 (released in 2017) was a complete rewrite, and despite the name, it isn't "one library" — it's built from three parts:
- JUnit Platform — the foundation that runs tests on the JVM and that tools (IDEs, Maven, Gradle) talk to. 2) JUnit Jupiter — the new programming model and annotations (
@Test,@BeforeEach, ...) that you actually write with. 3) JUnit Vintage — a compatibility engine that lets old JUnit 3/4 tests run on the same platform. When people say "JUnit 5," they usually mean writing with Jupiter.
The most widely used JUnit 5 line today is 5.13.x. Since 2025 the next family, JUnit 6, has also shipped (latest stable around 6.1.2, July 2026), requiring at least Java 17 and continuing the same Jupiter programming model with improvements. Almost everything you learn here holds unchanged in JUnit 6 — only the version number changes.
Your first test
Let's test a simple class:
public class Calculator {
public int add(int a, int b) {
return a + b;
}
public int divide(int a, int b) {
if (b == 0) {
throw new IllegalArgumentException("division by zero");
}
return a / b;
}
}
And its test with JUnit Jupiter:
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class CalculatorTest {
private final Calculator calc = new Calculator();
@Test
@DisplayName("2 plus 3 should be 5")
void addsTwoNumbers() {
int result = calc.add(2, 3);
assertEquals(5, result);
}
@Test
void throwsOnDivideByZero() {
assertThrows(IllegalArgumentException.class, () -> calc.divide(10, 0));
}
}
A few notes: test methods don't need to be public (in JUnit 5 even package-private is fine), @Test comes from org.junit.jupiter.api (not the old org.junit), and @DisplayName lets you write the test name in plain human language for readable reports.
Lifecycle: before and after each test
Often you need to set something up before each test and clean up after. JUnit 5 gives you four hooks:
import org.junit.jupiter.api.*;
class LifecycleTest {
@BeforeAll
static void initAll() {
// once before all tests in this class (must be static)
System.out.println("expensive one-time setup, e.g. opening a connection");
}
@BeforeEach
void init() {
// runs before each individual test — build fresh state here
System.out.println("fresh setup for this test");
}
@Test
void testOne() { /* ... */ }
@Test
void testTwo() { /* ... */ }
@AfterEach
void tearDown() {
// after each test — release that test's resources
}
@AfterAll
static void tearDownAll() {
// once after all
}
}
By default JUnit creates a fresh instance of the test class for every test method (the PER_METHOD lifecycle). This is deliberate: tests must not depend on each other. If test A fills a shared list and test B expects it empty, they're contaminated and execution order changes their behavior — the classic source of flaky tests. @BeforeEach guarantees each test starts from a clean zero. That's also why @BeforeAll/@AfterAll must be static: they run before any instance is created.
If you really want one shared instance for the whole class (e.g. expensive setup), you can switch to @TestInstance(TestInstance.Lifecycle.PER_CLASS), after which @BeforeAll no longer needs to be static. Use this cautiously, since it opens the door to state leakage.
Commonly used annotations
@Disabled("reason")— temporarily disables a test (instead of commenting it out or deleting it).@Tag("slow")— labels a test so you can filter and run groups separately.@Nested— nested test classes to group scenarios (we'll see this below).@RepeatedTest(10)— runs a test ten times (great for hunting flaky tests).@Timeout(2)— fails the test if it runs longer than 2 seconds.
If you've used JUnit 4, a few names changed: @Before → @BeforeEach, @After → @AfterEach, @BeforeClass → @BeforeAll, @AfterClass → @AfterAll, @Ignore → @Disabled, and @RunWith/@Rule gave way to the flexible @ExtendWith extension model. There's no more expected or timeout inside @Test; use assertThrows and @Timeout instead.
Assertions: how we say what "correct" means
The heart of every test is an assertion: a statement that says "I expect this to be so." If it doesn't hold, the test fails. JUnit has a base set in the Assertions class:
import static org.junit.jupiter.api.Assertions.*;
@Test
void coreAssertions() {
assertEquals(4, 2 + 2); // equality
assertNotEquals(5, 2 + 2);
assertTrue(3 > 2, "three should be greater than two"); // optional message
assertNull(null);
assertNotNull("hello");
assertSame(Boolean.TRUE, Boolean.TRUE); // same object (==)
assertArrayEquals(new int[]{1, 2}, new int[]{1, 2});
}
assertThrows: testing errors
One of the most important things is to confirm the code throws the right error in bad conditions:
@Test
void divideByZeroThrows() {
IllegalArgumentException ex = assertThrows(
IllegalArgumentException.class,
() -> calc.divide(10, 0)
);
assertEquals("division by zero", ex.getMessage());
}
assertThrows returns the thrown exception so you can also assert on its message or fields.
assertAll: don't let the first failure hide the rest
The problem with plain assertions is that on the first failure, the test stops and you don't see the rest. assertAll runs all assertions and reports all failures together:
@Test
void personHasRightFields() {
Person p = new Person("Ali", 30, "Tehran");
assertAll("person",
() -> assertEquals("Ali", p.name()),
() -> assertEquals(30, p.age()),
() -> assertEquals("Tehran", p.city())
);
}
assertAll is great, but not an excuse to cram ten unrelated things into one test. A good rule: each test should verify one behavior, and its name should describe that behavior (throwsOnDivideByZero, not test3). When a test breaks, you should know what broke just from its name, without opening its body.
Parameterized tests: one test, many inputs
Often you want to exercise the same logic with different inputs. Copy-pasting a test per input is silly. @ParameterizedTest solves it:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.stream.Stream;
import org.junit.jupiter.params.provider.Arguments;
import static org.junit.jupiter.params.provider.Arguments.arguments;
class ParameterizedExamples {
@ParameterizedTest
@ValueSource(ints = {2, 4, 6, 100})
void allEven(int number) {
assertTrue(number % 2 == 0);
}
@ParameterizedTest
@CsvSource({
"2, 3, 5",
"0, 0, 0",
"-1, 1, 0"
})
void addsCorrectly(int a, int b, int expected) {
assertEquals(expected, new Calculator().add(a, b));
}
@ParameterizedTest
@MethodSource("provideStrings")
void checksLength(String input, int expectedLength) {
assertEquals(expectedLength, input.length());
}
static Stream<Arguments> provideStrings() {
return Stream.of(
arguments("hi", 2),
arguments("hello", 5)
);
}
}
There are more sources: @EnumSource to iterate an enum's values, @NullSource and @EmptySource for boundaries, and @CsvFileSource to read from a file. Parameterized tests both shorten the code and tempt you to cover more edge cases.
Nested tests with @Nested
When a class behaves differently under different conditions, @Nested lets you organize scenarios like folders:
class BankAccountTest {
BankAccount account;
@BeforeEach
void setUp() { account = new BankAccount(100); }
@Nested
@DisplayName("when balance is sufficient")
class WhenSufficientBalance {
@Test
void withdrawSucceeds() {
account.withdraw(50);
assertEquals(50, account.balance());
}
}
@Nested
@DisplayName("when balance is insufficient")
class WhenInsufficientBalance {
@Test
void withdrawThrows() {
assertThrows(InsufficientFundsException.class,
() -> account.withdraw(200));
}
}
}
The IDE renders the output as a tree, which makes reading a failure report much easier.
Mockito: isolating a unit from the world
Now a real problem. Suppose you have an OrderService that, to place an order, must call a PaymentGateway (which talks to a bank server) and an InventoryRepository (which hits the database). To test this service as a "unit," you don't want to actually call the bank or spin up a database. You want to replace those neighbors with controlled doubles. This is where Mockito comes in.
First, let's build the vocabulary from scratch, because everyone mixes these up:
These are types of test double: stand-ins that replace real dependencies.
- Stub: a double that only returns canned answers. "Whenever
findUser(5)is called, return this user." It doesn't care about interaction, it just feeds data. - Mock: a double that, besides answering, watches behavior so you can later ask it "was this method called?". A mock is for verifying interaction.
- Spy: a wrapper around a real object. Methods run for real by default, but you can stub some of them while still recording calls.
In Mockito, the object that
mock()creates can play both roles — stub (withwhen) and mock (withverify) — which is why people blur the names.
Setup with JUnit 5
Mockito 5 (latest around 5.23) has an extension for JUnit 5:
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
PaymentGateway paymentGateway; // an automatic double
@Mock
InventoryRepository inventory;
@InjectMocks
OrderService orderService; // the mocks above get injected into it
@Test
void placesOrderWhenPaymentSucceeds() {
// arrange: teach the mocks what to say
when(inventory.isAvailable("book-1", 2)).thenReturn(true);
when(paymentGateway.charge(anyString(), eq(20.0))).thenReturn(PaymentResult.ok());
// act
OrderResult result = orderService.place(new OrderRequest("book-1", 2, "user-9"));
// assert
assertTrue(result.isSuccessful());
// verify interaction: make sure inventory was actually decreased
verify(inventory).decrease("book-1", 2);
}
}
@Mock creates an empty double, @InjectMocks builds the real class under test and injects the mocks via constructor or field, and MockitoExtension wires it all up before each test.
when() and thenReturn(): programming the stub
The when(...).thenReturn(...) pattern says "when this method is called with these arguments, return this." Other forms:
// return a value
when(repo.findById(1L)).thenReturn(Optional.of(user));
// throw an exception
when(repo.findById(99L)).thenThrow(new EntityNotFoundException());
// consecutive answers: first this, then that
when(repo.count()).thenReturn(1L, 2L, 3L);
// dynamic answer based on the input
when(repo.save(any(User.class))).thenAnswer(inv -> {
User u = inv.getArgument(0);
return new User(42L, u.name()); // as if the DB assigned an id
});
when(mock.doThing()).thenReturn(...) works when the method returns a value. For void methods, or when working on a spy, this syntax is dangerous, because Mockito first calls the real method to evaluate the argument. Use doReturn/doThrow/doNothing instead:
doThrow(new RuntimeException()).when(mock).voidMethod();
doReturn(realValue).when(spy).someMethod(); // safe on a spy
Rule of thumb: on spies, always write doReturn().when(), not when().thenReturn().
verify(): did the interaction happen correctly?
Some behaviors have no return value; you just need to be sure a thing was done (like sending an email or writing a log). That's where verify comes in:
verify(emailService).send(eq("user@x.com"), anyString()); // exactly once
verify(emailService, times(2)).send(any(), any()); // exactly twice
verify(emailService, never()).send(eq("blocked@x.com"), any()); // never
verify(emailService, atLeastOnce()).send(any(), any());
verify(auditLog, timeout(1000)).record(any()); // within a time window (for async)
// make sure there were no other unexpected interactions
verifyNoMoreInteractions(emailService);
ArgumentCaptor: what exactly was passed?
Sometimes you don't just want to know a method was called, you want to inspect the actual argument that was passed. ArgumentCaptor "captures" that argument so you can assert on it:
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
@Captor
ArgumentCaptor<EmailMessage> emailCaptor;
@Test
void sendsWelcomeEmailWithRightBody() {
userService.register(new SignupRequest("Sara", "sara@x.com"));
verify(emailService).send(emailCaptor.capture());
EmailMessage sent = emailCaptor.getValue();
assertEquals("sara@x.com", sent.to());
assertTrue(sent.body().contains("Welcome, Sara"));
}
A common mistake: using ArgumentCaptor to match a stub. A captor is for after the call, to see what went through. If you want to condition behavior before the call, use matchers like eq(), any(), argThat(...). And remember: if you use a matcher for one argument, you must use matchers for all arguments of that call — you can't write charge("id", eq(20.0)); it must be charge(eq("id"), eq(20.0)).
Strictness: why Mockito complains about unused stubs
MockitoExtension enables strict stubs by default. That means if you write a when(...) that's never used in the test, Mockito fails it with UnnecessaryStubbingException. This feels annoying but is a gift: an unused stub usually signals that the test doesn't check what you think it does, or the code changed and the test wasn't updated.
Mocking pitfalls
- Mocking what you shouldn't: don't mock value objects like
String, lists, or DTOs — build the real thing. Save mocks for boundaries (database, network, external service). - Mocking a type you don't own: mocking a third-party library is risky, because your assumption about its behavior might be wrong and the mock will confirm that wrong belief. Instead, build a thin adapter of your own and test that.
- Over-mocking: if a test has ten
whencalls and fiveverifys, the class under test probably has too many responsibilities. The test is shouting at you to simplify the design. - Testing implementation instead of behavior: if every small refactor breaks a pile of tests without any behavior change, you're testing internal details, not the external contract. Tests should bind to what it does, not how.
- Mocking static/final without reason: Mockito 5 ships with the inline mock maker (Byte Buddy-based) by default and can mock final classes and static methods with
mockStatic(...). But a frequent need for this usually signals rigid design; first see whether you can inject the dependency.
AssertJ: assertions that read like sentences
JUnit's own assertions work, but they have two weaknesses. First, their order is confusing: in assertEquals(expected, actual), which comes first? Everyone gets it wrong once. Second, for complex things (lists, nested fields) they get ugly and uninformative. AssertJ (latest around 3.27.x) is a fluent assertion library that fixes both: you start with assertThat(...) and then chain assertions.
import static org.assertj.core.api.Assertions.*;
@Test
void assertjBasics() {
String name = "Testcontainers";
assertThat(name)
.isNotNull()
.startsWith("Test")
.endsWith("ers")
.hasSize(15)
.isEqualToIgnoringCase("testcontainers");
assertThat(2 + 2).isEqualTo(4).isPositive().isEven();
}
Because everything follows assertThat(actual), the order is no longer ambiguous, and the IDE suggests the assertions appropriate for that type after the dot.
The real power: collections and objects
List<String> fruits = List.of("apple", "banana", "cherry");
assertThat(fruits)
.hasSize(3)
.contains("banana")
.doesNotContain("mango")
.containsExactly("apple", "banana", "cherry") // in this order
.allMatch(f -> f.length() > 4);
// extract one field from each object and assert on it
List<Person> people = List.of(new Person("Ali", 30), new Person("Sara", 25));
assertThat(people)
.extracting(Person::name)
.containsExactlyInAnyOrder("Sara", "Ali");
// full comparison of two objects without needing a custom equals
assertThat(actualPerson)
.usingRecursiveComparison()
.isEqualTo(expectedPerson);
Testing exceptions with AssertJ
assertThatThrownBy(() -> calc.divide(1, 0))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("zero");
// or the more readable alternative
assertThatExceptionOfType(IllegalArgumentException.class)
.isThrownBy(() -> calc.divide(1, 0))
.withMessage("division by zero");
Even test libraries aren't immune to security bugs. In version 3.27.7 (early 2026), AssertJ patched an XXE (XML External Entity injection) vulnerability in the isXmlEqualTo assertion (CVE-2026-24400). The lesson: keep libraries up to date — even "test-only" dependencies — and XML parsing should always disable external entities. Also, the next major, AssertJ 4.0, is on the way, cleaning up a decade of technical debt.
Many teams delegate test running to JUnit 5 but prefer AssertJ for assertions, because its failure messages are far more informative. When containsExactly fails, AssertJ tells you exactly which element is missing or extra — something you'd have to eyeball with assertEquals(list1, list2). They're not rivals; they're complementary.
Testcontainers: integration testing against real infrastructure
So far we've isolated units. But some things can only be tested properly with the real thing: does my SQL query actually work on Postgres? Do the migrations run? Does my Kafka consumer process the message correctly?
For years people either used in-memory databases like H2 (which doesn't behave exactly like Postgres and hides real bugs), or assumed a database was installed on the developer's machine (brittle and inconsistent). Testcontainers solved this: a library that, during a test, programmatically spins up real Docker containers and tears them down afterward.
Imagine that before each attempt at a new recipe, a complete, brand-new kitchen — with the exact stove and fridge brand you have in the real restaurant — was assembled for you in seconds, you ran your test, and then the whole kitchen vanished in a puff of smoke. That's exactly what Testcontainers is: each test gets a real, fresh Postgres or Kafka identical to production, then it's destroyed. No contamination between tests, no "it worked on my machine."
A Postgres integration test
import org.junit.jupiter.api.Test;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer; // new package in Testcontainers 2.x
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
class UserRepositoryIT {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:17-alpine")
.withDatabaseName("shop")
.withUsername("test")
.withPassword("test");
@Test
void savesAndLoadsUser() {
// the container provides the connection URL (random port)
String jdbcUrl = postgres.getJdbcUrl();
var repo = new UserRepository(jdbcUrl, postgres.getUsername(), postgres.getPassword());
repo.save(new User("Ali", "ali@x.com"));
assertThat(repo.findByEmail("ali@x.com"))
.isPresent()
.get()
.extracting(User::name)
.isEqualTo("Ali");
}
}
Key points: @Testcontainers binds the container lifecycle to JUnit, @Container marks a field as a container. If it's static, it starts up once for the whole class (faster); if it's an instance field, it starts fresh for each test (more isolated). And getMappedPort/getJdbcUrl give the real address, because Testcontainers maps ports to a random free port on the host so parallel tests don't collide.
The major Testcontainers 2.0 release (2025/2026, latest around 2.0.5) came with an important breaking change: module artifacts were standardized with a testcontainers- prefix (e.g. org.testcontainers:mysql became org.testcontainers:testcontainers-mysql), and container classes moved to org.testcontainers.<module> packages (e.g. PostgreSQLContainer now lives under org.testcontainers.postgresql). If you see older docs importing org.testcontainers.containers.PostgreSQLContainer, that's the 1.x line.
Testing a Kafka consumer
import org.testcontainers.kafka.KafkaContainer;
@Testcontainers
class OrderEventsIT {
@Container
static KafkaContainer kafka = new KafkaContainer("apache/kafka:latest");
@Test
void consumesOrderPlacedEvent() {
String bootstrap = kafka.getBootstrapServers();
var producer = buildProducer(bootstrap);
var consumer = buildConsumer(bootstrap, "orders");
producer.send(new ProducerRecord<>("orders", "order-1", "{\"amount\":50}"));
var records = consumer.poll(Duration.ofSeconds(5));
assertThat(records.count()).isEqualTo(1);
}
}
The same pattern works for Redis, RabbitMQ, Elasticsearch, MongoDB, and even any arbitrary image via GenericContainer.
Wait strategy: wait until it's actually ready
A subtle trap: the container "starts" but the service inside it isn't ready for connections yet. Testcontainers has wait strategies to ensure the service is up before your test runs:
GenericContainer<?> app = new GenericContainer<>("my-api:latest")
.withExposedPorts(8080)
.waitingFor(Wait.forHttp("/health").forStatusCode(200));
Ready-made modules (like PostgreSQLContainer) come with the correct wait strategy preconfigured; for GenericContainer you have to specify it yourself.
If you're on Spring Boot 3.1 or later, the @ServiceConnection annotation on a container bean automatically fills in spring.datasource.url and the rest of the settings from the container — no more hand-written @DynamicPropertySource. This integration shaves several lines off Spring tests and prevents manual mistakes.
Each container means starting Docker, pulling an image, and consuming memory. If you spin up a Postgres for every unit test, your suite becomes slow and brittle and you're back to the ice-cream cone. The rule: keep unit tests fast with mocks; reserve Testcontainers for integration tests that genuinely need a database/queue, make containers static (shared per class), and turn on the reuse feature for faster local runs. You'll also need Docker available on CI.
Flaky tests: the silent enemy of trust
A flaky test is one that sometimes passes and sometimes fails without any code change. These are more dangerous than the bug itself, because they destroy trust: once a team learns to dismiss failures with "just re-run it," they stop taking any failure seriously — and a real bug gets lost in that same noise.
- Time dependence: hopeful
Thread.sleep, or a realLocalDateTime.now(). Instead, inject aClockand fix it in the test. - Order and shared state: tests that lean on a static or a shared database and are sensitive to execution order. Each test should build and clean up its own state.
- Concurrency and races: asserting before an async task finishes. Use conditional waits (like Awaitility or
verify(..., timeout(...))), not a fixed sleep. - Dependence on network/external resources: calling a real service that might be slow or down. Isolate with mocks or Testcontainers.
- Nondeterministic collection order: asserting on the order of a
HashSet. Either sort, or usecontainsExactlyInAnyOrder.
How to hunt them: run the suspect test many times with @RepeatedTest(50) to make it reveal itself, and the moment you spot a flaky test, fix it right there — don't hide it with @Disabled where it stays forever.
Coverage vs meaningful tests
Code coverage is what percentage of lines (or branches) were executed while your tests ran. The common tool in Java is JaCoCo. Coverage is useful, but it has a trap.
You can have 100% coverage and test nothing at all: just call every method and write no asserts. Then every line was "executed" but no behavior was verified. This is Goodhart's Law: "when a measure becomes a target, it ceases to be a good measure." Treat coverage as a detector of forgotten spots (which branch was never executed?), not a score to maximize. The difference between "line of code ran" and "correct behavior was verified" is everything.
Branch coverage is more meaningful than line coverage, because it tells you whether you tested both sides of an if. And if you want to really measure test quality, look at mutation testing with a tool like PIT: it deliberately plants small bugs in the code and checks whether your tests catch them — far more honest than a "coverage percentage."
TDD: test first, code second
Test-Driven Development (TDD) means writing the test before the code. Its cycle is three short steps:
- Red: write a small test for behavior that doesn't exist yet. Run it; it fails (because there's no code yet). That means your test actually measures something. 2) Green: write the simplest code that makes the test green — even if it's ugly. 3) Refactor: now that the safety net is green, clean up the code, confident nothing breaks. Then repeat.
The real benefit of TDD isn't just "having tests"; it's that it forces you to think from the consumer's viewpoint first ("how should this API be called?") and build a more testable design. If writing a test for a class is hard, that usually means the class is badly designed — and TDD tells you this early.
Write each test in three parts: Arrange (set up inputs and mocks), Act (perform the one action), Assert (assert on the result). This structure keeps the test readable and helps each test check just one behavior. Name the test descriptively too: returnsEmptyWhenUserNotFound, not test1.
Interview questions
All three are "test doubles." A stub only returns canned answers and doesn't watch behavior; it's for feeding data. A mock additionally records interaction so you can later verify "was this method called?"; it's for verifying interaction. A spy wraps a real object, running methods for real by default unless you stub them. In Mockito, a mock() object can play both stub (via when) and mock (via verify) roles at once, which is why the terms get blurred in conversation. Practical note: on spies, use doReturn().when() to stub, not when().thenReturn(), because the latter calls the real method.
Three parts: JUnit Platform (the foundation that runs tests on the JVM and is the interface for tools and IDEs), JUnit Jupiter (the new programming model and annotations you write tests with), and JUnit Vintage (a compatibility engine to run legacy JUnit 3/4 tests on the same platform). The separation is deliberate: the Platform defines a standard API for "test engines," so that even other frameworks (like Spock or Cucumber) can run on the same platform and tools only have to integrate with one shared foundation.
For isolation. The default lifecycle is PER_METHOD: before each test method, a brand-new instance of the class is created so instance fields don't leak between tests. This prevents tests from depending on one another and being sensitive to execution order — one of the main sources of flaky tests. If you switch this with @TestInstance(PER_CLASS), one shared instance is created for all (and then @BeforeAll need not be static), but you must guard against state leakage.
With assertThrows in JUnit or assertThatThrownBy in AssertJ. Both take a lambda that runs the failing code and check that the exception type is correct. Their advantage over the old @Test(expected=...) style is that they return the thrown exception so you can also assert on its message and fields, and they pinpoint exactly which line should fail — not anywhere in the method.
ArgumentCaptor "captures" the actual argument passed to a mock so you can assert on it after the call — useful when you want to inspect a complex object the code built and handed to a dependency (like the body of an email that got constructed). The difference from a matcher: a matcher (like eq, any, argThat) is for before the call, to condition a stub or match a verify. If you only need to check the argument satisfies a condition, argThat is cleaner; reach for a captor when you want to extract the exact value and assert on several of its fields.
Three reasons. First readability and order: assertThat(actual).isEqualTo(expected) removes the "which argument is expected" ambiguity. Second informative failure messages: when a collection assertion fails, AssertJ tells you exactly which element is missing or extra, not just "lists aren't equal." Third chaining power: you can chain several assertions on one object, pull fields out with extracting, and compare two objects with usingRecursiveComparison without writing a custom equals. They aren't rivals of JUnit; teams typically use JUnit to run and AssertJ to assert.
Testcontainers spins up real Docker containers (Postgres, Kafka, Redis, ...) during a test and tears them down afterward, so integration tests run against the same infrastructure you have in production. The problem with in-memory databases like H2 is that their behavior isn't identical to real Postgres — data types, functions, transaction behavior, and vendor-specific SQL differ — so a test may pass while the query breaks in production. Testcontainers closes that gap: "what you tested is what runs." The cost is that it needs Docker and is relatively slow, so it suits integration tests, not every unit test.
If you make the container field static, it starts up once for the whole test class and is shared across all its tests — faster, but you must be careful tests don't contaminate each other's data (clean up between them). If it's non-static, it's created and destroyed for each test — more isolated but much slower. In practice teams usually make containers static and reset the data state in @BeforeEach, because repeatedly starting a container is expensive.
A test that sometimes passes and sometimes fails without a code change. Its danger is that it destroys trust: the team learns to dismiss failures with "re-run it," and then a real bug gets lost in the noise. Common sources: time dependence (sleep, now()), shared state and order sensitivity, races in async code, reliance on external networks, and asserting on nondeterministic collection order. Fixes: inject and fix a Clock, have each test build and clean its own state, use conditional waits (Awaitility) instead of sleep, isolate dependencies with mocks/Testcontainers, and use containsExactlyInAnyOrder for collections. Expose the suspect with @RepeatedTest and fix it right there — don't disable it.
No. Coverage only tells you which lines were executed during tests, not that their behavior was verified. You can have 100% coverage without a single assert — every method runs but nothing is confirmed. By Goodhart's Law, once coverage becomes a target it ceases to be a good measure, and people write assertion-free tests to push the number up. Treat coverage as a detector of forgotten spots (which branch never ran?). To really measure quality, branch coverage is more meaningful than line coverage, and mutation testing (with PIT) is the most honest metric because it plants bugs to see whether your tests catch them.
TDD means writing the test before the code, in the red-green-refactor cycle: write a failing test (red), write the simplest code to make it green (green), then clean the code with the safety net in place (refactor). Its main benefit isn't coverage; it's that it forces you to think from the API consumer's viewpoint first, producing a simpler, more testable design. If writing a test for a class is hard, that usually signals bad design (too many dependencies, too much responsibility) — and TDD gives you that feedback early, not months later.
By injecting the dependency on that source of nondeterminism, instead of calling static directly. Instead of LocalDateTime.now(), inject a java.time.Clock and fix it in the test with Clock.fixed(...); now time is predictable. Instead of new Random(), inject a Random with a fixed seed or a number-generating interface. The same principle applies to UUIDs and other external sources. This makes the test deterministic and also cleans up the design by making hidden dependencies explicit and controllable.
Because MockitoExtension is in strict stubs mode by default: any when(...) never used in the test is an unused stub, and Mockito fails the test. No, you usually shouldn't turn it off — the warning is valuable. An unused stub often means: the test doesn't measure what you think, or the code changed and the test wasn't updated, or it's leftover copy-paste. Instead of disabling strictness, remove the redundant stub. Only in specific cases (e.g. a @BeforeEach that sets up a shared stub not all tests need) should you locally relax it with lenient() or Strictness.LENIENT.
Save mocks for system boundaries: database, network, message queue, external service, the clock. Those are slow, nondeterministic, or out of your control. For fast, deterministic internals — value objects, DTOs, collections, pure functions — build the real thing, not a mock. Mocking a type you don't own (a third-party library) is also risky, because it makes your possibly-wrong assumption about its behavior look "verified"; instead build a thin adapter and test that boundary with an integration test. And if a test needs dozens of mocks, that's a design smell: the class has too many responsibilities.
Good tests are a safety net that lets you change things fast and fearlessly. The test pyramid says most of your tests should be fast unit tests, one layer of integration, and a few end-to-end — not the inverse (the ice-cream cone). JUnit 5, with its three parts (Platform/Jupiter/Vintage), is the engine: the @BeforeEach/@AfterEach lifecycle, per-test isolation, parameterized and nested tests, and assertions like assertThrows and assertAll. Mockito isolates the unit from the world; know the mock/spy/stub difference, use doReturn().when() on spies, verify interaction with verify and inspect arguments with ArgumentCaptor, and avoid over-mocking and testing implementation. AssertJ makes assertions readable and failure messages informative. Testcontainers makes integration tests match production with real Docker containers (and renamed things in 2.0). Watch for flaky tests, treat coverage as a floor not a target, and use TDD to think consumer-first. A test that isn't run or isn't trusted has zero value — so keep them fast, deterministic, and meaningful.