Unit testy vs Integration testy

TL;DR: Unit testy testują pojedyncze komponenty w izolacji (szybkie, deterministyczne), Integration testy sprawdzają współpracę między komponentami (wolniejsze, ale bardziej realistyczne). Idealne ratio to 70% unit, 20% integration, 10% end-to-end tests.

Dlaczego znajomość różnic między testami jest kluczowa?

Pisanie testów bez zrozumienia ich typów to jak używanie młotka do wszystkiego – czasem zadziała, ale często będzie nieefektywne. Unit testy to jak sprawdzanie czy każda część samochodu działa osobno, Integration testy to testowanie czy silnik współpracuje z przekładnią. Oba są potrzebne, ale do różnych celów.

Test Pyramid Martin Fowlera zaleca więcej unit testów niż integration, a więcej integration niż end-to-end. Ta proporcja zmaksymalizuje confidence przy minimalnych kosztach utrzymania.

Co się nauczysz:

  • Różnice między unit a integration testami
  • Kiedy używać każdego typu testów
  • Praktyczne przykłady z JUnit 4, Mockito i Spring Boot
  • Test doubles: mocks, stubs, fakes
  • Test Pyramid i testing strategy
Wymagania wstępne: Podstawy Java, znajomość JUnit, pojęcie dependency injection, doświadczenie z Spring Framework będzie pomocne.

Unit Tests – testowanie w izolacji

Charakterystyka unit testów

AspektUnit TestsZaletyWady
ScopePojedyncza klasa/metodaSzybka identyfikacja błędówNie sprawdza integracji
DependenciesWszystkie zmockowaneDeterministyczneMogą przegapić real-world issues
SpeedBardzo szybkie (<1ms)Szybki feedback
MaintenanceŁatwe w utrzymaniuProste do debugDużo mock setup
Unit Test – test który sprawdza pojedynczą „unit of work” (zwykle metodę) w complete isolation od external dependencies.

Przykład unit testu z Mockito

// UserService.java - Klasa do testowania
public class UserService {
    
    private final UserRepository userRepository;
    private final EmailService emailService;
    
    public UserService(UserRepository userRepository, EmailService emailService) {
        this.userRepository = userRepository;
        this.emailService = emailService;
    }
    
    public User createUser(String email, String firstName, String lastName) {
        // Validation
        if (email == null || !email.contains("@")) {
            throw new IllegalArgumentException("Invalid email");
        }
        
        // Business logic
        User user = new User(email, firstName, lastName);
        user.setActive(true);
        user.setCreatedAt(LocalDateTime.now());
        
        // Save user
        User savedUser = userRepository.save(user);
        
        // Send welcome email
        emailService.sendWelcomeEmail(savedUser.getEmail(), savedUser.getFirstName());
        
        return savedUser;
    }
}
// UserServiceTest.java - Unit test z mockami
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import static org.mockito.Mockito.*;
import static org.junit.Assert.*;

@RunWith(MockitoJUnitRunner.class)
public class UserServiceTest {
    
    @Mock
    private UserRepository userRepository;
    
    @Mock
    private EmailService emailService;
    
    private UserService userService;
    
    @Before
    public void setUp() {
        userService = new UserService(userRepository, emailService);
    }
    
    @Test
    public void shouldCreateUserWithValidData() {
        // Given
        String email = "john@example.com";
        String firstName = "John";
        String lastName = "Doe";
        
        User expectedUser = new User(email, firstName, lastName);
        expectedUser.setId(1L);
        expectedUser.setActive(true);
        
        when(userRepository.save(any(User.class))).thenReturn(expectedUser);
        
        // When
        User result = userService.createUser(email, firstName, lastName);
        
        // Then
        assertNotNull(result);
        assertEquals(email, result.getEmail());
        assertEquals(firstName, result.getFirstName());
        assertTrue(result.isActive());
        
        // Verify interactions
        verify(userRepository).save(any(User.class));
        verify(emailService).sendWelcomeEmail(email, firstName);
    }
    
    @Test(expected = IllegalArgumentException.class)
    public void shouldThrowExceptionForInvalidEmail() {
        // When & Then
        userService.createUser("invalid-email", "John", "Doe");
    }
    
    @Test
    public void shouldNotSendEmailWhenRepositoryFails() {
        // Given
        when(userRepository.save(any(User.class)))
            .thenThrow(new RuntimeException("Database error"));
        
        // When & Then
        try {
            userService.createUser("john@example.com", "John", "Doe");
            fail("Expected exception");
        } catch (RuntimeException e) {
            verify(emailService, never()).sendWelcomeEmail(anyString(), anyString());
        }
    }
}
Pro tip: Unit testy powinny testować behavior, nie implementation details. Focus na „what” not „how” – test outcomes, nie internal method calls.

Integration Tests – testowanie współpracy

Charakterystyka integration testów

AspektIntegration TestsZaletyWady
ScopeWiele komponentów razemRealistyczne scenariuszeTrudniejsze debug
DependenciesPrawdziwe lub test doublesSprawdza real integrationWięcej setup
SpeedWolniejsze (sekundy)Większe confidenceFeedback delay
MaintenanceTrudniejsze w utrzymaniuCatch więcej bugówFlaky tests

Spring Boot Integration Test

// UserControllerIntegrationTest.java
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.context.TestPropertySource;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestPropertySource(locations = "classpath:application-integrationtest.properties")
public class UserControllerIntegrationTest {
    
    @LocalServerPort
    private int port;
    
    @Autowired
    private TestRestTemplate restTemplate;
    
    @Autowired
    private UserRepository userRepository;
    
    @Test
    public void shouldCreateUserThroughAPI() {
        // Given
        String baseUrl = "http://localhost:" + port + "/api/users";
        
        CreateUserRequest request = new CreateUserRequest();
        request.setEmail("integration@example.com");
        request.setFirstName("Integration");
        request.setLastName("Test");
        
        // When
        ResponseEntity response = restTemplate.postForEntity(
            baseUrl, request, User.class);
        
        // Then
        assertEquals(HttpStatus.CREATED, response.getStatusCode());
        assertNotNull(response.getBody());
        assertEquals("integration@example.com", response.getBody().getEmail());
        
        // Verify in database
        Optional savedUser = userRepository.findByEmail("integration@example.com");
        assertTrue(savedUser.isPresent());
        assertTrue(savedUser.get().isActive());
    }
    
    @Test
    public void shouldReturnBadRequestForInvalidEmail() {
        // Given
        String baseUrl = "http://localhost:" + port + "/api/users";
        
        CreateUserRequest request = new CreateUserRequest();
        request.setEmail("invalid-email");
        request.setFirstName("John");
        request.setLastName("Doe");
        
        // When
        ResponseEntity response = restTemplate.postForEntity(
            baseUrl, request, String.class);
        
        // Then
        assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
    }
}

Database Integration Test z H2

// UserRepositoryIntegrationTest.java
@RunWith(SpringRunner.class)
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class UserRepositoryIntegrationTest {
    
    @Autowired
    private TestEntityManager entityManager;
    
    @Autowired
    private UserRepository userRepository;
    
    @Test
    public void shouldFindUserByEmail() {
        // Given
        User user = new User("test@example.com", "Test", "User");
        entityManager.persistAndFlush(user);
        
        // When
        Optional found = userRepository.findByEmail("test@example.com");
        
        // Then
        assertTrue(found.isPresent());
        assertEquals("Test", found.get().getFirstName());
    }
    
    @Test
    public void shouldReturnActiveUsersOnly() {
        // Given
        User activeUser = new User("active@example.com", "Active", "User");
        activeUser.setActive(true);
        
        User inactiveUser = new User("inactive@example.com", "Inactive", "User");
        inactiveUser.setActive(false);
        
        entityManager.persistAndFlush(activeUser);
        entityManager.persistAndFlush(inactiveUser);
        
        // When
        List activeUsers = userRepository.findByActiveTrue();
        
        // Then
        assertEquals(1, activeUsers.size());
        assertEquals("active@example.com", activeUsers.get(0).getEmail());
    }
}
Pułapka: Integration testy mogą być flaky z powodu external dependencies, timing issues, lub shared state. Używaj test containers lub embedded databases dla deterministic results.

Test Doubles – kiedy używać czego

Rodzaje test doubles

TypeOpisKiedy używaćPrzykład
MockSprawdza interactionsGdy ważne są side effectsverify(emailService).send()
StubZwraca pre-defined responsesGdy potrzebujesz specific return valueswhen(repo.find()).thenReturn(user)
FakeWorking implementation (simplified)Complex dependenciesIn-memory database
SpyPartial mock (real object + some mocked methods)Legacy code, selective mockingspy(realService)

Przykłady różnych test doubles

public class TestDoublesExample {
    
    @Test
    public void exampleWithMock() {
        // Mock - sprawdza czy metoda została wywołana
        EmailService emailMock = mock(EmailService.class);
        UserService userService = new UserService(userRepository, emailMock);
        
        userService.createUser("test@example.com", "John", "Doe");
        
        verify(emailMock).sendWelcomeEmail("test@example.com", "John");
    }
    
    @Test
    public void exampleWithStub() {
        // Stub - zwraca pre-defined values
        UserRepository repositoryStub = mock(UserRepository.class);
        User stubUser = new User("test@example.com", "John", "Doe");
        when(repositoryStub.save(any(User.class))).thenReturn(stubUser);
        
        UserService userService = new UserService(repositoryStub, emailService);
        User result = userService.createUser("test@example.com", "John", "Doe");
        
        assertEquals("John", result.getFirstName());
    }
    
    @Test
    public void exampleWithFake() {
        // Fake - in-memory implementation
        UserRepository fakeRepository = new InMemoryUserRepository();
        UserService userService = new UserService(fakeRepository, emailService);
        
        User result = userService.createUser("test@example.com", "John", "Doe");
        
        // Fake ma working implementation
        assertEquals(1, fakeRepository.count());
    }
    
    @Test
    public void exampleWithSpy() {
        // Spy - real object z niektórymi mocked methods
        UserService realService = new UserService(userRepository, emailService);
        UserService spyService = spy(realService);
        
        // Mock tylko specific method
        doReturn(true).when(spyService).isEmailUnique(anyString());
        
        // Reszta methods użyje real implementation
        User result = spyService.createUser("test@example.com", "John", "Doe");
    }
}

Testing Strategy – Test Pyramid

Optymalne proporcje testów

                    /\
                   /  \
                  / UI \
                 /Tests \    10% - End-to-End Tests
                /________\   (Selenium, Cypress)
               /          \
              / Integration \  20% - Integration Tests
             /    Tests      \ (Spring Boot, TestContainers)
            /________________\
           /                  \
          /    Unit Tests      \ 70% - Unit Tests
         /______________________\ (JUnit, Mockito)

Testing strategy guidelines

Pro tip: Zacznij od unit testów dla core business logic, dodaj integration testy dla critical paths, a end-to-end tylko dla najważniejsze user journeys.
  1. Unit Tests (70%):
    • Business logic
    • Edge cases
    • Error handling
    • Algorithms
  2. Integration Tests (20%):
    • Database operations
    • REST API endpoints
    • External service integration
    • Configuration
  3. End-to-End Tests (10%):
    • Critical user workflows
    • Cross-system integration
    • UI functionality

Best Practices

Unit Test best practices

// GOOD - Clear test name, single responsibility
@Test
public void shouldCalculateDiscountFor_PremiumCustomer_WhenOrderAbove100() {
    // Given
    Customer premiumCustomer = new Customer(CustomerType.PREMIUM);
    Order order = new Order(150.0);
    
    // When
    double discount = discountService.calculateDiscount(premiumCustomer, order);
    
    // Then
    assertEquals(15.0, discount, 0.01);
}

// BAD - Unclear name, testing multiple things
@Test
public void testDiscount() {
    Customer customer = new Customer(CustomerType.PREMIUM);
    Order order = new Order(150.0);
    
    double discount = discountService.calculateDiscount(customer, order);
    
    assertEquals(15.0, discount, 0.01);
    assertTrue(customer.isPremium()); // Testing different thing
}

Integration Test best practices

# application-integrationtest.properties
# Use different database for tests
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.jpa.hibernate.ddl-auto=create-drop

# Disable external services
external.email.service.enabled=false
external.payment.service.mock=true

# Faster test execution
logging.level.org.springframework=WARN
Kiedy pisać unit testy a kiedy integration?

Unit testy dla business logic, algorithms, edge cases. Integration testy dla database operations, REST endpoints, external integrations. Jeśli mockujesz więcej niż testujesz, prawdopodobnie potrzebujesz integration test.

Czy powinienem mockować wszystko w unit testach?

Mockuj external dependencies (database, web services, file system), ale nie mockuj value objects ani simple collaborators. Over-mocking prowadzi do testów które testują mocks zamiast real behavior.

Jak radzić sobie z flaky integration testami?

Użyj deterministic test data, avoid shared state, use test containers zamiast external services, add proper waits dla async operations, cleanup after each test.

Czy mogę mieszać unit i integration testy w jednej klasie?

Lepiej ich nie mieszać. Unit testy powinny być szybkie i niezależne, integration testy wymagają więcej setup. Separate test classes pozwalają na różne configuration i parallel execution.

Ile testów powinienem napisać?

Focus na test coverage of critical paths, nie na 100% line coverage. 80% coverage z dobrze napisanymi testami jest lepsze niż 100% z testami które testują getters/setters.

🚀 Zadanie dla Ciebie

Napisz kompletny test suite dla Order Management System:

  1. Unit testy: OrderService.calculateTotal() z różnymi discount rules
  2. Unit testy: OrderValidator.validate() z edge cases
  3. Integration test: OrderRepository database operations
  4. Integration test: REST API /orders endpoint
  5. Test strategy: Określ które parts wymagają unit vs integration testing

Use JUnit 4, Mockito, Spring Boot Test. Zmierz test execution time i sprawdź jak proporcje wpływają na feedback speed.

Przydatne zasoby:

Jakich proporcji unit vs integration testów używasz w swoich projektach? Które podejście sprawdza się najlepiej w twoim teamie?

Zostaw komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Przewijanie do góry