JUnit සහ Mockito සමග Unit Testing පටන් ගමු! | SC Guide

JUnit සහ Mockito සමග Unit Testing පටන් ගමු! | SC Guide

දන්නවද බ්‍රෝ, කෝඩ් ලියනවා කියන්නේ හරියට ගෙයක් හදනවා වගේ වැඩක්. අපි කොච්චර පරිස්සමෙන්, කොලිටියට වැඩේ කළත්, පොඩි පොඩි අඩුපාඩු ඇතිවෙන්න තියෙන ඉඩ වැඩියි. හැබැයි ඉතින්, ගෙයක් හදනකොට ගඩොලින් ගඩොල, සිමෙන්ති ගුලියෙන් ගුලිය චෙක් කරනවා වගේ, අපේ කෝඩ් එකේ හැම පොඩි කොටසක්ම හරියට වැඩ කරනවද කියලා චෙක් කරන්න පුළුවන් නම්, අන්තිමට ලොකු අවුල් එන එක නවත්තගන්න පුළුවන් නේද? අන්න ඕකට තමයි අපි Unit Testing කියන්නේ!

අද අපි බලමු කොහොමද මේ Unit Testing කියන කන්සෙප්ට් එක අපේ සොෆ්ට්වෙයාර් ඩෙවලොප්මන්ට් එකට ගේන්නේ කියලා. විශේෂයෙන්ම Java පාවිච්චි කරන අයට පට්ටම වැදගත් වෙන JUnit සහ Mockito කියන ෆ්‍රේම්වර්ක්ස් දෙක ගැන තමයි අපි කතා කරන්නේ. මේවා සෙට් කරගන්නේ කොහොමද, ඒ වගේම අපේ සර්විස් ක්ලාස් (Service Class) එකක් ටෙස්ට් කරන්නේ කොහොමද කියලත් අපි ප්‍රැක්ටිකල් විදිහට බලමු. එහෙනම්, අපි වැඩේට බහිමු!

Unit Testing කියන්නේ මොකක්ද?

සරලවම කිව්වොත්, Unit Testing කියන්නේ අපේ කෝඩ් එකේ තියෙන පොඩිම, තනියම ටෙස්ට් කරන්න පුළුවන් කොටස් (Units) අරගෙන, ඒ හැම එකක්ම අපි බලාපොරොත්තු වෙන විදිහට වැඩ කරනවද කියලා චෙක් කරන එක. මේ Units කියන්නේ method එකක්, function එකක්, නැත්නම් class එකක් වෙන්න පුළුවන්. උදාහරණයක් විදිහට, ඔයාගේ කෝඩ් එකේ තියෙන `calculateTotal()` කියන method එකට 2 + 2 දුන්නම 4 එනවද කියලා චෙක් කරන එක Unit Test එකක්.

ඇයි Unit Testing මෙච්චර වැදගත්?

  • බග්ස් ඉක්මනින් හොයාගන්න පුළුවන් (Early Bug Detection): අපි කෝඩ් ලියන ගමන්ම, කෝඩ් එක හැදෙන ගමන්ම බග්ස් හොයාගන්න පුළුවන් නම්, අන්තිමට ලොකු ප්‍රොඩක්ට් එකක් හදලා ඉවර වුණාම බග්ස් හොයනවාට වඩා ගොඩක් වෙලාව ඉතුරු කරගන්න පුළුවන්.
  • කෝඩ් රීෆැක්ටර් කරන්න බයක් නෑ (Confidence in Refactoring): කෝඩ් එකේ මොකක් හරි වෙනසක් කරනකොට, ඒ වෙනස නිසා වෙන තැනක අවුලක් යයි කියලා බය වෙන්න ඕනේ නෑ. මොකද, ටෙස්ට්ස් හරහා අපිට ෂුවර් කරගන්න පුළුවන් අනිත් කොටස් වලට හානියක් වුණේ නෑ කියලා.
  • කෝඩ් ඩිසයින් එක හොඳ වෙනවා (Better Code Design): ටෙස්ට් කරන්න පහසු විදිහට කෝඩ් ලියන්න පුරුදු වෙනකොට, ස්වයංක්‍රීයවම කෝඩ් ඩිසයින් එක හොඳ වෙනවා.
  • ඩොකියුමන්ටේෂන් එකක් වගේ වැඩ කරනවා (Acts as Documentation): ටෙස්ට්ස් කියන්නේ අපේ කෝඩ් එක මොන වගේ අවස්ථා වලදී කොහොමද හැසිරෙන්නේ කියන එකට හොඳ ඩොකියුමන්ටේෂන් එකක්.

JUnit සහ Mockito සෙට් කරගමු!

Java වල Unit Testing කරනකොට JUnit කියන්නේ අනිවාර්යයෙන්ම පාවිච්චි කරන ෆ්‍රේම්වර්ක් එකක්. Mockito එන්නේ JUnit ටිකක් දියුණු කරන්න, එහෙමත් නැත්නම් අපේ ටෙස්ට්ස් වල dependencies manage කරන්න. මොකක්ද මේ dependencies කියන්නේ? හිතන්න ඔයාට `UserService` එකක් ටෙස්ට් කරන්න ඕනේ කියලා. ඒක ඇතුලේ `UserRepository` එකක් පාවිච්චි වෙනවා. `UserService` එක ටෙස්ට් කරනකොට, ඇත්තටම `UserRepository` එකට ඩේටාබේස් එකට කනෙක්ට් වෙන්න අවශ්‍ය නෑ. අපිට ඕනේ `UserService` එකේ ලොජික් එක විතරක් ටෙස්ට් කරන්න. අන්න එතකොට Mockito පාවිච්චි කරලා `UserRepository` එක fake කරන්න පුළුවන්. මේවට අපි 'Mocks' කියලා කියනවා.

Maven Dependencies

ඔයා Maven පාවිච්චි කරනවා නම්, `pom.xml` එකට මේ dependencies ටික එකතු කරගන්න:


<dependencies>
    <!-- JUnit 5 -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-api</artifactId>
        <version>5.10.0</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-engine</artifactId>
        <version>5.10.0</version>
        <scope>test</scope>
    </dependency>

    <!-- Mockito -->
    <dependency>
        <groupId>org.mockito</groupId>
        <artifactId>mockito-core</artifactId>
        <version>5.6.0</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.mockito</groupId>
        <artifactId>mockito-junit-jupiter</artifactId>
        <version>5.6.0</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Gradle පාවිච්චි කරනවා නම්, `build.gradle` එකට මේ ටික දාගන්න:


dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.0'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.0'
    testImplementation 'org.mockito:mockito-core:5.6.0'
    testImplementation 'org.mockito:mockito-junit-jupiter:5.6.0'
}

Service Class එකක් Test කරමු

අපි හිතමු අපිට Product Management System එකක් හදන්න තියෙනවා කියලා. ඒකේ Product එකක් add කරන, තියෙන Product එකක් හොයාගන්න වගේ දේවල් කරන `ProductService` එකක් තියෙනවා. මේ `ProductService` එකට Product Details save කරන්න `ProductRepository` එකක් ඕනේ වෙනවා.

Product DTO/POJO

මුලින්ම Product එකක් නිරූපණය කරන්න POJO (Plain Old Java Object) එකක් හදාගමු.


public class Product {
    private String id;
    private String name;
    private double price;

    public Product(String id, String name, double price) {
        this.id = id;
        this.name = name;
        this.price = price;
    }

    // Getters and Setters (omitted for brevity)
    public String getId() { return id; }
    public void setId(String id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public double getPrice() { return price; }
    public void setPrice(double price) { this.price = price; }
}

ProductRepository Interface

මේක අපේ `ProductService` එකේ dependency එක. සරලව Product Data access කරන interface එකක්.


public interface ProductRepository {
    Product findById(String id);
    Product save(Product product);
    boolean existsById(String id);
}

ProductService Class

දැන් අපේ ටෙස්ට් කරන්න ඕනේ සර්විස් ක්ලාස් එක. මේකේ ලොජික් දෙකක් තියෙනවා: Product එකක් add කරන එකයි, Product එකක් ID එකෙන් හොයාගන්න එකයි.


public class ProductService {
    private final ProductRepository productRepository;

    // Constructor Injection
    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    public Product addProduct(Product product) {
        if (productRepository.existsById(product.getId())) {
            throw new IllegalArgumentException("Product with ID " + product.getId() + " already exists.");
        }
        return productRepository.save(product);
    }

    public Product getProductById(String id) {
        Product product = productRepository.findById(id);
        if (product == null) {
            throw new IllegalArgumentException("Product with ID " + id + " not found.");
        }
        return product;
    }
}

මෙතනදී බලන්න, `ProductService` එක `ProductRepository` එකට ඩිපෙන්ඩ් වෙනවා. අපිට `ProductService` එකේ ලොජික් එක විතරක් ටෙස්ට් කරන්න නම්, `ProductRepository` එකේ ඇත්ත implementation එකක් ඕනේ නෑ. ඒක Mock කරගන්න පුළුවන්.

Unit Tests ලියමු!

දැන් අපි `ProductService` එකට Unit Tests ලියමු. අපේ ටෙස්ට් ක්ලාස් එක `src/test/java` ෆෝල්ඩර් එක ඇතුලේ හදාගන්න පුළුවන්.


import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
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.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
public class ProductServiceTest {

    @Mock
    private ProductRepository productRepository; // Mock ProductRepository

    @InjectMocks
    private ProductService productService; // Inject the mock into ProductService

    // This method runs before each test method
    @BeforeEach
    void setUp() {
        // Any setup that needs to happen before each test can go here
        // Mockito annotations handle initialization, so not much needed here for this example.
    }

    @Test
    @DisplayName("Should add a new product successfully")
    void testAddProductSuccess() {
        // Given
        Product newProduct = new Product("P001", "Laptop", 1200.0);

        // When productRepository.existsById() is called with "P001", return false
        when(productRepository.existsById("P001")).thenReturn(false);
        // When productRepository.save() is called with newProduct, return newProduct
        when(productRepository.save(newProduct)).thenReturn(newProduct);

        // When
        Product savedProduct = productService.addProduct(newProduct);

        // Then
        assertNotNull(savedProduct);
        assertEquals("P001", savedProduct.getId());
        assertEquals("Laptop", savedProduct.getName());
        assertEquals(1200.0, savedProduct.getPrice());

        // Verify that existsById was called once with "P001"
        verify(productRepository, times(1)).existsById("P001");
        // Verify that save was called once with newProduct
        verify(productRepository, times(1)).save(newProduct);
    }

    @Test
    @DisplayName("Should throw exception if product with ID already exists")
    void testAddProductAlreadyExists() {
        // Given
        Product existingProduct = new Product("P002", "Mouse", 25.0);

        // When productRepository.existsById() is called with "P002", return true
        when(productRepository.existsById("P002")).thenReturn(true);

        // When & Then (assert that an exception is thrown)
        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                () -> productService.addProduct(existingProduct));

        assertEquals("Product with ID P002 already exists.", exception.getMessage());

        // Verify that existsById was called once, and save was never called
        verify(productRepository, times(1)).existsById("P002");
        verify(productRepository, never()).save(any(Product.class));
    }

    @Test
    @DisplayName("Should return product when found by ID")
    void testGetProductByIdFound() {
        // Given
        Product product = new Product("P003", "Keyboard", 75.0);

        // When productRepository.findById() is called with "P003", return the product
        when(productRepository.findById("P003")).thenReturn(product);

        // When
        Product foundProduct = productService.getProductById("P003");

        // Then
        assertNotNull(foundProduct);
        assertEquals("P003", foundProduct.getId());
        assertEquals("Keyboard", foundProduct.getName());

        verify(productRepository, times(1)).findById("P003");
    }

    @Test
    @DisplayName("Should throw exception when product not found by ID")
    void testGetProductByIdNotFound() {
        // Given
        String productId = "P004";

        // When productRepository.findById() is called with "P004", return null
        when(productRepository.findById(productId)).thenReturn(null);

        // When & Then (assert that an exception is thrown)
        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                () -> productService.getProductById(productId));

        assertEquals("Product with ID P004 not found.", exception.getMessage());

        verify(productRepository, times(1)).findById(productId);
    }
}

කෝඩ් එක තේරුම් ගනිමු

  • `@ExtendWith(MockitoExtension.class)`: JUnit 5 ට කියනවා Mockito Extension එක පාවිච්චි කරන්න කියලා. මේක නිසා `@Mock` සහ `@InjectMocks` වගේ Annotations වැඩ කරනවා.
  • `@Mock ProductRepository productRepository;`: මේකෙන් කියන්නේ `productRepository` කියන එක Mock එකක් විදිහට හදාගන්න කියලා. ඒ කියන්නේ ඇත්ත `ProductRepository` එක වෙනුවට, අපිට control කරන්න පුළුවන් fake එකක් හැදෙනවා.
  • `@InjectMocks ProductService productService;`: මේකෙන් කියන්නේ `productService` එක හදනකොට, ඒකට ඕනේ කරන `ProductRepository` dependency එකට උඩින් හදාගත්තු mock එක inject කරන්න කියලා.
  • `@BeforeEach`: හැම ටෙස්ට් එකක්ම දුවන්න කලින් මේ method එක දුවනවා. ටෙස්ට් එකට කලින් අවශ්‍ය කරන පොදු setup මෙතන කරන්න පුළුවන්.
  • `when(productRepository.existsById("P001")).thenReturn(false);`: Mockito වල මේක පට්ටම වැදගත් කෑල්ලක්. මේකෙන් කියන්නේ, `productRepository` එකේ `existsById("P001")` method එකට call එකක් ආවොත්, `false` කියලා return කරන්න කියලා. මේක තමයි Mock එක configure කරනවා කියන්නේ.
  • `assertNotNull()`, `assertEquals()`, `assertThrows()`: මේවා JUnit වල Assertions. අපේ කෝඩ් එකෙන් අපේක්ෂිත ප්‍රතිඵල ලැබෙනවද කියලා චෙක් කරන්න මේවා පාවිච්චි කරනවා.
  • `verify(productRepository, times(1)).existsById("P001");`: මේකෙන් චෙක් කරන්නේ `productRepository` එකේ `existsById("P001")` method එක හරියටම එක වතාවක් call වුණාද කියලා. `never()`, `atLeastOnce()`, `atMost()` වගේ දේවලුත් පාවිච්චි කරන්න පුළුවන්.

Tips & Tricks

  • F.I.R.S.T. මූලධර්මය:
    • Fast: Unit Tests ඉක්මනින් දුවන්න ඕනේ.
    • Isolated: හැම ටෙස්ට් එකක්ම තනියම දුවන්න පුළුවන් වෙන්න ඕනේ, වෙන ටෙස්ට් එකකට ඩිපෙන්ඩ් වෙන්න බෑ.
    • Repeatable: ඕනෑම වෙලාවක, ඕනෑම පරිසරයක එකම ප්‍රතිඵලය දෙන්න ඕනේ.
    • Self-validating: ටෙස්ට් එක දුවලා pass ද fail ද කියලා තනියම කියන්න පුළුවන් වෙන්න ඕනේ (console output බලන්න ඕනේ නෑ).
    • Timely: කෝඩ් ලියන ගමන්ම ටෙස්ට්ස් ලියන්න.
  • තනි දෙයක් ටෙස්ට් කරන්න (Test one thing at a time): හැම ටෙස්ට් method එකකින්ම තනි, නිශ්චිත කේස් එකක් ටෙස්ට් කරන්න.
  • හොඳ නම් දෙන්න (Descriptive Names): ටෙස්ට් method වලට `testAddProductSuccess()` වගේ වැඩේ පැහැදිලි වෙන විදිහේ නම් දෙන්න.
  • private methods ටෙස්ට් කරන්න එපා (Don't test private methods directly): private methods කියන්නේ class එකේ අභ්‍යන්තර ලොජික් එකේ කොටස්. ඒවා ටෙස්ට් කරන්න ඕනේ public methods හරහා.

දැන් ඔයාලට පැහැදිලි ඇති Unit Testing කියන්නේ මොකක්ද, JUnit සහ Mockito කොහොමද පාවිච්චි කරන්නේ කියලා. මුලින් ටිකක් අමාරු වුණාට, මේක පුරුදු වුණාම ඔයාගේ කෝඩ් එකේ කොලිටි එකත්, ඔයාගේ ඩෙවලොප්මන්ට් ස්පීඩ් එකත් පට්ටටම වැඩි වෙනවා. මේක හරියට බයික් එකක් රේස් කරනකොට, හොඳට බ්‍රේක් සෙට් කරගෙන ඉන්නවා වගේ. බ්‍රේක් හොඳට වැඩ කරනවා නම්, බය නැතුව වේගයෙන් යන්න පුළුවන් නේද? කෝඩ් එකත් එහෙමයි, හොඳ ටෙස්ට්ස් තියෙනවා නම්, බය නැතුව වෙනස්කම් කරගෙන යන්න පුළුවන්!

එහෙනම්, මේ කන්සෙප්ට් ටික ඔයාලගේ ප්‍රොජෙක්ට්ස් වලට දාගෙන බලන්න. මොනවා හරි ප්‍රශ්න තියෙනවා නම්, ඔයාලගේ අදහස් Comment Section එකේ දාගෙන යන්න! අපි ඊළඟ පෝස්ට් එකෙන් හම්බවෙමු!