সরাসরি পর্বে যান
পর্ব ৪৭ · ৫৯-এর মধ্যে · মডিউল ৭

টেস্টিং — JUnit 5 দিয়ে কোডের নিরাপত্তা জাল

Testing Java Code with JUnit 5 — Assertions, Parameterized Tests, Test Doubles, Mockito, Testable Design and TDD

Taught in Bangla, this lesson covers testing Java code with JUnit 5: why tests matter and the test pyramid, @Test, core assertions and lifecycle annotations, Arrange-Act-Assert, parameterized tests with @CsvSource, testing exceptions and edge cases, test doubles and Mockito, testable design with dependency injection and Clock, flaky tests, coverage limits and TDD.

এই পর্বে যা শিখবেন

  • টেস্ট কেন লাগে, regression কী, আর test pyramid-এ unit, integration ও end-to-end টেস্টের ভাগ
  • JUnit 5-এর @Test, প্রধান assertion, @DisplayName, @BeforeEach/@AfterEach/@BeforeAll, নামের রীতি আর Arrange-Act-Assert
  • @ParameterizedTest (@ValueSource, @CsvSource), @Nested, @Disabled, আর exception ও edge case টেস্ট করা
  • test double-এর পাঁচ ধরন, Mockito-র mock/when/verify, আর dependency injection ও Clock দিয়ে টেস্টযোগ্য নকশা
  • Maven দিয়ে টেস্ট চালানো, coverage-এর সীমা, flaky টেস্ট এড়ানো, TDD-র চক্র আর AI-এর লেখা টেস্ট যাচাই করা

১ · টেস্ট কী, আর কেন — কয়েক সেকেন্ডের নিরাপত্তা জাল

এতদিন কোড লেখার পর আপনি কী করেছেন? চালিয়েছেন, আউটপুট চোখে দেখেছেন, মনে মনে বলেছেন “ঠিক আছে”। একটি ছোট প্রোগ্রামের জন্য এটা চলে। কিন্তু ভাবুন একটি মোবাইল ওয়ালেট অ্যাপের কথা: send money, cash out, রিচার্জ, বিল পেমেন্ট — প্রতিটির চার্জের নিয়ম আলাদা, প্রতিটিতে দশ-বিশটি বিশেষ অবস্থা। আজ একজন developer cash out-এর চার্জ বদলালেন। send money কি এখনও ঠিক আছে? প্রতিবার সব কিছু হাতে চালিয়ে দেখা অসম্ভব।

এখানেই টেস্ট। টেস্ট হলো এমন কোড, যা আপনার কোডকে জানা ইনপুট দিয়ে চালায় আর ফলাফল নিজে থেকেই প্রত্যাশার সাথে মেলায়। মানুষকে আউটপুট পড়তে হয় না; মিললে পাস, না মিললে ফেল।

মোবাইল সার্ভিসিংয়ের দোকানের কথা ভাবুন। দক্ষ মেকানিক ফোনের charging port বদলানোর পর শুধু চার্জিং দেখেই ফোন ফেরত দেন না। তাঁর একটি চেকলিস্ট আছে: কল যায় কি না, স্পিকার, ক্যামেরা, টাচ, Wi-Fi. কারণ একটি অংশে হাত দিলে পাশের অংশ নড়ে যেতে পারে। টেস্ট হলো সেই চেকলিস্ট, যা যন্ত্র নিজেই চালায় — প্রতিটি বদলের পর, পুরোটা, কয়েক সেকেন্ডে।

যে bug আগে ঠিক ছিল এমন কোডে নতুন বদলের কারণে ঢোকে, তার নাম regression. টেস্টের সবচেয়ে বড় কাজ এই regression ধরা। আর এর একটি গভীর ফল আছে — যে কোডের চারপাশে ভালো টেস্ট আছে, সেটা নির্ভয়ে বদলানো যায়। পর্ব ২২-এ বলা refactoring (আচরণ না বদলে ভেতরের গঠন উন্নত করা) টেস্ট ছাড়া জুয়া, টেস্টসহ নিয়মিত কাজ।

মূল ধারণা

টেস্ট আপনার কোড সঠিক কি না “প্রমাণ” করে না — শুধু নির্দিষ্ট কিছু ইনপুটে আচরণ যাচাই করে। কিন্তু সেই যাচাই স্বয়ংক্রিয়, পুনরাবৃত্তিযোগ্য আর দ্রুত। এই কারণেই AI-এর সাহায্যে কোড লেখার যুগে টেস্ট আরও জরুরি: AI এক মিনিটে ২০০ লাইন বদলে দিতে পারে; কোন আচরণ বদলাল, তা আপনাকে কয়েক সেকেন্ডে জানাবে শুধু টেস্ট।

test pyramid — কোন টেস্ট কত

কতটুকু কোড একসাথে যাচাই হচ্ছে, তার ওপর ভিত্তি করে তিনটি স্তর ভাবা হয়। এদের সাজানো রূপকে বলে test pyramid — নিচে চওড়া, ওপরে সরু:

নিচে: unit test — অনেক

একটি class বা method আলাদা করে। ডেটাবেস, নেটওয়ার্ক, ফাইল নেই। প্রতিটি মিলিসেকেন্ডে চলে, ফেল করলে ঠিক কোথায় ভুল তা স্পষ্ট। একটি প্রজেক্টে এগুলো শত শত বা হাজার হাজার।

মাঝে: integration test — কম

কয়েকটি অংশ একসাথে, আসল ডেটাবেস বা ফাইলসহ। ধীরতর, সেটআপ বেশি, কিন্তু অংশগুলো ঠিকমতো জোড়া লাগে কি না তা শুধু এখানেই ধরা পড়ে।

ওপরে: end-to-end test — অল্প

পুরো অ্যাপ, ব্যবহারকারীর মতো করে — ব্রাউজারে ক্লিক বা API ডাক। সবচেয়ে বাস্তব, কিন্তু সবচেয়ে ধীর, ভঙ্গুর আর ফেল করলে কারণ খুঁজতে কঠিন।

এই পর্বের বেশিরভাগ অংশ নিচের স্তর নিয়ে — integration test আর end-to-end test অংশ ৭-এ সংক্ষেপে আসবে। পিরামিডের যুক্তি সহজ: সস্তা আর দ্রুত টেস্ট বেশি লিখুন, দামি আর ধীর টেস্ট শুধু সেখানে, যেখানে অন্য কিছু দিয়ে কাজ হয় না। (এই আকার নিয়েও বিতর্ক আছে — ভাবনার প্রশ্ন ৩।)

২ · প্রথম টেস্ট হাতে লেখা — assertion কী জিনিস

JUnit শেখার আগে টেস্টের মূল কৌশলটা খালি হাতে দেখা যাক, যাতে framework-কে আর জাদু মনে না হয়। একটি কাল্পনিক send money চার্জের নিয়ম: ১ থেকে ৫০০ টাকায় ৫ টাকা, ৫০১ থেকে ২০০০ টাকায় ১০ টাকা, তার বেশি হলে ১%. (এগুলো শেখার জন্য বানানো হার, কোনো আসল সেবার নয়।) কোড লিখেছেন একজন, তাড়াহুড়োয়। টেস্টের কেন্দ্রে থাকে একটি assertion — “এই মান এটাই হওয়ার কথা” বলে যাচাই করা, না মিললে ফেল ঘোষণা। নিচে দুটি ছোট assertion helper আর ছয়টি test case:

class FeeCalculator {
    // কাল্পনিক slab: ১–৫০০ টাকায় ৫, ৫০১–২০০০ টাকায় ১০, তার বেশি হলে ১%
    static long fee(long amount) {
        if (amount <= 0) throw new IllegalArgumentException("amount must be positive");
        if (amount < 500) return 5;
        if (amount <= 2000) return 10;
        return amount / 100;
    }
}
class Main {
    static int run, failed;
    static void report(String name, boolean ok, String why) {
        run++; if (!ok) failed++;
        System.out.println(ok ? "PASS " + name : "FAIL " + name + " -> " + why);
    }
    static void assertEquals(long expected, long actual, String name) {
        report(name, expected == actual, "expected: <" + expected + "> but was: <" + actual + ">");
    }
    static void assertThrows(Class<? extends Exception> type, Runnable code, String name) {
        try { code.run(); report(name, false, "nothing thrown"); }
        catch (Exception e) { report(name, type.isInstance(e), "wrong exception " + e); }
    }
    public static void main(String[] args) {
        assertEquals(5, FeeCalculator.fee(1), "smallest amount");
        assertEquals(5, FeeCalculator.fee(500), "upper edge of slab 1");
        assertEquals(10, FeeCalculator.fee(501), "lower edge of slab 2");
        assertEquals(10, FeeCalculator.fee(2000), "upper edge of slab 2");
        assertEquals(25, FeeCalculator.fee(2500), "percent slab");
        assertThrows(IllegalArgumentException.class, () -> FeeCalculator.fee(0), "zero is rejected");
        System.out.println("Tests run: " + run + ", Failures: " + failed);
    }
}

আউটপুটে পাঁচটি PASS আর একটি লাইন: FAIL upper edge of slab 1 -> expected: <5> but was: <10>, শেষে Tests run: 6, Failures: 1. ঠিক ৫০০ টাকায় চার্জ ৫ হওয়ার কথা, কিন্তু কোডে লেখা amount < 500 — হওয়া উচিত ছিল <=. ১, ৫০১, ২০০০, ২৫০০ — অন্য সব মানে কোড ঠিক চলে, তাই হাতে দু-একবার চালিয়ে এই ভুল প্রায় কখনো চোখে পড়ত না। ধরা পড়ল কারণ টেস্টে ইচ্ছা করে slab-এর ঠিক কিনারার মান রাখা হয়েছিল (অংশ ৫-এ এর নাম boundary value analysis). assertThrows একটি lambda নেয়, কারণ exception ছোড়ার কোডটা helper-এর try-এর ভেতরে চালাতে হবে। (সব class public ছাড়া, কারণ অনলাইন runner ফাইলটিকে অন্য নামে compile করে; নিজের প্রজেক্টে সাধারণত public class.)

প্রতিটি টেস্টের ভেতরে তিনটি অংশ থাকে, এমনকি এক লাইনের টেস্টেও: প্রস্তুতি (কোন ইনপুট), কাজ (method ডাকা), যাচাই (ফল মেলানো)। এই গঠনের নাম Arrange-Act-Assert, সংক্ষেপে AAA. একই ধারণার আরেকটি নাম Given-When-Then — “ধরা যাক ব্যালান্স ১০০০ (given), যখন ৩০০ তোলা হয় (when), তখন ব্যালান্স ৭০০ (then)”.

পর্ব ৪৫-এ আপনি @MyTest annotation আর reflection দিয়ে একটি mini test runner বানিয়েছিলেন। ওপরের helper আর সেই runner মিলালে প্রায় একটি ছোট JUnit হয়ে যায়: runner টেস্ট খোঁজে আর চালায়, helper যাচাই করে আর পরিষ্কার বার্তা দেয়।

৩ · JUnit 5-এর শুরু — @Test, assertion আর টেস্টের জীবনচক্র

JUnit JDK-র অংশ নয়; এটা একটি বাইরের library, তাই পর্ব ৪৬-এর মতো Maven বা Gradle দিয়ে যোগ করতে হয়। Maven-এ pom.xml-এ একটি dependency (সংস্করণ নম্বর উদাহরণ মাত্র; নতুন প্রজেক্টে সর্বশেষটি দেখে নিন):

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.11.0</version>
    <scope>test</scope>          <!-- শুধু টেস্টের সময় লাগবে -->
</dependency>

scope test মানে চূড়ান্ত .jar-এ JUnit যাবে না। আর কোড থাকে দুই ফোল্ডারে: আসল কোড src/main/java-এ, টেস্ট src/test/java-এ, একই package-এ। নাম JUnit Jupiter দেখে অবাক হবেন না: JUnit 5 তিন ভাগে তৈরি — Platform (টেস্ট খুঁজে চালানোর ইঞ্জিন, যা IDE আর Maven ব্যবহার করে), Jupiter (আপনি যে annotation আর assertion লেখেন), আর Vintage (পুরোনো JUnit 4-এর টেস্ট চালানোর জন্য)।

এবার একটি আসল class — পর্ব ১৭-এর ধাঁচে একটি ওয়ালেট, যার টাকা তোলার দুটি নিয়ম:

class Wallet {
    private long balance;
    private final List<String> history = new ArrayList<>();

    Wallet(long opening) { balance = opening; }

    void withdraw(long amount) {
        if (amount <= 0) throw new IllegalArgumentException("amount must be positive");
        if (amount > balance) throw new IllegalStateException("insufficient balance");
        balance -= amount;
        history.add("withdraw " + amount);
    }
    long balance() { return balance; }
    List<String> history() { return List.copyOf(history); }
}

আর তার টেস্ট class (চলবে Maven বা IDE-তে; অনলাইন runner-এ JUnit নেই):

import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

@DisplayName("Wallet — টাকা তোলার নিয়ম")
class WalletTest {
    private Wallet wallet;

    @BeforeEach
    void setUp() {
        wallet = new Wallet(1000);                       // Arrange: প্রতিটি টেস্টের আগে নতুন ওয়ালেট
    }

    @Test
    @DisplayName("ব্যালান্সের মধ্যে তুললে টাকা কমে আর ইতিহাসে লেখা হয়")
    void withdraw_withinBalance_reducesBalance() {
        wallet.withdraw(300);                            // Act
        assertEquals(700, wallet.balance());             // Assert: প্রত্যাশিত মান আগে
        assertEquals(List.of("withdraw 300"), wallet.history());
    }

    @Test
    @DisplayName("ব্যালান্সের বেশি তুললে exception, ব্যালান্স অক্ষত")
    void withdraw_moreThanBalance_throwsAndKeepsBalance() {
        var e = assertThrows(IllegalStateException.class, () -> wallet.withdraw(1500));
        assertEquals("insufficient balance", e.getMessage());
        assertEquals(1000, wallet.balance());
    }

    @Test
    void newWallet_hasOpeningBalanceAndNoHistory() {
        assertAll("নতুন ওয়ালেট",
            () -> assertEquals(1000, wallet.balance()),
            () -> assertTrue(wallet.history().isEmpty()));
    }
}

কয়েকটি জিনিস লক্ষ করুন। টেস্ট class আর method-এ public লাগে না (JUnit 4-এ লাগত); শুধু private হওয়া চলবে না। কোনো main নেই — JUnit Platform reflection দিয়ে @Test-ওয়ালা method খুঁজে চালায়, ঠিক পর্ব ৪৫-এর runner-এর মতো। @DisplayName দিয়ে রিপোর্টে বাংলায় পড়ার মতো নাম দেওয়া যায়; IDE-র টেস্ট-প্যানেলে method-এর নামের বদলে এটাই দেখায়।

প্রধান assertion — কোনটা কখন

assertionযাচাই করেমনে রাখার বিষয়
assertEquals(expected, actual)দুটি মান সমান (object হলে equals দিয়ে)প্রত্যাশিত মান প্রথমে; উল্টো দিলে ফেলের বার্তা বিভ্রান্তিকর হয়। double-এর জন্য তৃতীয় argument delta: assertEquals(3.7, x, 0.001)
assertTrue(cond) / assertFalseশর্ত সত্য/মিথ্যাফেল করলে শুধু “expected true” বলে; সম্ভব হলে assertEquals বেশি তথ্য দেয়
assertThrows(Type.class, () -> ...)কোডটি ওই ধরনের exception ছোড়েছোড়া exception ফেরত দেয় — message-ও যাচাই করুন
assertAll(...)কয়েকটি যাচাই একসাথেপ্রথম ফেলে থামে না; সব ফেল একসাথে রিপোর্ট করে
assertTimeout(Duration.ofMillis(200), () -> ...)কাজটি সময়ের মধ্যে শেষ হয়কাজ শেষ হওয়া পর্যন্ত অপেক্ষা করে তারপর বিচার করে; মাঝপথে থামাতে assertTimeoutPreemptively
assertNull, assertNotNull, assertSame, fail("...")null, একই object (==), জোর করে ফেলassertSame হলো reference equality, পর্ব ২৩ দেখুন

কোনো assertion ব্যর্থ হলে JUnit একটি AssertionFailedError ছোড়ে, যার বার্তা expected: <700> but was: <1000> ধরনের — অংশ ২-এর helper-এর মতোই। টেস্টের ভেতরে অপ্রত্যাশিত exception (যেমন NullPointerException) হলেও টেস্ট ব্যর্থ হয়; Maven-এর রিপোর্টে প্রথমটি গোনা হয় Failure, দ্বিতীয়টি Error হিসেবে।

জীবনচক্র — আর কেন প্রতিটি টেস্ট নতুন object পায়

lifecycle annotation বলে কোন কোড কখন চলবে। তিনটি টেস্টওয়ালা একটি class-এ JUnit এই ক্রমে চলে:

@BeforeAll  (static, পুরো class-এ একবার — যেমন ভারী কিছু তৈরি)
  নতুন WalletTest object → @BeforeEach → টেস্ট ১ → @AfterEach
  নতুন WalletTest object → @BeforeEach → টেস্ট ২ → @AfterEach
  নতুন WalletTest object → @BeforeEach → টেস্ট ৩ → @AfterEach
@AfterAll   (static, শেষে একবার — যেমন পরিষ্কার করা)

সবচেয়ে গুরুত্বপূর্ণ লাইনটি “নতুন WalletTest object”. JUnit 5 ডিফল্টে প্রতিটি টেস্ট method-এর জন্য টেস্ট class-এর নতুন instance বানায়। তাই এক টেস্টে wallet.withdraw(300) করলেও পরের টেস্ট আবার ১০০০ টাকার নতুন ওয়ালেট পায়। একে বলে টেস্টের স্বাধীনতা: প্রতিটি টেস্ট একা চললেও পাস করবে, যেকোনো ক্রমে চললেও। এ কারণেই @BeforeAll method static হতে হয় — তখনো কোনো instance থাকে না। (@TestInstance(Lifecycle.PER_CLASS) দিয়ে এই আচরণ বদলানো যায়, তবে তখন অবস্থা ভাগ হওয়ার ঝুঁকি আপনার।) টেস্টের ক্রমও JUnit নিশ্চিত করে না।

নামের রীতি আর একটি টেস্টে একটি আচরণ

টেস্ট ফেল করলে প্রথমে চোখে পড়ে তার নাম। test1 কিছুই বলে না; withdraw_moreThanBalance_throwsAndKeepsBalance পড়েই বোঝা যায় কোন নিয়ম ভেঙেছে। প্রচলিত দুটি রীতি:

  • methodName_condition_expected: fee_amountIs500_returnsFive — কোন method, কোন অবস্থায়, কী প্রত্যাশা।
  • should… বা বাক্যের মতো: shouldRejectZeroAmount(), সাথে @DisplayName("শূন্য টাকা গ্রহণ করে না").

দলে যেটা চলে সেটাই মানুন। আর একটি টেস্ট একটি আচরণ যাচাই করবে — তিনটি নিয়ম এক টেস্টে থাকলে প্রথমটি ফেল করলেই বাকিগুলোর খবর হারায়। (একই আচরণের কয়েকটি দিক — যেমন ব্যালান্স আর ইতিহাস — একসাথে যাচাই করা ঠিক আছে; সেখানে assertAll কাজে লাগে।)

C/C++/Python জানলে

Python-এর pytest-এ টেস্ট সাধারণ function, যাচাই হয় সাধারণ assert দিয়ে, আর ফিক্সচার দিয়ে প্রস্তুতি — Python-এর pytest পর্বে দেখুন। Java-তে একই ধারণা annotation দিয়ে: @BeforeEach মোটামুটি pytest fixture-এর কাজ করে, @ParameterizedTest হলো parametrize. Java-র নিজের assert keyword টেস্টের জন্য নয় — এটা ডিফল্টে বন্ধ থাকে (-ea flag ছাড়া চলেই না)। C/C++-এ standard library-তে কোনো test framework নেই; GoogleTest বা Catch2-এর মতো বাইরের টুল লাগে, ধারণা একই।

৪ · parameterized test — একটি টেবিল, অনেক টেস্ট

অংশ ২-এ চার্জের পাঁচটি মান যাচাই করতে পাঁচটি প্রায় একই লাইন লিখতে হয়েছিল। VAT-এর হার, চার্জের slab, পরীক্ষার গ্রেড — এমন নিয়ম আসলে একটি টেবিল। JUnit-এ টেবিলটা সরাসরি লেখা যায়, আর প্রতিটি সারি একটি আলাদা টেস্ট হয়ে চলে। এর নাম parameterized test:

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;
import static org.junit.jupiter.api.Assertions.*;

class FeeCalculatorTest {
    @ParameterizedTest(name = "[{index}] {0} টাকা → চার্জ {1}")
    @CsvSource({
        "1,    5",
        "500,  5",
        "501,  10",
        "2000, 10",
        "2500, 25"
    })
    void fee_followsSlabTable(long amount, long expectedFee) {
        assertEquals(expectedFee, FeeCalculator.fee(amount));
    }

    @ParameterizedTest
    @ValueSource(longs = {0, -1, -500})
    void fee_nonPositiveAmount_isRejected(long amount) {
        assertThrows(IllegalArgumentException.class, () -> FeeCalculator.fee(amount));
    }
}

@CsvSource-এর প্রতিটি String একটি সারি; কমা দিয়ে ভাগ করা মানগুলো method-এর parameter-এ যায়, আর JUnit নিজেই String থেকে long-এ রূপান্তর করে। @ValueSource এক parameter-এর টেস্টের জন্য — একটি মানের তালিকা। এই দুই annotation-এর জন্য junit-jupiter-params লাগে, যা ওপরের junit-jupiter dependency-র সাথেই আসে। একটি টেবিল কীভাবে আলাদা আলাদা টেস্ট হয়, ধাপে ধাপে দেখুন (অংশ ২-এর সেই < 500 bug এখনও কোডে আছে ধরে নিয়ে):

1@ParameterizedTest(name = "[{index}] {0} টাকা → চার্জ {1}")2@CsvSource({3    "1,    5",4    "500,  5",5    "501,  10",6    "2000, 10",7    "2500, 25" })8void fee_followsSlabTable(long amount, long expectedFee) {9    assertEquals(expectedFee, FeeCalculator.fee(amount)); }
এই টেস্টের মান
amount
expectedFee
fee(amount)
  1. টেবিল পড়া। JUnit টেস্ট চালানোর আগে @CsvSource-এর পাঁচটি সারি পড়ে। প্রতিটি সারির জন্য একটি আলাদা টেস্ট “আহ্বান” তৈরি হবে, প্রত্যেকটির নিজের নাম — name-এ দেওয়া ছাঁচ অনুযায়ী, যেখানে {index} সারির ক্রম আর {0}, {1} argument-এর মান।
  2. সারি ১: 1, 5. amount = 1, expectedFee = 5. method চলে, চার্জ আসে ৫, মিলেছে — পাস।
  3. সারি ২: 500, 5 — ফেল। slab-এর কিনারায় bug: কোড দেয় ১০. এই টেস্ট ফেল করল, কিন্তু টেবিলের বাকি সারি থামল না। লুপ দিয়ে এক টেস্টে পাঁচটি মান যাচাই করলে প্রথম ফেলেই থেমে যেত, আর আপনি জানতেন না বাকিগুলোর কী অবস্থা।
  4. সারি ৩: 501, 10. দ্বিতীয় slab-এর শুরু — পাস।
  5. সারি ৪: 2000, 10. দ্বিতীয় slab-এর শেষ কিনারা — পাস।
  6. সারি ৫: 2500, 25. শতাংশের slab — পাস। নতুন slab বা নতুন হার এলে শুধু টেবিলে একটি লাইন যোগ করতে হবে, নতুন method নয়।
  7. রিপোর্ট। পাঁচটি আলাদা টেস্ট, একটি ফেল, আর ফেলের নামেই লেখা “[2] 500 টাকা → চার্জ 5”. কোডে <-কে <= করলে আবার চালান — পাঁচটিই সবুজ।

@Nested আর @Disabled — টেস্ট সাজানো

class WalletTest {
    @Nested
    @DisplayName("ব্যালান্স শূন্য হলে")
    class WhenEmpty {
        Wallet wallet = new Wallet(0);

        @Test void anyWithdraw_throws() {
            assertThrows(IllegalStateException.class, () -> wallet.withdraw(1));
        }
    }

    @Test
    @Disabled("দৈনিক সীমা এখনও তৈরি হয়নি — টিকিট #142")
    void withdraw_overDailyLimit_throws() { }
}

@Nested দিয়ে একটি অবস্থার সব টেস্ট একটি inner class-এ রাখা যায় (পর্ব ২৭-এর inner class — static নয়); রিপোর্টে গাছের মতো দেখায়: “Wallet → ব্যালান্স শূন্য হলে → anyWithdraw_throws”. @Disabled টেস্টকে সাময়িক বাদ রাখে, রিপোর্টে “Skipped” হিসেবে গোনা হয়। সবসময় কারণ লিখুন।

টেস্ট চালানো — IDE আর Maven

IDE-তে টেস্টের পাশের সবুজ ▶ চাপলেই সেটি চলে। কমান্ড লাইনে বা CI সার্ভারে Maven:

mvn test                              # সব টেস্ট
mvn test -Dtest=FeeCalculatorTest     # শুধু একটি class
./gradlew test                        # Gradle হলে (build.gradle-এ useJUnitPlatform() লাগে)

Maven-এ টেস্ট চালায় Surefire plugin. ডিফল্টে সে src/test/java-এর এমন class খোঁজে, যার নাম Test দিয়ে শুরু বা Test, Tests বা TestCase দিয়ে শেষ — তাই WalletChecks নামের class নীরবে বাদ পড়বে। ওপরের ফি-টেস্ট (bug-সহ) চালালে রিপোর্টের মূল অংশ এমন দেখায়:

প্রত্যাশিত আউটপুট (সংক্ষেপে) — সময়, লাইন নম্বর আর বাড়তি লাইন বাদ দেওয়া; সংস্করণভেদে খুঁটিনাটি আলাদা হয়:

[INFO]  T E S T S
[INFO] Running FeeCalculatorTest
[ERROR] Tests run: 8, Failures: 1, Errors: 0, Skipped: 0 ... <<< FAILURE!
...
[ERROR] Failures:
[ERROR]   FeeCalculatorTest.fee_followsSlabTable ... expected: <5> but was: <10>
[ERROR] Tests run: 8, Failures: 1, Errors: 0, Skipped: 0
[INFO] BUILD FAILURE

৮টি টেস্ট কেন? টেবিলের ৫টি সারি আর @ValueSource-এর ৩টি মান — প্রতিটি আলাদা টেস্ট। Skipped গোনে @Disabled টেস্ট। আর শেষের BUILD FAILURE গুরুত্বপূর্ণ: একটি টেস্ট ফেল করলে mvn package-ও থেমে যায়, ফলে ভাঙা কোড .jar হয়ে সার্ভারে যেতে পারে না। দলগুলো প্রতিটি push-এ এই কাজ স্বয়ংক্রিয়ভাবে চালায় — একে বলে continuous integration (CI).

৫ · exception আর edge case — যেখানে bug লুকিয়ে থাকে

নতুনদের লেখা টেস্টে সবচেয়ে বড় ফাঁক: শুধু happy path — সব ঠিকঠাক চলার পথ। ১০০০ টাকার ওয়ালেট থেকে ৩০০ তোলা, পাস, শেষ। কিন্তু bug থাকে কিনারায়। পর্ব ০২-এ দেখা edge case-এর ধারণা এখানে একটি পদ্ধতি হয়ে যায়।

প্রথম ধাপ: ইনপুটকে এমন দলে ভাগ করুন, যাদের ভেতরে কোডের আচরণ একই — একে বলে equivalence partitioning. withdraw-এর জন্য: অবৈধ (০ বা কম), বৈধ (১ থেকে ব্যালান্স), বেশি (ব্যালান্সের ওপরে)। প্রতিটি দল থেকে অন্তত একটি মান। দ্বিতীয় ধাপ: দলের সীমায় দুই পাশের মান নিন — কারণ প্রোগ্রামাররা ভুল করেন ঠিক সেখানে, < আর <=-এর মধ্যে (পর্ব ১১-এর off-by-one). এর নাম boundary value analysis. ব্যালান্স ১০০০ হলে:

ইনপুটকেন বাছাপ্রত্যাশা
-1, 0অবৈধ দলের কিনারাIllegalArgumentException, ব্যালান্স ১০০০
1বৈধ দলের নিচের কিনারাব্যালান্স ৯৯৯
999, 1000বৈধ দলের ওপরের কিনারা — পুরো টাকা তোলা যায় তো?ব্যালান্স ১ ও ০
1001বেশি দলের শুরুIllegalStateException, ব্যালান্স ১০০০
Long.MAX_VALUEচরম মান — overflow হয় কি না (পর্ব ০৭)IllegalStateException
পরপর দুবার ৬০০অবস্থা বদলের পরের আচরণপ্রথমটি সফল, দ্বিতীয়টি exception

অন্য টাইপের জন্য একই চিন্তা: String হলে null, "", শুধু space, খুব লম্বা, বাংলা অক্ষর (পর্ব ০৮-এর UTF-16); collection হলে খালি, একটি উপাদান, ডুপ্লিকেট; তারিখ হলে মাসের শেষ দিন, ২৯ ফেব্রুয়ারি, মধ্যরাত (পর্ব ৩৮)।

exception টেস্ট করার সময় তিনটি জিনিস যাচাই করুন: সঠিক টাইপ (assertThrows), দরকার হলে বার্তা (ফেরত পাওয়া exception থেকে), আর অবস্থা অক্ষত — exception-এর পরেও ব্যালান্স বদলায়নি তো? তৃতীয়টি সবচেয়ে বেশি বাদ পড়ে। পর্ব ১৬-এ বলা fail-fast নীতি — অবস্থা বদলানোর আগেই সব যাচাই — এই ঝুঁকি কমায়, আর টেস্ট নিশ্চিত করে নীতিটা মানা হচ্ছে।

পুরোনো একটি ভুল ধাঁচ এখনও অনেক কোডে দেখবেন: try { wallet.withdraw(0); } catch (IllegalArgumentException e) { } — কোনো fail() ছাড়া। exception না এলেও টেস্ট নীরবে পাস করে! assertThrows ঠিক এই ফাঁক বন্ধ করে: কিছু না ছুড়লে বা অন্য টাইপ ছুড়লে টেস্ট ফেল।

AssertJ — পড়তে সহজ assertion

অনেক দল JUnit-এর সাথে AssertJ library ব্যবহার করে। এর assertion বাক্যের মতো পড়া যায়, আর ফেলের বার্তা আরও বিস্তারিত:

import static org.assertj.core.api.Assertions.*;

assertThat(wallet.balance()).isEqualTo(700);
assertThat(wallet.history()).hasSize(1).containsExactly("withdraw 300");
assertThatThrownBy(() -> wallet.withdraw(1500))
    .isInstanceOf(IllegalStateException.class)
    .hasMessage("insufficient balance");

প্রতিটি assertion assertThat(actual) দিয়ে শুরু হয়, তাই expected-actual উল্টে যাওয়ার ভয় নেই।

৬ · test double — আসল SMS না পাঠিয়ে টেস্ট

একটি OrderService অর্ডার নিলে গ্রাহককে SMS পাঠায়। টেস্ট চালালে কি সত্যিই SMS যাবে? প্রতিবার টাকা খরচ, নেটওয়ার্ক না থাকলে টেস্ট ফেল, আর কোন SMS গেল তা যাচাই করার উপায় নেই। সমাধান: পর্ব ২১-এর মতো SMS পাঠানোকে একটি interface-এর পেছনে রাখা — interface SmsSender { void send(String phone, String text); } — আর টেস্টে আসল gateway-এর বদলে একটি নকল বসানো। টেস্টে আসল নির্ভরতার জায়গায় যে বসে, তার নাম test double. Gerard Meszaros তাঁর xUnit Test Patterns বইয়ে এর পাঁচটি ধরন আলাদা করেছেন, আর Martin Fowler-এর পরিচিত প্রবন্ধ “Mocks Aren't Stubs” নামগুলো জনপ্রিয় করেছে। একই SmsSender-এর ওপর চারটি দেখুন:

  1. dummyশুধু জায়গা ভরায়কখনো ডাকা হয় না
  2. stubঠিক করা উত্তরইনপুট জোগায়
  3. fakeসরল আসল কাজতালিকায় জমা রাখে
  4. mockপ্রত্যাশিত ডাকverify করে
dummyconstructor ভরানোর জন্য
SmsSender dummy = (phone, text) -> {
    throw new AssertionError("should not be called");
};
// টেস্টটা শুধু invalid অর্ডার বাতিল হয় কি না দেখছে —
// SMS এই পথে কখনো পৌঁছায় না
assertThrows(IllegalArgumentException.class,
    () -> new OrderService(dummy, clock).place("017...", 0));
stubনির্ভরতা থেকে আসা উত্তর ঠিক করে দেওয়া
interface VatRates { int percentFor(String category); }

VatRates stub = category -> 15;        // সবসময় ১৫%
var calc = new InvoiceCalculator(stub);
assertEquals(1150, calc.total(1000, "electronics"));
// stub কোডকে ইনপুট দেয়; কে কতবার ডাকল তা দেখে না
fakeসত্যিকারের, কিন্তু সরল implementation
class FakeSmsSender implements SmsSender {
    final List<String> sent = new ArrayList<>();
    public void send(String p, String t) { sent.add(p + " | " + t); }
}
// নেটওয়ার্কের বদলে একটি List. পরে টেস্ট দেখে কী জমা হলো:
assertEquals(1, fake.sent.size());
// in-memory ডেটাবেস বা Map-ভিত্তিক repository-ও fake
mockMockito দিয়ে — কোন ডাক হওয়ার কথা
SmsSender sms = mock(SmsSender.class);
new OrderService(sms, clock).place("01712345678", 1500);

verify(sms).send(eq("01712345678"), contains("1500 Tk"));
verify(sms, times(1)).send(anyString(), anyString());
// mock ডাকগুলো রেকর্ড করে; verify প্রত্যাশা না মিললে ফেল
কোনটা কখনএকটি ছোট নিয়ম
dummy  → লাগে, কিন্তু ব্যবহার হয় না
stub   → কোডকে ইনপুট জোগাতে (হার, কনফিগ, আজকের তারিখ)
fake   → সরল আসল আচরণ দরকার, পরে অবস্থা দেখবেন
mock   → ডাকটাই ফল (SMS গেল কি না, কতবার)
spy    → আসল object, কিন্তু ডাকগুলো রেকর্ড হয়
  1. dummy — শুধু জায়গা ভরানো। OrderService-এর constructor একটি SmsSender চায়, কিন্তু এই টেস্টের পথে SMS কখনো যায় না। তাই এমন একটি object, যা ডাকা হলে উল্টো টেস্টকেই ফেল করিয়ে দেয় — নিশ্চিত হওয়া যায় যে সত্যিই ডাকা হয়নি। dummy সবচেয়ে সরল double.
  2. stub — ঠিক করা উত্তর। stub নির্ভরতার জায়গায় বসে আগে থেকে ঠিক করা উত্তর দেয়। এখানে একটি lambda সবসময় ১৫% হার দেয়। stub কোডে ঢোকা তথ্য নিয়ন্ত্রণ করে।
  3. fake — সরল কিন্তু সত্যিকারের কাজ। fake interface-টি আসলেই implement করে, তবে সহজ উপায়ে: SMS পাঠানোর বদলে একটি List-এ জমা রাখে। টেস্ট শেষে List দেখে যাচাই। Map দিয়ে বানানো একটি repository (ডেটাবেসের বদলে) আরেকটি প্রচলিত fake. নিচের চালানো যায় এমন উদাহরণে এটাই।
  4. mock — ডাকটাই যাচাইয়ের বিষয়। mock প্রতিটি ডাক রেকর্ড করে, আর টেস্ট শেষে বলা হয় “এই argument দিয়ে ঠিক একবার ডাকা হওয়ার কথা ছিল”। কোডটি কোনো মান ফেরত দেয় না, তার আসল কাজই একটি পার্শ্বপ্রতিক্রিয়া (SMS যাওয়া) — তখন mock স্বাভাবিক। mock, verify, eq, contains আসে Mockito থেকে।
  5. কোনটা কখন। পঞ্চম ধরন spy: আসল object, যার ডাকগুলো রেকর্ড হয় (Mockito-তে spy(realObject)). মনে রাখার সহজ উপায়: stub আর fake দেখে ফল আর অবস্থা, mock দেখে কীভাবে কাজ হলো।

Mockito-র মূল তিনটি কাজ

Mockito Java-র সবচেয়ে প্রচলিত mocking library (এটাও Maven dependency, অনলাইনে চালানো যাবে না)। মূল কাজ তিনটি — নকল বানানো, উত্তর ঠিক করা, ডাক যাচাই করা:

import static org.mockito.Mockito.*;

@Test
void total_usesRateFromRepository_andSendsSms() {
    VatRates rates = mock(VatRates.class);                 // ১. নকল বানানো
    SmsSender sms = mock(SmsSender.class);
    when(rates.percentFor("electronics")).thenReturn(15);   // ২. উত্তর ঠিক করা (stub-এর মতো)

    var service = new InvoiceService(rates, sms);
    long total = service.bill("01712345678", 1000, "electronics");

    assertEquals(1150, total);
    verify(sms).send("01712345678", "Total 1150 Tk");       // ৩. ডাক যাচাই
    verify(sms, never()).send(eq("01812345678"), anyString());
}

@ExtendWith(MockitoExtension.class) দিলে field-এ @Mock লিখেও নকল বানানো যায়।

হাতে লেখা fake আর নির্দিষ্ট ঘড়ি — চালিয়ে দেখুন

এখানে Mockito ছাড়া, শুধু JDK দিয়ে পুরো ব্যাপারটা। OrderService-এর দুটি নির্ভরতা — SMS আর সময় — দুটোই constructor দিয়ে আসে। নিয়ম: অর্ডার নিলে SMS-এ ডেলিভারির তারিখ জানানো হয়; সাধারণত দুই দিন পরে, কিন্তু রাত ৮টার পরে অর্ডার হলে তিন দিন। টেস্টে পর্ব ৩৮-এর Clock.fixed দিয়ে সময় বেঁধে দেওয়া হয়েছে:

import java.time.*;
import java.util.*;
interface SmsSender { void send(String phone, String text); }
class FakeSmsSender implements SmsSender {             // হাতে লেখা fake
    final List<String> sent = new ArrayList<>();
    public void send(String phone, String text) { sent.add(phone + " | " + text); }
}
class OrderService {
    private final SmsSender sms; private final Clock clock;
    OrderService(SmsSender sms, Clock clock) { this.sms = sms; this.clock = clock; }
    void place(String phone, long amount) {
        if (amount <= 0) throw new IllegalArgumentException("amount must be positive");
        LocalDateTime now = LocalDateTime.now(clock);
        int days = now.getHour() >= 20 ? 3 : 2;             // রাত ৮টার পরে হলে এক দিন বেশি
        sms.send(phone, "Order " + amount + " Tk confirmed, delivery by " + now.toLocalDate().plusDays(days));
    }
}
class Main {
    static Clock at(String t) { var z = ZoneId.of("Asia/Dhaka"); return Clock.fixed(LocalDateTime.parse(t).atZone(z).toInstant(), z); }
    public static void main(String[] args) {
        var fake = new FakeSmsSender();
        new OrderService(fake, at("2026-10-18T10:00")).place("01712345678", 1500);
        new OrderService(fake, at("2026-10-18T21:30")).place("01812345678", 700);
        try { new OrderService(fake, at("2026-10-18T10:00")).place("01912345678", 0); }
        catch (IllegalArgumentException e) { System.out.println("rejected: " + e.getMessage()); }
        fake.sent.forEach(System.out::println);
        System.out.println(fake.sent.size() == 2 ? "PASS 2 sms, none for the bad order" : "FAIL");
    }
}

আউটপুট চার লাইন: rejected: amount must be positive, তারপর 01712345678 | Order 1500 Tk confirmed, delivery by 2026-10-20, 01812345678 | Order 700 Tk confirmed, delivery by 2026-10-21, আর PASS 2 sms, none for the bad order. সকাল ১০টার অর্ডারে দুই দিন (২০ তারিখ), রাত সাড়ে ৯টার অর্ডারে তিন দিন (২১ তারিখ) — দুটো নিয়মই যাচাই হলো, অথচ আপনাকে রাত ৮টা পর্যন্ত অপেক্ষা করতে হয়নি। LocalDateTime.now() সরাসরি লিখলে এই টেস্ট দিনের কোন সময়ে চালাচ্ছেন তার ওপর নির্ভর করত — সকালে পাস, রাতে ফেল। শূন্য টাকার অর্ডারে exception এসেছে, আর fake-এর তালিকা প্রমাণ করে তখন কোনো SMS যায়নি। কোনো mock library লাগেনি।

কখন হাতে লেখা fake ভালো

  • fake ভালো যখন: একই নির্ভরতা অনেক টেস্টে লাগে — ৩০ বার when(...) লেখার চেয়ে একটি FakeSmsSender সহজ; আর ফল দেখে যাচাই করায় refactoring-এ টেস্ট কম ভাঙে।
  • mock ভালো যখন: নির্দিষ্ট ডাকটাই ব্যবসার নিয়ম (“ব্যর্থ পেমেন্টে কখনো SMS যাবে না”), বা exception-এর অবস্থা তৈরি করা দরকার (when(...).thenThrow(...))।
  • নিয়ম: শুধু ধীর বা অনিয়ন্ত্রিত বাইরের জিনিস (নেটওয়ার্ক, ঘড়ি, এলোমেলো সংখ্যা) নকল করুন; নিজের record বা সরল class-এর (পর্ব ২৫) mock প্রায় সবসময় অপ্রয়োজনীয়।

৭ · টেস্টযোগ্য নকশা আর flaky টেস্ট

কিছু কোড টেস্ট করা সহজ, কিছু প্রায় অসম্ভব। পার্থক্যটা সাধারণত টেস্টে নয়, কোডের নকশায়। এই কোডটি দেখুন:

class OrderService {                                      // টেস্ট করা কঠিন
    void place(String phone, long amount) {
        var sms = new BkashSmsGateway("api-key-123");        // নিজেই বানায় — বদলানো যায় না
        LocalDateTime now = LocalDateTime.now();             // আসল ঘড়ি — নিয়ন্ত্রণ নেই
        double vat = AppConfig.VAT_RATE;                      // static, যে কেউ বদলাতে পারে
        sms.send(phone, "...");
    }
}

এর টেস্ট লিখতে গেলে প্রতিবার আসল SMS যাবে, ফল নির্ভর করবে কখন চালাচ্ছেন তার ওপর, আর এক টেস্টে AppConfig.VAT_RATE বদলালে পরের টেস্ট সেই বদল দেখবে। অংশ ৬-এর সংস্করণে তিনটি সমস্যারই সমাধান একই নীতি:

  • নির্ভরতা বাইরে থেকে, constructor দিয়ে: পর্ব ২১-এর dependency injection. class নিজে new BkashSmsGateway() করে না; কেউ তাকে একটি SmsSender দেয়। আসল অ্যাপে আসল gateway, টেস্টে fake. এর ভিত্তি program to an interface — parameter-এর টাইপ interface, নির্দিষ্ট class নয়। পর্ব ৫০-এ Spring এই জোড়া লাগানোর কাজটা নিজে করবে।
  • সময়ও একটি নির্ভরতা: LocalDateTime.now()-এর বদলে LocalDateTime.now(clock), আর Clock constructor-এ। এলোমেলো সংখ্যার ক্ষেত্রেও একই: new Random()-এর বদলে বাইরে থেকে দেওয়া Random বা RandomGenerator, যাতে টেস্টে নির্দিষ্ট seed দেওয়া যায়।
  • বদলানো যায় এমন static এড়ানো: পর্ব ১৮-এর global state টেস্টের শত্রু, কারণ টেস্টগুলো একে অপরের অবস্থা দেখে ফেলে। হার, কনফিগ — এগুলোও constructor দিয়ে দিন। static final ধ্রুবক (যা কখনো বদলায় না) সমস্যা নয়।

যে নকশা টেস্ট সহজ করে, সেটাই নমনীয় নকশা; টেস্ট লিখতে কষ্ট হওয়া প্রায়ই নকশার সমস্যার প্রথম সংকেত। একে বলে testability, আর পরের পর্বের design pattern-গুলোর অনেকগুলোর পেছনে এই একই চিন্তা।

flaky টেস্ট — কখনো পাস, কখনো ফেল

কোড না বদলেও যে টেস্ট কখনো পাস আর কখনো ফেল করে, তাকে বলে flaky test. কয়েকবার “আবার চালাও, ঠিক হয়ে যাবে” করার পর দল টেস্টের লাল রং বিশ্বাস করা বন্ধ করে দেয় — তখন আসল bug-ও উপেক্ষিত হয়। প্রধান কারণ আর সমাধান:

কারণউদাহরণসমাধান
সময়LocalDate.now(); মধ্যরাত, মাসের শেষ বা অন্য time zone-এর CI সার্ভারে ভিন্ন ফলClock inject করুন, টেস্টে Clock.fixed (পর্ব ৩৮)
এলোমেলো মানseed ছাড়া Random; HashMap-এর ক্রম ধরে নেওয়া (পর্ব ৩১)নির্দিষ্ট seed; ক্রম নয়, বিষয়বস্তু যাচাই করুন
thread আর অপেক্ষাThread.sleep(100) করে ধরে নেওয়া যে কাজ শেষ; race condition (পর্ব ৪০)CountDownLatch, Future.get দিয়ে অপেক্ষা; সম্ভব হলে যুক্তি thread থেকে আলাদা করে টেস্ট
ভাগ করা অবস্থাstatic field বা একই ফাইল/ডেটাবেস সারি একাধিক টেস্টেপ্রতিটি টেস্টের নিজের ডেটা; @BeforeEach-এ নতুন করে তৈরি

integration test — অংশগুলো জোড়া লাগে তো?

unit test প্রমাণ করে প্রতিটি অংশ আলাদাভাবে ঠিক। কিন্তু আপনার SQL কি আসল ডেটাবেসে চলে? JSON-এর field-এর নাম কি অন্য সার্ভিসের সাথে মেলে? এগুলো ধরা পড়ে integration test-এ। জনপ্রিয় একটি পদ্ধতি Testcontainers library: টেস্টের শুরুতে Docker দিয়ে একটি আসল PostgreSQL বা MySQL চালু করে, টেস্ট শেষে মুছে ফেলে — প্রতিবার পরিষ্কার, আসল ডেটাবেস। পর্ব ৪৯-এ JDBC-র সাথে এর ব্যবহার আরও অর্থবহ হবে।

৮ · TDD — আগে টেস্ট, তারপর কোড; আর coverage-এর সীমা

এতক্ষণ কোড লিখে পরে টেস্ট লিখেছি। test-driven development (TDD) উল্টো দিক থেকে কাজ করে: আগে একটি ব্যর্থ টেস্ট, তারপর সেটা পাস করানোর মতো ন্যূনতম কোড, তারপর পরিষ্কার করা। এই চক্রের নাম red-green-refactor — IDE-তে ফেল করা টেস্ট লাল, পাস করা সবুজ দেখায়। একটি sign-up form-এর password-শক্তি যাচাইয়ের উদাহরণে তিন পাক ঘুরে দেখুন:

🔴 REDব্যর্থ টেস্ট লিখুন
🟢 GREENন্যূনতম কোডে পাস
🔵 REFACTORসবুজ রেখে পরিষ্কার
পাক ১ · REDclass-ই নেই
@Test void shortPassword_isWeak() {
    assertEquals(WEAK, PasswordChecker.check("ab12"));
}
compile error: PasswordChecker নেই  ← এটাও “লাল”
পাক ১ · GREENসবচেয়ে সরল কোড
static Strength check(String p) {
    return p.length() < 8 ? WEAK : STRONG;
}
টেস্ট ১ ✔  — digit-এর নিয়ম এখনও নেই, ইচ্ছা করেই
পাক ২ · REDনতুন নিয়ম, নতুন টেস্ট
@Test void noDigit_isWeak() {
    assertEquals(WEAK, PasswordChecker.check("abcdefgh"));
}
✘ expected: <WEAK> but was: <STRONG>
টেস্ট ১ ✔  টেস্ট ২ ✘
পাক ২ · GREENএকটি শর্ত যোগ
if (p.length() < 8) return WEAK;
if (p.chars().noneMatch(Character::isDigit)) return WEAK;
return STRONG;
টেস্ট ১ ✔  টেস্ট ২ ✔
পাক ৩ · REDসাধারণ password
@Test void commonPassword_isWeak() {
    assertEquals(WEAK, PasswordChecker.check("Password123"));
}
✘ expected: <WEAK> but was: <STRONG>  ৮+ অক্ষর, digit আছে
পাক ৩ · GREENতালিকা যোগ
static final Set<String> COMMON = Set.of("password123", "12345678", "bangladesh1");
if (p.length() < 8) return WEAK;
if (p.chars().noneMatch(Character::isDigit)) return WEAK;
if (COMMON.contains(p.toLowerCase())) return WEAK;
return STRONG;                          ✔ ✔ ✔
REFACTORআচরণ একই, গঠন ভালো
static final List<Predicate<String>> WEAK_RULES = List.of(
    p -> p.length() < 8,
    p -> p.chars().noneMatch(Character::isDigit),
    p -> COMMON.contains(p.toLowerCase()));

static Strength check(String p) {
    return WEAK_RULES.stream().anyMatch(r -> r.test(p)) ? WEAK : STRONG;
}
তিনটি টেস্ট আবার চালানো: ✔ ✔ ✔ — কিছু ভাঙেনি
  1. পাক ১, লাল। কোড লেখার আগে টেস্ট: ৮ অক্ষরের কম password দুর্বল। PasswordChecker এখনও নেই, তাই compile-ই হয় না — TDD-তে এটাও লাল ধাপ। লাভ: টেস্ট লিখতে গিয়েই API-র নকশা ঠিক হলো।
  2. পাক ১, সবুজ। শুধু এই টেস্ট পাস করানোর মতো কোড — দৈর্ঘ্যের একটি শর্ত। digit-এর নিয়ম মাথায় থাকলেও এখন লেখা হলো না, কারণ এখনও কোনো টেস্ট সেটা চায় না।
  3. পাক ২, লাল। নতুন নিয়ম: digit না থাকলে দুর্বল। “abcdefgh” ৮ অক্ষরের, তাই বর্তমান কোড STRONG বলে — টেস্ট ফেল। টেস্টটা আগে ফেল করতে দেখা জরুরি: এতে প্রমাণ হয় টেস্টটা সত্যিই কিছু যাচাই করে, সবসময় পাস করা অর্থহীন টেস্ট নয়।
  4. পাক ২, সবুজ। একটি শর্ত যোগ (পর্ব ৩৫-এর stream-এর noneMatch). এবার দুটি টেস্টই চালানো হয় — নতুন কোড পুরোনো আচরণ ভাঙেনি, সেটাও যাচাই হলো। প্রতিটি পাকে পুরো টেস্ট-সেট চলে।
  5. পাক ৩, লাল। “Password123” দৈর্ঘ্য আর digit — দুটো শর্তই পূরণ করে, অথচ এটা সবচেয়ে বেশি ব্যবহৃত password-গুলোর একটি। নতুন টেস্ট ফেল।
  6. পাক ৩, সবুজ। সাধারণ password-এর একটি ছোট তালিকা, ছোট হাতে রূপান্তর করে তুলনা। তিনটি টেস্টই সবুজ। কোডে এখন তিনটি প্রায় একই রকম if — একটি পুনরাবৃত্তির গন্ধ।
  7. REFACTOR. নিয়মগুলো একটি Predicate-এর তালিকায় (পর্ব ৩৪) — নতুন নিয়ম মানে তালিকায় একটি লাইন। আচরণ বদলায়নি, আর তিনটি টেস্ট সেটা কয়েক মিলিসেকেন্ডে প্রমাণ করল। তারপর আবার লাল — পরের নিয়ম, যেমন null ইনপুট।

নিয়ম স্পষ্ট এমন যুক্তিতে (চার্জ, যাচাই, পার্সার) TDD দারুণ; UI-র চেহারা নিয়ে পরীক্ষায় কম। যাঁরা সব সময় TDD করেন না, তাঁরাও bug ঠিক করার সময় একটি নিয়ম মানেন: আগে bug-টা ধরে এমন একটি ব্যর্থ টেস্ট, তারপর সমাধান। তাহলে bug আর কখনো নীরবে ফিরতে পারে না।

code coverage — কতটুকু কোড টেস্টে চলেছে

code coverage মাপে টেস্ট চলার সময় কোডের কত শতাংশ লাইন (line coverage) বা কত শতাংশ শাখা — if-এর দুই দিক (branch coverage) — অন্তত একবার চলেছে। Java-তে প্রচলিত টুল JaCoCo, যা কোন লাইন চলেছে আর কোনটা চলেনি তার রঙিন HTML রিপোর্ট দেয়।

কিন্তু উল্টোটা সত্য নয়। এই টেস্টটা দেখুন:

@Test
void fee_runs() {
    for (long a : new long[] {1, 500, 501, 2000, 2500}) FeeCalculator.fee(a);
    assertThrows(IllegalArgumentException.class, () -> FeeCalculator.fee(0));
}

এটা fee-এর প্রতিটি লাইন আর প্রতিটি শাখা চালায় — coverage ১০০%. অথচ ৫০০ টাকার < 500 bug ধরে না, কারণ ফলটা কেউ যাচাই করে না। coverage বলে কোন কোড চলেছে, কোনটা যাচাই হয়েছে তা বলে না। সুস্থ ব্যবহার: কম coverage-কে সতর্কসংকেত ধরা, বেশিকে প্রমাণ নয়। টেস্টের মান মাপার শক্তিশালী উপায় mutation testing — ভাবনার প্রশ্ন ৪-এ।

পরীক্ষা ও ইন্টারভিউতে

unit test আর integration test-এর পার্থক্য? unit test একটি অংশকে নির্ভরতা ছাড়া আলাদা করে যাচাই করে, দ্রুত; integration test কয়েকটি অংশ আসল ডেটাবেস বা নেটওয়ার্কসহ একসাথে।
@BeforeEach আর @BeforeAll-এর পার্থক্য? প্রথমটি প্রতিটি টেস্টের আগে চলে; দ্বিতীয়টি পুরো class-এ একবার, আর ডিফল্টে static হতে হয়।
mock আর stub-এর পার্থক্য? stub ঠিক করা উত্তর জোগায় (ইনপুট নিয়ন্ত্রণ); mock ডাকগুলো রেকর্ড করে আর কোন ডাক হওয়ার কথা তা verify করে।
TDD-র তিন ধাপ? red (ব্যর্থ টেস্ট), green (পাস করানোর ন্যূনতম কোড), refactor (টেস্ট সবুজ রেখে কোড পরিষ্কার)।
১০০% coverage মানে কি bug নেই? না — coverage শুধু বলে কোন কোড চলেছে, ফল যাচাই হয়েছে কি না বলে না।

৯ · AI-এর যুগে — AI-এর লেখা টেস্ট কি আসলে কিছু যাচাই করে?

“এই class-এর জন্য unit test লিখে দাও” — AI টুলের সবচেয়ে জনপ্রিয় কাজগুলোর একটি, আর ফলাফল দেখতে চমৎকার: অনেক টেস্ট, সুন্দর নাম, সবগুলো সবুজ। সমস্যা ঠিক এখানে। AI সাধারণত দেখে আপনার কোড কী করে, তারপর সেটাই যাচাই করে — কোড কী করা উচিত তা সে জানে না। অংশ ২-এর ফি-ক্যালকুলেটর দিলে AI খুব সম্ভবত লিখবে assertEquals(10, fee(500)), কারণ কোড সেটাই দেয়। bug তখন টেস্টের ভেতরে পাকাপাকি বসে গেল। এমন টেস্টকে বলে tautological test — implementation-এর আয়না, যা কখনো ফেল করতে পারে না।

AI যা করেকেন সমস্যাআপনি যা করবেন
বর্তমান আউটপুট দেখে প্রত্যাশিত মান লেখেbug-কেই “সঠিক” বলে পাকা করেপ্রত্যাশিত মান আপনি দিন — নিয়মের টেবিল বা হিসাব থেকে
কোনো assertion ছাড়া method ডাকে, বা শুধু assertNotNullcoverage বাড়ে, যাচাই শূন্যপ্রতিটি টেস্টে নির্দিষ্ট মান বা অবস্থা যাচাই চান
সব কিছুর mock বানায়, এমনকি record আর নিজের সরল classটেস্ট শুধু “mock যা বলেছি তা-ই ফিরল” প্রমাণ করে; refactor-এ ভাঙেশুধু ধীর/বাইরের নির্ভরতা নকল; বাকি আসল object
শুধু happy path০, ঋণাত্মক, null, কিনারার মান বাদboundary value-এর তালিকা চান, তারপর নিজে যাচাই করুন
টেস্টে LocalDate.now(), Thread.sleep, seed ছাড়া Randomflaky টেস্টClock, seed, latch চান
পুরোনো JUnit 4 (@Before, @Test(expected = ...)) মিশিয়ে ফেলেJUnit 5-এর সাথে মেলে না বা চলেই না“শুধু JUnit 5 Jupiter” স্পষ্ট বলুন

AI-এর সবচেয়ে ভালো ব্যবহার উল্টো দিকে: নিয়মটা বলে তাকে edge case প্রস্তাব করতে বলা — সে প্রায়ই এমন কিছু মনে করিয়ে দেয় যা আপনি ভাবেননি। তারপর প্রতিটির প্রত্যাশিত ফল আপনি নিজে নিয়ম থেকে ঠিক করবেন। আর একটি সহজ পরীক্ষা: AI-এর লেখা টেস্ট পেলে কোডে ইচ্ছা করে একটি ছোট bug ঢোকান (<=-কে <). কোনো টেস্ট লাল না হলে সেগুলো আপনাকে রক্ষা করছে না।

AI-কে টেস্ট লেখাতে বা রিভিউ করাতে এই কাঠামো ব্যবহার করুন:

আমি Java 21 আর JUnit 5 (Jupiter) ব্যবহার করছি। নিচের class-এর জন্য টেস্ট চাই। [class-এর কোড] নিয়মগুলো (এটাই সঠিক আচরণ, কোড নয়): [যেমন: ১–৫০০ টাকায় চার্জ ৫, ৫০১–২০০০ টাকায় ১০, তার বেশি ১%; ০ বা ঋণাত্মক হলে IllegalArgumentException] শর্ত: ১. প্রত্যাশিত মান নিয়ম থেকে হিসাব করবে, কোডের বর্তমান আউটপুট থেকে নয়। কোড আর নিয়মে অমিল পেলে আলাদা করে জানাবে। ২. আগে edge case-এর একটি তালিকা দাও (০, ঋণাত্মক, slab-এর কিনারার দুই পাশ, খুব বড় মান, null/খালি থাকলে) আর প্রতিটির কারণ লেখো। ৩. টেবিলের মতো নিয়মে @ParameterizedTest আর @CsvSource ব্যবহার করবে। ৪. exception-এর ক্ষেত্রে assertThrows-এর ফেরত দেওয়া exception থেকে message আর অবস্থা অক্ষত কি না যাচাই করবে। ৫. শুধু বাইরের নির্ভরতার (SMS, ঘড়ি, নেটওয়ার্ক) নকল বানাবে; record বা সরল class-এর mock নয়। সময়ের জন্য Clock.fixed. ৬. assertEquals-এ প্রত্যাশিত মান প্রথমে। প্রতিটি টেস্টে একটি আচরণ, নাম methodName_condition_expected ধাঁচে। ৭. JUnit 4-এর কিছু (@Before, @Test(expected=...)) ব্যবহার করবে না। ৮. শেষে বলো: কোডে কোন এক লাইনের ভুল ঢোকালে কোন টেস্ট ফেল করবে।

AI-এর লেখা টেস্ট হাতে পেলে — যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।

প্র ০১প্রচলিত গল্প অনুযায়ী Kent Beck আর Erich Gamma ১৯৯৭ সালে একটি বিমানযাত্রায় JUnit লিখেছিলেন। এর আগে Smalltalk-এ SUnit ছিল। এত ছোট একটি framework কেন এত প্রভাবশালী হলো?

Kent Beck ১৯৯০-এর দশকে Smalltalk-এ কাজ করার সময় টেস্ট লেখার একটি সরল কাঠামো তৈরি করেন, যা পরে SUnit নামে পরিচিত হয়। ধারণাগুলো আজও চেনা: প্রতিটি টেস্ট একটি method, প্রস্তুতির জন্য setUp, পরিষ্কারের জন্য tearDown, আর একটি runner যা সব টেস্ট চালিয়ে পাস-ফেল জানায়। প্রচলিত গল্প হলো, ১৯৯৭ সালে OOPSLA সম্মেলনে যাওয়ার পথে Zurich থেকে Atlanta-র বিমানে Beck আর Erich Gamma (Design Patterns বইয়ের “Gang of Four”-এর একজন — পরের পর্বে তাঁদের কথা আসবে) মিলে এই ধারণাকে Java-তে আনেন, pair programming করে, আগে টেস্ট লিখে। গল্পের খুঁটিনাটি নিয়ে ভিন্ন বর্ণনা থাকতে পারে, কিন্তু ফলাফল ঐতিহাসিক।

JUnit-এর শক্তি ছিল তার ছোট আকারে। কোনো বড় টুল কেনা লাগত না, আলাদা টেস্টিং ভাষা শিখতে হতো না — টেস্ট লেখা হতো একই Java-তে, একই IDE-তে, developer নিজেই লিখতেন। এর আগে অনেক প্রতিষ্ঠানে টেস্টিং ছিল আলাদা QA দলের কাজ, কোড লেখা শেষ হওয়ার সপ্তাহখানেক পরে। JUnit টেস্টকে প্রোগ্রামারের প্রতিদিনের অভ্যাসে নিয়ে এলো।

এর নকশা এত সফল যে অন্য ভাষায় একই ছাঁচের framework তৈরি হলো — NUnit (.NET), PyUnit (Python-এর unittest), CppUnit — সব মিলিয়ে নাম হলো “xUnit পরিবার”। JUnit নিজেও Java-র বিবর্তনের সাথে বদলেছে। JUnit 3-এ টেস্ট class-কে TestCase extend করতে হতো, আর method-এর নাম test দিয়ে শুরু হতে হতো — runner নামের রীতি দেখে reflection দিয়ে খুঁজত। Java 5-এ annotation আসার পর JUnit 4 (২০০৬) @Test আনল — পর্ব ৪৫-এর @MyTest-এর মতো। Java 8-এর lambda আসার পর JUnit 5 (২০১৭) assertThrows(..., () -> ...) আর assertAll সম্ভব করল, আর নিজেকে Platform-Jupiter-Vintage-এ ভাগ করল, যাতে IDE আর build tool একটি সাধারণ ইঞ্জিনে যেকোনো টেস্ট-ইঞ্জিন চালাতে পারে।

মূল উপলব্ধি: যে টুল কাজটাকে সহজ আর developer-এর নিজের পরিবেশের অংশ করে দেয়, সেটাই অভ্যাস বদলায়; JUnit টেস্টিংকে আলাদা বিভাগের কাজ থেকে প্রতিদিনের প্রোগ্রামিংয়ের অংশ বানিয়েছিল।

প্র ০২Kent Beck-এর ২০০২ সালের বই TDD-কে জনপ্রিয় করে। ২০১৪ সালে Ruby on Rails-এর স্রষ্টা DHH লিখলেন “TDD is dead”. এই বিতর্ক থেকে কী শেখার আছে?

Kent Beck-এর Test-Driven Development: By Example (২০০২) red-green-refactor চক্রকে একটি শৃঙ্খলা হিসেবে দাঁড় করায়। TDD-র পক্ষে যুক্তি: টেস্ট আগে লিখলে আপনি ব্যবহারকারীর চোখ থেকে API ভাবেন; প্রতিটি লাইনের পেছনে একটি টেস্ট থাকে; ছোট ধাপে এগোনোয় ভুল দ্রুত ধরা পড়ে; আর টেস্টযোগ্য নকশা — নির্ভরতা বাইরে থেকে, ছোট method — প্রায় নিজে থেকেই আসে।

২০১৪ সালের এপ্রিলে David Heinemeier Hansson (DHH) “TDD is dead. Long live testing.” শিরোনামে লেখেন, টেস্টিং জরুরি কিন্তু কঠোর test-first মতবাদ ক্ষতিও করে। তাঁর পরিচিত অভিযোগ “test-induced design damage”: শুধু unit test-এর সুবিধার জন্য কোডে অপ্রয়োজনীয় interface, স্তর আর পরোক্ষতা যোগ হয়, যা কোডকে বোঝা কঠিন করে। তাঁর মতে, Rails-এর মতো ডেটাবেস-কেন্দ্রিক অ্যাপে সবকিছু mock করে unit test লেখার চেয়ে আসল ডেটাবেসসহ টেস্ট বেশি মূল্যবান। এরপর DHH, Kent Beck আর Martin Fowler কয়েকটি প্রকাশ্য আলোচনায় বসেন, যা “Is TDD Dead?” নামে পরিচিত।

আলোচনায় দেখা গেল মতভেদ যতটা মনে হয়েছিল ততটা নয়। Beck নিজেও স্বীকার করেন, তিনি সব কোড TDD-তে লেখেন না, আর অতিরিক্ত mock ব্যবহার — যেখানে টেস্ট ভেতরের প্রতিটি ডাক বেঁধে দেয় — টেস্টকে ভঙ্গুর করে। আসল প্রশ্নটা হয়ে দাঁড়াল: কোন কোডে কোন ধরনের টেস্ট সবচেয়ে বেশি আত্মবিশ্বাস দেয়, আর কোন খরচে? এই আলোচনার সাথে TDD-র দুই “ঘরানা”-ও জড়িত: classic বা Detroit ঘরানা আসল object যতটা সম্ভব ব্যবহার করে আর ফল যাচাই করে; mockist বা London ঘরানা প্রতিটি class আলাদা করে mock দিয়ে তাদের মধ্যের ডাক যাচাই করে। অংশ ৬-এ “fake দিয়ে ফল যাচাই” বনাম “mock দিয়ে ডাক যাচাই”-এর যে আলোচনা, সেটা এই বিতর্কেরই ছোট রূপ।

ব্যবহারিক শিক্ষা: স্পষ্ট নিয়মের যুক্তিতে TDD খুব শক্তিশালী; bug ঠিক করতে আগে ব্যর্থ টেস্ট প্রায় সর্বজনীনভাবে ভালো অভ্যাস; কিন্তু শুধু টেস্ট সহজ করার জন্য নকশাকে জটিল করা — যেমন প্রতিটি class-এর জন্য interface — সবসময় যুক্তিসংগত নয়।

মূল উপলব্ধি: TDD একটি হাতিয়ার, ধর্ম নয়; লক্ষ্য হলো কম খরচে বেশি আত্মবিশ্বাস, আর কোন পদ্ধতি সেটা দেবে তা নির্ভর করে কোডের ধরনের ওপর।

প্র ০৩Mike Cohn-এর test pyramid বলে unit test সবচেয়ে বেশি হওয়া উচিত। অন্যদিকে “testing trophy” বলে integration test-এ বেশি জোর দাও। কোনটা ঠিক, আর কেন দুটো মত তৈরি হলো?

Mike Cohn তাঁর ২০০৯ সালের বই Succeeding with Agile-এ test pyramid জনপ্রিয় করেন: নিচে অনেক unit test, মাঝে কিছু service-স্তরের টেস্ট, ওপরে অল্প UI টেস্ট। প্রেক্ষাপট বোঝা জরুরি। তখন অনেক দল প্রধানত UI-ভিত্তিক স্বয়ংক্রিয় টেস্টের ওপর নির্ভর করত — ব্রাউজারে ক্লিক করে চলা টেস্ট, যা ছিল খুব ধীর, ঘনঘন অকারণে ভাঙত, আর ফেল করলে কারণ খুঁজতে ঘণ্টা লাগত। এই “উল্টো পিরামিড” বা “আইসক্রিম কোন”-এর বিরুদ্ধে Cohn বললেন: ভিত্তি হোক দ্রুত আর নির্ভুল unit test.

প্রায় এক দশক পরে, JavaScript জগতে Kent C. Dodds “testing trophy” প্রস্তাব করেন: নিচে static analysis (টাইপ-যাচাই, lint), তার ওপর কিছু unit test, সবচেয়ে চওড়া অংশ integration test, ওপরে অল্প end-to-end. যুক্তি: ব্যবহারকারী একটি একক function দেখেন না, দেখেন কয়েকটি অংশ মিলে কী ঘটে; আর অতিরিক্ত mock-ভিত্তিক unit test প্রায়ই implementation-এর খুঁটিনাটি বেঁধে দেয়, ফলে refactor করলে টেস্ট ভাঙে অথচ আসল bug ধরে না। Testcontainers-এর মতো টুল integration test-কে সস্তাও করেছে।

লক্ষ করুন, দুটো মতই একই মূল্যবোধ থেকে আসে: টেস্টের খরচ (লেখা, চালানোর সময়, রক্ষণাবেক্ষণ) আর আত্মবিশ্বাসের মধ্যে ভারসাম্য। পার্থক্য আসে পরিবেশ থেকে। Java-তে জটিল ব্যবসায়িক নিয়ম — চার্জ, সুদ, কর, স্টক-হিসাব — এমন কোডে unit test সস্তা আর খুব কার্যকর। আবার যে service প্রধানত ডেটাবেস থেকে পড়ে JSON ফেরত দেয়, সেখানে নিজের যুক্তি সামান্য; আসল ঝুঁকি SQL, mapping আর কনফিগারেশনে — সেখানে integration test বেশি মূল্য দেয়।

মূল উপলব্ধি: পিরামিড বা ট্রফি কোনো আইন নয়, একটি খরচ-লাভের মডেল; আপনার কোডে ঝুঁকি কোথায় — যুক্তিতে না সংযোগে — সেটা দেখে টেস্টের ভাগ ঠিক করুন।

প্র ০৪coverage বলে কোন কোড চলেছে, কিন্তু টেস্ট ভালো কি না বলে না। mutation testing (যেমন Java-র PIT) কীভাবে এই ফাঁক পূরণ করে, আর সবাই কেন এটা ব্যবহার করে না?

mutation testing-এর ধারণা পুরোনো — ১৯৭০-এর দশকে Richard Lipton-সহ কয়েকজন গবেষকের কাজ থেকে এর শুরু। প্রশ্নটা উল্টো দিক থেকে করা হয়: “আমার টেস্টগুলো কি সত্যিই ভুল ধরতে পারে?” উত্তর খুঁজতে টুলটি আপনার কোডে ইচ্ছা করে ছোট ছোট ভুল ঢোকায়, প্রতিটি আলাদা সংস্করণে: <-কে <=, +-কে -, return x-কে return 0, একটি method ডাক মুছে ফেলা। প্রতিটি পরিবর্তিত সংস্করণকে বলে mutant. তারপর প্রতিটি mutant-এর ওপর পুরো টেস্ট-সেট চালানো হয়।

অন্তত একটি টেস্ট ফেল করলে mutant “killed” — আপনার টেস্ট এই ভুল ধরতে পারে। সব টেস্ট পাস করলে mutant “survived” — কোড ভুল হলেও টেস্ট টের পায়নি। killed mutant-এর শতাংশই mutation score. অংশ ৮-এর সেই অর্থহীন টেস্টের কথা ভাবুন: coverage ১০০%, কিন্তু কোনো ফল যাচাই নেই — তাই exception-এর শর্তটি ছাড়া প্রায় সব mutant বেঁচে যাবে, mutation score খুব কম। coverage যা লুকিয়েছিল, mutation testing তা খুলে দেয়। আর একটি বেঁচে থাকা mutant খুব নির্দিষ্ট তথ্য দেয়: “লাইন ৫-এ <-কে <= করলে কেউ টের পায় না” — মানে ঠিক এখানে একটি boundary টেস্ট নেই।

Java-তে প্রচলিত টুল PIT (pitest), যা Maven বা Gradle plugin হিসেবে চলে আর bytecode স্তরে mutant তৈরি করে। তাহলে সবাই কেন ব্যবহার করে না? প্রথমত খরচ: হাজারো mutant, প্রতিটির জন্য টেস্ট চালানো — বড় প্রজেক্টে সময়সাপেক্ষ। দ্বিতীয়ত “equivalent mutant”: কিছু পরিবর্তন আচরণ একটুও বদলায় না (যেমন এমন জায়গায় শর্ত বদলানো যেখানে দুটো পথ একই ফল দেয়), সেগুলো কোনো টেস্টই মারতে পারে না, অথচ রিপোর্টে বেঁচে থাকা হিসেবে দেখায় — মানুষকে বিচার করতে হয়। তবু গুরুত্বপূর্ণ মডিউলে (টাকা-পয়সার হিসাব, নিরাপত্তা) এটা টেস্টের মান যাচাইয়ের সবচেয়ে সৎ উপায়গুলোর একটি।

মূল উপলব্ধি: টেস্টের মান মাপার সঠিক প্রশ্ন “কতটুকু কোড চলেছে” নয়, “ভুল ঢোকালে কি টেস্ট ধরে?” — mutation testing ঠিক এই প্রশ্নটা স্বয়ংক্রিয়ভাবে করে।

প্র ০৫২০১২ সালে Knight Capital প্রায় ৪৫ মিনিটে বিপুল অর্থ হারায়, ২০১৪ সালে Apple-এর “goto fail” bug নিরাপদ সংযোগ যাচাই এড়িয়ে যায়। এসব ঘটনায় টেস্ট কী করতে পারত, আর কী পারত না?

Apple-এর goto fail (২০১৪): iOS আর OS X-এর SSL/TLS কোডে, C ভাষায়, একটি goto fail; লাইন ভুল করে দুবার লেখা হয়েছিল। দ্বিতীয়টি কোনো if-এর অধীনে ছিল না, তাই সবসময় চলত — ফলে সার্ভারের সার্টিফিকেটের স্বাক্ষর যাচাইয়ের একটি গুরুত্বপূর্ণ ধাপ বাদ পড়ত, আর মাঝখানে বসা আক্রমণকারী নিজেকে বৈধ সার্ভার হিসেবে চালাতে পারত। অনেক বিশেষজ্ঞ তখন লিখেছিলেন, একটি সাধারণ টেস্ট — ভুল স্বাক্ষরওয়ালা সার্টিফিকেট দিয়ে সংযোগ করে দেখা যে সেটা প্রত্যাখ্যাত হয় কি না — এই bug ধরে ফেলত। এটা অংশ ৫-এর শিক্ষারই চরম রূপ: শুধু “বৈধ সংযোগ সফল হয়” (happy path) টেস্ট করলে চলে না; “অবৈধ জিনিস প্রত্যাখ্যাত হয়” — সেটাও যাচাই করতে হয়।

Knight Capital (১ আগস্ট ২০১২): যুক্তরাষ্ট্রের এই শেয়ার-লেনদেন প্রতিষ্ঠান একটি নতুন সফটওয়্যার আটটি সার্ভারে বসাচ্ছিল। মার্কিন নিয়ন্ত্রক সংস্থা SEC-এর পরে প্রকাশিত বিবরণ অনুযায়ী, একটি সার্ভারে নতুন কোড পৌঁছায়নি; আর নতুন কোডে একটি পুরোনো flag নতুন অর্থে ব্যবহার করা হয়েছিল। ফলে সেই সার্ভারে বহু বছর অব্যবহৃত পুরোনো একটি ফিচার আবার চালু হয়ে বাজারে অনিয়ন্ত্রিত অর্ডার পাঠাতে থাকে। প্রায় ৪৫ মিনিটে প্রতিষ্ঠানটির ক্ষতি দাঁড়ায় ৪৪ কোটি ডলারের বেশি (SEC-এর বিবরণে ৪৬ কোটি ডলারের বেশি)।

এখানে সৎ থাকা জরুরি: Knight-এর ঘটনা মূলত deployment আর প্রক্রিয়ার ব্যর্থতা — হাতে করা সার্ভার-আপডেট, পরীক্ষা ছাড়া পুরোনো flag-এর পুনর্ব্যবহার, অব্যবহৃত “মৃত” কোড না মুছে রাখা, আর বিপদে দ্রুত থামানোর ব্যবস্থার অভাব। একটি unit test একা এটা থামাত না, কারণ প্রতিটি সার্ভারের কোড নিজে নিজে “ঠিক” ছিল; ভুল ছিল সার্ভারগুলোর অসমান অবস্থায়। যা সাহায্য করত: স্বয়ংক্রিয় deployment যা সব সার্ভারে একই সংস্করণ নিশ্চিত করে আর যাচাই করে, মৃত কোড মুছে ফেলা (যাতে পুরোনো পথ জেগে উঠতে না পারে), আর deployment-এর পরে বাস্তব পরিবেশে স্বয়ংক্রিয় যাচাই।

মূল উপলব্ধি: টেস্ট কোডের যুক্তির ভুল ধরতে অসাধারণ, বিশেষত “খারাপ ইনপুট প্রত্যাখ্যাত হয়” ধরনের; কিন্তু deployment, কনফিগারেশন আর প্রক্রিয়ার ভুলের জন্য আলাদা স্বয়ংক্রিয় সুরক্ষা লাগে — একটি স্তর কখনো যথেষ্ট নয়।

অনুশীলন

বেশিরভাগ কাজে নতুন কোড লিখতে হবে না — ওপরের কোড বদলে দেখা আর ভাবাই আসল কাজ।

  1. bug ঠিক করুন, তারপর একটি নতুন bug ঢোকান: অংশ ২-এর কোডে amount < 500-কে <= করে চালান। এবার amount <= 2000-কে < 2000 করুন। না চালিয়ে বলুন কোন টেস্ট ফেল করবে, আর ২০০১ টাকার কোনো টেস্ট না থাকলে কোন bug অধরা থেকে যেতে পারত।

    প্রথম বদলের পর ছয়টি টেস্টই পাস, শেষ লাইন Tests run: 6, Failures: 0. দ্বিতীয় বদলের পর ২০০০ টাকা দ্বিতীয় slab-এ না পড়ে তৃতীয়তে চলে যায়, আর চার্জ হয় 2000 / 100 = 20, তাই ফেল করবে upper edge of slab 2: expected: <10> but was: <20>. এটা ধরা পড়ল কারণ টেস্টে ঠিক ২০০০ আছে। কিন্তু কিনারার অন্য পাশ — ২০০১ — নেই। ধরুন কেউ শর্তটা ভুল করে amount <= 2001 লিখল: তখন ২০০১ টাকায় চার্জ ২০-এর বদলে ১০ হবে, অথচ ছয়টি টেস্টের একটিও ফেল করবে না — ২০০০-এ ১০ আর ২৫০০-এ ২৫ তখনো ঠিক। boundary value analysis-এর নিয়ম তাই প্রতিটি সীমার দুই পাশ: ৫০০/৫০১ আর ২০০০/২০০১।

  2. ঘড়ির কিনারা: অংশ ৬-এর OrderService-এ রাত ৮টার নিয়ম আছে (getHour() >= 20). boundary value analysis অনুযায়ী কোন কোন সময়ের জন্য টেস্ট লেখা উচিত? at("...")-এ কী কী মান দেবেন, আর প্রত্যাশিত ডেলিভারির তারিখ কী?

    সীমা ঠিক ২০:০০. দুই পাশ: 2026-10-18T19:59 → তখনো দুই দিন, ডেলিভারি ২০২৬-১০-২০; 2026-10-18T20:00 → তিন দিন, ২০২৬-১০-২১. আরও দেখুন 23:59 (তিন দিন → ২১ তারিখ) আর পরদিন 00:00 (দুই দিন → আবারও ২১ তারিখ) — দুটো একই তারিখ দেয়; এটাই কি চাওয়া হয়েছিল? টেস্ট লিখতে গিয়ে নিয়মের অস্পষ্টতা বেরিয়ে আসা টেস্টের একটি বড় উপকার। আরেকটি সূক্ষ্ম বিষয়: Clock-এ zone Asia/Dhaka না দিয়ে UTC দিলে “রাত ৮টা” আসলে ঢাকার রাত ২টা হয়ে যেত — time zone-ও একটি ইনপুট।

  3. কোন test double? প্রতিটির জন্য dummy, stub, fake, mock — কোনটা সবচেয়ে মানানসই, এক লাইনে কারণসহ: (ক) ReportService-এর constructor একটি EmailSender চায়, কিন্তু যে টেস্ট লিখছেন সেখানে শুধু PDF-এর পৃষ্ঠাসংখ্যা যাচাই; (খ) মুদ্রা বিনিময় হার একটি বাইরের API থেকে আসে, টেস্টে হার ১ ডলার = ১২০ টাকা ধরে হিসাব যাচাই; (গ) ব্যর্থ পেমেন্টে কখনো SMS যাবে না — এটা নিশ্চিত করা; (ঘ) ৪০টি টেস্টে অর্ডার সেভ আর খোঁজা লাগে, আসল ডেটাবেস ছাড়া।

    (ক) dummy — EmailSender লাগছে শুধু constructor ভরাতে; ডাকা হলে ফেল করে এমন একটি lambda দিলে আরও নিরাপদ। (খ) stub — rates -> 120 ধরনের নির্দিষ্ট উত্তর, কোডে ইনপুট জোগানোই উদ্দেশ্য। (গ) mock (অথবা fake-এর খালি তালিকা) — “ডাক হয়নি” যাচাই: verify(sms, never()).send(anyString(), anyString()), বা assertTrue(fake.sent.isEmpty()). (ঘ) fake — Map-ভিত্তিক InMemoryOrderRepository একবার লিখলে ৪০টি টেস্টেই চলে, প্রতিটিতে when(...) লিখতে হয় না, আর সেভ-খোঁজার আসল আচরণ (সেভ করা জিনিস পরে পাওয়া যায়) বজায় থাকে।

  4. একটি tautological test চিনুন: AI নিচের টেস্টটি লিখেছে। এটা কী যাচাই করে, আর কীভাবে ভালো করবেন? when(rates.percentFor("food")).thenReturn(5); assertEquals(5, rates.percentFor("food"));

    এটা কিছুই যাচাই করে না। mock-কে বলা হলো “food চাইলে 5 দাও”, তারপর যাচাই করা হলো mock সত্যিই 5 দেয় কি না — যা Mockito-র নিজের কাজ, আপনার কোডের নয়। আপনার কোনো class এখানে চলেইনি; আপনার কোডে যেকোনো bug থাকলেও এটা সবুজ থাকবে। ভালো সংস্করণে stub-টি আসল class-এ যাবে আর যাচাই হবে সেই class-এর ফল: var calc = new InvoiceCalculator(category -> 5); assertEquals(1050, calc.total(1000, "food")); — এখানে প্রত্যাশিত ১০৫০ এসেছে নিয়ম থেকে (১০০০ + ৫%). নিয়ম: টেস্টে অন্তত একটি আপনার নিজের কোড চলতে হবে, আর assertion সেই কোডের ফলের ওপর হতে হবে।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমTDD-তে একটি নতুন নিয়ম যোগ করার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 📝 নতুন নিয়মের জন্য একটি টেস্ট লেখা
  2. 🔴 টেস্ট চালিয়ে দেখা যে সেটা ফেল করছে
  3. ✍️ টেস্ট পাস করানোর মতো ন্যূনতম কোড লেখা
  4. 🟢 সব টেস্ট চালিয়ে দেখা যে সব সবুজ
  5. 🧹 টেস্ট সবুজ রেখে কোড পরিষ্কার করা (refactor)
  6. 🔁 সব টেস্ট আবার চালিয়ে নিশ্চিত হওয়া যে কিছু ভাঙেনি

JUnit 5-এ assertEquals ব্যবহারের সঠিক রীতি কোনটি?

  • assertEquals(actual, expected)
  • assertEquals(expected, actual)
  • যেকোনো ক্রমে, ফলাফলে কোনো পার্থক্য নেই
  • শুধু String-এর জন্য, সংখ্যার জন্য assertTrue

JUnit 5-এ একটি টেস্ট class-এ তিনটি @Test method আর একটি @BeforeEach method আছে (ডিফল্ট lifecycle). সব টেস্ট চালালে কী ঘটে?

  • একটি instance তৈরি হয়, @BeforeEach একবার চলে
  • তিনটি instance তৈরি হয়, @BeforeEach একবার চলে
  • তিনটি instance তৈরি হয়, প্রতিটি টেস্টের আগে @BeforeEach চলে — মোট তিনবার
  • টেস্ট লেখার ক্রমে চলে, প্রতিটির পরে @BeforeEach চলে

একটি চার্জ-হিসাবে নিয়ম “৫০০ টাকা পর্যন্ত চার্জ ৫, তার বেশি হলে ১০”. boundary value analysis অনুযায়ী কোন ইনপুটগুলো সবচেয়ে জরুরি?

  • ০, ৫০০, ৫০১
  • ১০০, ২৫০, ১০০০
  • ১০,০০০ আর ১,০০,০০০
  • প্রতিবার এলোমেলোভাবে বাছা যেকোনো মান

একটি টেস্টে নির্দিষ্ট argument দিয়ে SmsSender.send ঠিক একবার ডাকা হয়েছে কি না যাচাই করা দরকার। কোন test double এর জন্য সবচেয়ে সরাসরি?

  • dummy
  • stub
  • Clock.fixed
  • mock (যেমন Mockito-র verify)

একটি টেস্ট-সেটের line coverage ১০০%. এ থেকে নিশ্চিতভাবে কী বলা যায়?

  • কোডে কোনো bug নেই
  • টেস্ট চলার সময় প্রতিটি লাইন অন্তত একবার চলেছে
  • প্রতিটি সম্ভাব্য ইনপুট যাচাই হয়েছে
  • mutation score-ও ১০০%

একটি টেস্ট কোড না বদলেও কখনো পাস আর কখনো ফেল করে। ভেতরে LocalDate.now() দিয়ে “আজ মাসের শেষ দিন কি না” হিসাব হয়। সবচেয়ে ভালো সমাধান কোনটি?

  • ফেল করলে টেস্টটি স্বয়ংক্রিয়ভাবে আবার চালানো
  • টেস্টে @Disabled দিয়ে বাদ রাখা
  • কোডে Clock inject করা আর টেস্টে Clock.fixed দেওয়া
  • টেস্টের শুরুতে Thread.sleep যোগ করা

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. টেস্ট = জানা ইনপুট, প্রত্যাশিত ফল, স্বয়ংক্রিয় তুলনা। এর বড় কাজ regression ধরা আর নির্ভয়ে refactor করতে দেওয়া। pyramid: অনেক unit, কম integration, অল্প end-to-end.
  2. JUnit 5: @Test, assertEquals(expected, actual), assertThrows (exception ফেরত দেয়), assertAll; প্রতিটি টেস্টের জন্য নতুন instance আর @BeforeEach — টেস্ট একে অপরের ওপর নির্ভর করবে না। নাম পড়েই যেন বোঝা যায় কী ভাঙল।
  3. টেবিলের মতো নিয়মে @ParameterizedTest + @CsvSource; প্রতিটি সারি আলাদা টেস্ট। প্রতিটি সীমার দুই পাশ, ০, ঋণাত্মক, null/খালি — আর exception-এর পরে অবস্থা অক্ষত কি না।
  4. test double: dummy, stub, fake, spy, mock. নির্ভরতা constructor দিয়ে, সময় Clock দিয়ে, বদলানো যায় এমন static নয় — তাহলে কোড টেস্টযোগ্য আর flaky টেস্ট থেকে মুক্ত। শুধু বাইরের, ধীর জিনিস নকল করুন।
  5. TDD = red → green → refactor; bug ঠিক করার আগে ব্যর্থ টেস্ট। coverage বলে কী চলেছে, কী যাচাই হয়েছে তা বলে না। AI-এর টেস্টে প্রত্যাশিত মান নিজে মিলিয়ে নিন, আর একটি ইচ্ছাকৃত bug দিয়ে পরীক্ষা করুন।

পরের পর্বে: এই পর্বে বারবার দেখলেন, টেস্ট লেখা সহজ হয় যখন নকশা ভালো — interface, constructor দিয়ে নির্ভরতা, ছোট দায়িত্ব। পরের পর্বে সেই নকশার পরিচিত সমাধানগুলো: design pattern আর clean code — পরিচিত নকশার সমাধান, আর কখন pattern না ব্যবহার করাই ভালো।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান