← Назад к вопросам

Что такое модульное тестирование?

2.0 Middle🔥 191 комментариев
#SOLID и паттерны проектирования#Spring Boot и Spring Data

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Модульное тестирование

Модульное тестирование (Unit Testing) - это практика написания автоматизированных тестов для отдельных единиц кода (функций, методов, классов) с целью проверки их корректного поведения в изоляции от других компонентов. Это самый базовый уровень тестирования в пирамиде тестов.

Основные концепции

Единица кода (Unit) - это обычно один метод или небольшой набор связанных методов одного класса.

Изоляция - тест должен проверять только одну функцию, не зависи от БД, API или других сервисов.

Автоматизация - тесты выполняются автоматически и быстро (миллисекунды, не секунды).

Повторяемость - тест должен давать одинаковый результат каждый раз.

Структура Unit теста (AAA Pattern)

Все unit тесты следуют паттерну: Arrange → Act → Assert

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

public class CalculatorTest {
    
    @Test
    public void testAddNumbers() {
        // Arrange (подготовка)
        Calculator calculator = new Calculator();
        int a = 5;
        int b = 3;
        
        // Act (действие)
        int result = calculator.add(a, b);
        
        // Assert (проверка)
        assertEquals(8, result);
    }
}

Простой пример Unit теста в Java

Тестируемый класс

public class UserValidator {
    
    public boolean isValidEmail(String email) {
        if (email == null || email.isEmpty()) {
            return false;
        }
        return email.matches("^[A-Za-z0-9+_.-]+@(.+)$");
    }
    
    public boolean isStrongPassword(String password) {
        if (password == null || password.length() < 8) {
            return false;
        }
        boolean hasUpperCase = password.matches(".*[A-Z].*");
        boolean hasLowerCase = password.matches(".*[a-z].*");
        boolean hasDigit = password.matches(".*\\d.*");
        
        return hasUpperCase && hasLowerCase && hasDigit;
    }
}

Unit тесты для этого класса

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

public class UserValidatorTest {
    
    private UserValidator validator;
    
    @BeforeEach  // Выполняется перед каждым тестом
    public void setUp() {
        validator = new UserValidator();
    }
    
    // ===== Email validation tests =====
    
    @Test
    public void testValidEmail() {
        assertTrue(validator.isValidEmail("john@example.com"));
    }
    
    @Test
    public void testInvalidEmailWithoutDomain() {
        assertFalse(validator.isValidEmail("john@"));
    }
    
    @Test
    public void testInvalidEmailWithoutAt() {
        assertFalse(validator.isValidEmail("johnexample.com"));
    }
    
    @Test
    public void testNullEmail() {
        assertFalse(validator.isValidEmail(null));
    }
    
    @Test
    public void testEmptyEmail() {
        assertFalse(validator.isValidEmail(""));
    }
    
    // ===== Password validation tests =====
    
    @Test
    public void testStrongPassword() {
        assertTrue(validator.isStrongPassword("MyPassword123"));
    }
    
    @Test
    public void testPasswordTooShort() {
        assertFalse(validator.isStrongPassword("Pass1a"));
    }
    
    @Test
    public void testPasswordWithoutUpperCase() {
        assertFalse(validator.isStrongPassword("password123"));
    }
    
    @Test
    public void testPasswordWithoutLowerCase() {
        assertFalse(validator.isStrongPassword("PASSWORD123"));
    }
    
    @Test
    public void testPasswordWithoutDigit() {
        assertFalse(validator.isStrongPassword("PasswordOnly"));
    }
    
    @Test
    public void testNullPassword() {
        assertFalse(validator.isStrongPassword(null));
    }
}

Использование Mocks для изоляции

Одна из главных идей unit тестов - изолировать единицу кода. Для этого используются mock объекты:

import org.mockito.Mock;
import org.mockito.InjectMocks;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.Test;

public class OrderServiceTest {
    
    @Mock
    private PaymentGateway paymentGateway;  // Mock объект
    
    @Mock
    private EmailService emailService;       // Mock объект
    
    @InjectMocks
    private OrderService orderService;       // Реальный объект с injected mocks
    
    @Test
    public void testOrderCreation() {
        // Arrange: конфигурируем mock поведение
        when(paymentGateway.processPayment(100.0))
            .thenReturn(true);
        
        // Act: вызываем реальный метод
        Order order = orderService.createOrder(100.0, "product1");
        
        // Assert: проверяем результат
        assertNotNull(order);
        assertEquals("product1", order.getProductId());
        
        // Verify: проверяем, что mock был вызван правильно
        verify(paymentGateway).processPayment(100.0);
        verify(emailService).sendConfirmation("customer@example.com");
    }
}

Пример реального сервиса с Unit тестами

// Сервис
public class BankAccountService {
    
    private BankAccountRepository repository;
    private TransactionLogger logger;
    
    public BankAccountService(
        BankAccountRepository repository,
        TransactionLogger logger
    ) {
        this.repository = repository;
        this.logger = logger;
    }
    
    public void transferMoney(String from, String to, BigDecimal amount) 
        throws InsufficientFundsException {
        
        if (amount.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("Amount must be positive");
        }
        
        BankAccount fromAccount = repository.findById(from);
        BankAccount toAccount = repository.findById(to);
        
        if (fromAccount.getBalance().compareTo(amount) < 0) {
            throw new InsufficientFundsException(
                "Insufficient funds in account " + from
            );
        }
        
        fromAccount.withdraw(amount);
        toAccount.deposit(amount);
        
        repository.save(fromAccount);
        repository.save(toAccount);
        
        logger.log("Transfer of " + amount + " from " + from + " to " + to);
    }
}

// Unit тесты
import org.mockito.Mock;
import org.mockito.InjectMocks;
import static org.mockito.Mockito.*;

public class BankAccountServiceTest {
    
    @Mock
    private BankAccountRepository repository;
    
    @Mock
    private TransactionLogger logger;
    
    @InjectMocks
    private BankAccountService service;
    
    @Test
    public void testSuccessfulTransfer() throws Exception {
        // Arrange
        BankAccount from = new BankAccount("100", BigDecimal.valueOf(500));
        BankAccount to = new BankAccount("200", BigDecimal.valueOf(1000));
        
        when(repository.findById("100")).thenReturn(from);
        when(repository.findById("200")).thenReturn(to);
        
        // Act
        service.transferMoney("100", "200", BigDecimal.valueOf(100));
        
        // Assert
        assertEquals(BigDecimal.valueOf(400), from.getBalance());
        assertEquals(BigDecimal.valueOf(1100), to.getBalance());
        
        // Verify mocks were called
        verify(repository, times(2)).save(any(BankAccount.class));
        verify(logger).log(contains("Transfer of 100"));
    }
    
    @Test
    public void testInsufficientFundsException() {
        // Arrange
        BankAccount from = new BankAccount("100", BigDecimal.valueOf(50));
        when(repository.findById("100")).thenReturn(from);
        
        // Act & Assert
        assertThrows(
            InsufficientFundsException.class,
            () -> service.transferMoney("100", "200", BigDecimal.valueOf(100))
        );
        
        // Verify that save was never called
        verify(repository, never()).save(any());
    }
    
    @Test
    public void testNegativeAmountException() {
        // Act & Assert
        assertThrows(
            IllegalArgumentException.class,
            () -> service.transferMoney("100", "200", BigDecimal.valueOf(-10))
        );
    }
}

Параметризованные тесты

Когда нужно проверить один метод с разными входными данными:

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.params.provider.CsvSource;

public class DiscountCalculatorTest {
    
    @ParameterizedTest
    @ValueSource(doubles = {10.0, 50.0, 100.0, 200.0})
    public void testDiscountPercentage(double price) {
        DiscountCalculator calculator = new DiscountCalculator();
        double discount = calculator.calculateDiscount(price);
        assertTrue(discount >= 0 && discount <= price);
    }
    
    @ParameterizedTest
    @CsvSource({
        "100, 10, 90",      // price=100, discount=10, expected=90
        "50, 5, 45",        // price=50, discount=5, expected=45
        "200, 20, 180"      // price=200, discount=20, expected=180
    })
    public void testApplyDiscount(double price, double discount, double expected) {
        DiscountCalculator calculator = new DiscountCalculator();
        double result = calculator.applyDiscount(price, discount);
        assertEquals(expected, result, 0.01);
    }
}

Покрытие кода (Code Coverage)

Одна из метрик качества unit тестов:

# С JaCoCo в Maven
mvn clean test jacoco:report
# Отчёт появится в target/site/jacoco/index.html

Уровни покрытия:

  • 50-60% - минимум
  • 80-90% - хорошее покрытие
  • 90%+ - отличное покрытие (для важного кода)

Best Practices Unit Testing

1. Тестируй behaviour, не реализацию

// ❌ Плохо - тестирует реализацию
@Test
public void testPrivateField() {
    User user = new User();
    // Пытаемся проверить приватное поле через reflection
}

// ✅ Хорошо - тестирует behavior
@Test
public void testUserEmailValidation() {
    User user = new User("invalid-email");
    assertFalse(user.isValid());
}

2. Один assert на тест (или один концепт)

// ❌ Плохо - слишком много в одном тесте
@Test
public void testUser() {
    assertEquals("John", user.getName());
    assertEquals(25, user.getAge());
    assertEquals("john@example.com", user.getEmail());
}

// ✅ Хорошо - каждый тест проверяет одно
@Test
public void testUserName() {
    assertEquals("John", user.getName());
}

3. Быстрые тесты

Unit тесты должны выполняться за миллисекунды, не секунды.

4. Независимые тесты

Тесты не должны зависеть друг от друга.

5. Хорошие имена

// ❌ Плохое имя
@Test
public void test1() {}

// ✅ Хорошее имя
@Test
public void testCalculateDiscountFor50PercentOff() {}

Инструменты для Unit Testing

  • JUnit - фреймворк для тестирования
  • Mockito - создание mock объектов
  • AssertJ - удобные assertions
  • JaCoCo - анализ покрытия кода
  • TestNG - альтернатива JUnit

Пирамида тестов

       /\
      / E2E\        (10%)
     /------\
    /Integration\   (20%)
   /-----------\
  /   Unit       \ (70%)
 /_______________\

Большинство тестов должны быть unit тесты (быстрые и дешёвые).

Модульное тестирование - это фундамент качества кода. Хорошо написанные unit тесты:

  • Защищают от регрессий
  • Служат документацией
  • Упрощают рефакторинг
  • Повышают уверенность в коде
  • Экономят время на поиск багов