Dlaczego testy jednostkowe to podstawa każdej aplikacji
**Bez testów Twoja aplikacja to dom z kart – jeden błąd może zburzyć wszystko.** Testy jednostkowe to automatyczna siatka bezpieczeństwa, która wykrywa błędy od razu, nie po tygodniach w produkcji. **Profesjonalni programiści nie przedstawiają kodu bez testów** – to jak lekarz operujący bez rękawiczek.
Co się nauczysz:
- Czym są testy jednostkowe i dlaczego są ważne
- Jak skonfigurować JUnit 4 w projekcie Java
- Pisanie pierwszych testów z adnotacjami @Test, @Before, @After
- Używanie asercji – assertEquals, assertTrue, assertNull
- Testowanie wyjątków i edge cases
- Najlepsze praktyki nazewnictwa i organizacji testów
Czym są testy jednostkowe?
**Zalety testów jednostkowych:**
– **Wczesne wykrywanie błędów** – łatwiej naprawić błąd od razu niż po tygodniu
– **Dokumentacja kodu** – testy pokazują jak kod powinien być używany
– **Bezpieczny refactoring** – możesz zmieniać kod z pewnością że nic nie zepsujesz
– **Szybsza implementacja** – nie musisz ręcznie testować każdej zmiany
Konfiguracja JUnit 4 w projekcie
Maven dependency (pom.xml)
4.0.0 com.example junit-example 1.0-SNAPSHOT 1.7 1.7 junit junit 4.12 test
Struktura katalogów
src/ ├── main/ │ └── java/ │ └── com/example/ │ ├── Calculator.java │ └── BankAccount.java └── test/ └── java/ └── com/example/ ├── CalculatorTest.java └── BankAccountTest.java
Pierwszy test JUnit 4
Zacznijmy od prostej klasy do testowania:
// src/main/java/com/example/Calculator.java public class Calculator { public int add(int a, int b) { return a + b; } public int subtract(int a, int b) { return a - b; } public int multiply(int a, int b) { return a * b; } public double divide(int a, int b) { if (b == 0) { throw new IllegalArgumentException("Nie można dzielić przez zero"); } return (double) a / b; } public boolean isEven(int number) { return number % 2 == 0; } }
A teraz pierwszy test:
// src/test/java/com/example/CalculatorTest.java import org.junit.Test; import static org.junit.Assert.*; public class CalculatorTest { @Test public void shouldAddTwoPositiveNumbers() { // Given - przygotowanie danych testowych Calculator calculator = new Calculator(); // When - wykonanie testowanej metody int result = calculator.add(5, 3); // Then - sprawdzenie rezultatu assertEquals(8, result); } @Test public void shouldAddNegativeNumbers() { Calculator calculator = new Calculator(); int result = calculator.add(-5, -3); assertEquals(-8, result); } @Test public void shouldSubtractNumbers() { Calculator calculator = new Calculator(); int result = calculator.subtract(10, 4); assertEquals(6, result); } }
Podstawowe asercje JUnit 4
import org.junit.Test; import static org.junit.Assert.*; public class AssertionsExampleTest { @Test public void demonstrateBasicAssertions() { Calculator calc = new Calculator(); // Równość obiektów/prymitywów assertEquals("Dodawanie nie działa", 8, calc.add(5, 3)); assertEquals(8, calc.add(5, 3)); // Bez niestandardowej wiadomości // Równość z tolerancją dla double/float assertEquals(2.5, calc.divide(5, 2), 0.001); // Boolean assertions assertTrue("5 powinno być nieparzyste", calc.isEven(4)); assertFalse(calc.isEven(5)); // Null assertions String text = null; assertNull("Text powinien być null", text); text = "Hello"; assertNotNull("Text nie powinien być null", text); // Same/Not Same (referencje obiektów) Calculator calc1 = new Calculator(); Calculator calc2 = new Calculator(); Calculator calc3 = calc1; assertSame(calc1, calc3); // Ta sama referencja assertNotSame(calc1, calc2); // Różne obiekty // Arrays int[] expected = {1, 2, 3}; int[] actual = {1, 2, 3}; assertArrayEquals(expected, actual); } @Test public void demonstrateStringAssertions() { String greeting = "Hello World"; assertEquals("Hello World", greeting); assertTrue(greeting.contains("World")); assertTrue(greeting.startsWith("Hello")); assertTrue(greeting.endsWith("World")); } }
Testowanie wyjątków
public class ExceptionTestingTest { // Sposób 1: expected parameter (najprostrzy) @Test(expected = IllegalArgumentException.class) public void shouldThrowExceptionWhenDividingByZero() { Calculator calculator = new Calculator(); calculator.divide(10, 0); // Powinno rzucić wyjątek } // Sposób 2: try-catch (więcej kontroli) @Test public void shouldThrowExceptionWithCorrectMessage() { Calculator calculator = new Calculator(); try { calculator.divide(10, 0); fail("Powinien rzucić IllegalArgumentException"); } catch (IllegalArgumentException e) { assertEquals("Nie można dzielić przez zero", e.getMessage()); } } // Test że metoda NIE rzuca wyjątku @Test public void shouldNotThrowExceptionForValidDivision() { Calculator calculator = new Calculator(); // Jeśli nie rzuci wyjątku, test przejdzie double result = calculator.divide(10, 2); assertEquals(5.0, result, 0.001); } }
Setup i Teardown – @Before i @After
import org.junit.*; import static org.junit.Assert.*; public class BankAccountTest { private BankAccount account; // Wykonywane PRZED każdym testem @Before public void setUp() { System.out.println("Przygotowuję dane testowe..."); account = new BankAccount("Jan Kowalski", 1000.0); } // Wykonywane PO każdym teście @After public void tearDown() { System.out.println("Sprzątam po teście..."); account = null; } // Wykonywane RAZ przed wszystkimi testami w klasie @BeforeClass public static void setUpClass() { System.out.println("Inicjalizacja całej klasy testowej"); // np. połączenie z bazą danych, konfiguracja logowania } // Wykonywane RAZ po wszystkich testach w klasie @AfterClass public static void tearDownClass() { System.out.println("Sprzątanie po całej klasie testowej"); // np. zamknięcie połączenia z bazą danych } @Test public void shouldDepositMoney() { // account jest już przygotowane w @Before account.deposit(500.0); assertEquals(1500.0, account.getBalance(), 0.01); } @Test public void shouldWithdrawMoney() { account.withdraw(300.0); assertEquals(700.0, account.getBalance(), 0.01); } @Test(expected = IllegalArgumentException.class) public void shouldNotAllowNegativeDeposit() { account.deposit(-100.0); } }
Praktyczny przykład – testowanie klasy BankAccount
Najpierw klasa do testowania:
// src/main/java/com/example/BankAccount.java public class BankAccount { private String ownerName; private double balance; private boolean isActive; public BankAccount(String ownerName, double initialBalance) { if (ownerName == null || ownerName.trim().isEmpty()) { throw new IllegalArgumentException("Nazwa właściciela nie może być pusta"); } if (initialBalance < 0) { throw new IllegalArgumentException("Saldo początkowe nie może być ujemne"); } this.ownerName = ownerName; this.balance = initialBalance; this.isActive = true; } public void deposit(double amount) { if (amount <= 0) { throw new IllegalArgumentException("Kwota musi być dodatnia"); } if (!isActive) { throw new IllegalStateException("Konto jest nieaktywne"); } balance += amount; } public void withdraw(double amount) { if (amount <= 0) { throw new IllegalArgumentException("Kwota musi być dodatnia"); } if (!isActive) { throw new IllegalStateException("Konto jest nieaktywne"); } if (amount > balance) { throw new IllegalArgumentException("Niewystarczające środki"); } balance -= amount; } public void deactivate() { isActive = false; } public void activate() { isActive = true; } // Gettery public String getOwnerName() { return ownerName; } public double getBalance() { return balance; } public boolean isActive() { return isActive; } }
Kompleksowe testy:
// src/test/java/com/example/BankAccountTest.java import org.junit.*; import static org.junit.Assert.*; public class BankAccountTest { private BankAccount account; private static final double DELTA = 0.001; // Tolerancja dla porównań double @Before public void setUp() { account = new BankAccount("Jan Kowalski", 1000.0); } // ================= // TESTY KONSTRUKTORA // ================= @Test public void shouldCreateAccountWithValidData() { BankAccount newAccount = new BankAccount("Anna Nowak", 500.0); assertEquals("Anna Nowak", newAccount.getOwnerName()); assertEquals(500.0, newAccount.getBalance(), DELTA); assertTrue(newAccount.isActive()); } @Test(expected = IllegalArgumentException.class) public void shouldNotCreateAccountWithNullName() { new BankAccount(null, 1000.0); } @Test(expected = IllegalArgumentException.class) public void shouldNotCreateAccountWithEmptyName() { new BankAccount(" ", 1000.0); } @Test(expected = IllegalArgumentException.class) public void shouldNotCreateAccountWithNegativeBalance() { new BankAccount("Jan Kowalski", -100.0); } @Test public void shouldCreateAccountWithZeroBalance() { BankAccount zeroAccount = new BankAccount("Piotr Wiśniewski", 0.0); assertEquals(0.0, zeroAccount.getBalance(), DELTA); } // ================= // TESTY DEPOZYTÓW // ================= @Test public void shouldDepositValidAmount() { account.deposit(500.0); assertEquals(1500.0, account.getBalance(), DELTA); } @Test public void shouldDepositMultipleTimes() { account.deposit(200.0); account.deposit(300.0); assertEquals(1500.0, account.getBalance(), DELTA); } @Test(expected = IllegalArgumentException.class) public void shouldNotDepositZero() { account.deposit(0.0); } @Test(expected = IllegalArgumentException.class) public void shouldNotDepositNegativeAmount() { account.deposit(-100.0); } @Test(expected = IllegalStateException.class) public void shouldNotDepositToInactiveAccount() { account.deactivate(); account.deposit(100.0); } // ================= // TESTY WYPŁAT // ================= @Test public void shouldWithdrawValidAmount() { account.withdraw(300.0); assertEquals(700.0, account.getBalance(), DELTA); } @Test public void shouldWithdrawEntireBalance() { account.withdraw(1000.0); assertEquals(0.0, account.getBalance(), DELTA); } @Test(expected = IllegalArgumentException.class) public void shouldNotWithdrawMoreThanBalance() { account.withdraw(1100.0); } @Test(expected = IllegalArgumentException.class) public void shouldNotWithdrawZero() { account.withdraw(0.0); } @Test(expected = IllegalArgumentException.class) public void shouldNotWithdrawNegativeAmount() { account.withdraw(-50.0); } @Test(expected = IllegalStateException.class) public void shouldNotWithdrawFromInactiveAccount() { account.deactivate(); account.withdraw(100.0); } // ================= // TESTY AKTYWACJI/DEAKTYWACJI // ================= @Test public void shouldDeactivateAccount() { account.deactivate(); assertFalse(account.isActive()); } @Test public void shouldActivateAccount() { account.deactivate(); account.activate(); assertTrue(account.isActive()); } @Test public void shouldAllowOperationsAfterReactivation() { account.deactivate(); account.activate(); // Powinno działać normalnie account.deposit(100.0); account.withdraw(50.0); assertEquals(1050.0, account.getBalance(), DELTA); } // ================= // TESTY EDGE CASES // ================= @Test public void shouldHandleVerySmallAmounts() { account.deposit(0.01); assertEquals(1000.01, account.getBalance(), DELTA); } @Test public void shouldHandleLargeAmounts() { account.deposit(999999.99); assertEquals(1000999.99, account.getBalance(), DELTA); } }
Uruchom testowanie i interpretacja wyników
Uruchamianie testów
**W IntelliJ IDEA:**
– Kliknij prawym na klasę testową → Run 'CalculatorTest’
– Lub użyj skrótu `Ctrl+Shift+F10`
– Uruchom wszystkie testy: `Ctrl+Shift+F10` na folderze test
**W Eclipse:**
– Kliknij prawym na klasę → Run As → JUnit Test
– Lub `Alt+Shift+X, T`
**Z linii komend (Maven):**
„`bash
mvn test # Wszystkie testy
mvn test -Dtest=CalculatorTest # Konkretna klasa
„`
Interpretacja wyników
Czerwony pasek = co najmniej jeden test nie przeszedł ❌
Żółty pasek = testy zostały zignorowane (@Ignore)
Running com.example.BankAccountTest Tests run: 15, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.123 sec Results : Tests run: 15, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS
Najlepsze praktyki testowania
Nazewnictwo testów
// DOBRE nazwy testów - opisują co i kiedy @Test public void shouldReturnTrueWhenNumberIsEven() { } @Test public void shouldThrowExceptionWhenDividingByZero() { } @Test public void shouldCalculateCorrectTaxForHighIncome() { } // ZŁTE nazwy testów @Test public void test1() { } // Co testuje? @Test public void testCalculator() { } // Która metoda? Jaki scenariusz? @Test public void divideTest() { } // Za ogólne
Wzorce nazewnictwa
**Popularne konwencje:**
– `should[ExpectedBehavior]When[StateUnderTest]`
– `given[Precondition]When[Action]Then[ExpectedResult]`
– `test[MethodName][Scenario]`
Organizacja testów
public class BankAccountTest { // =============================== // HAPPY PATH TESTS (pozytywne scenariusze) // =============================== @Test public void shouldDepositValidAmount() { } @Test public void shouldWithdrawValidAmount() { } // =============================== // ERROR CASES (błędne scenariusze) // =============================== @Test(expected = IllegalArgumentException.class) public void shouldThrowExceptionForNegativeDeposit() { } // =============================== // EDGE CASES (przypadki graniczne) // =============================== @Test public void shouldHandleZeroBalance() { } @Test public void shouldHandleMaximumAmount() { } }
Częste błędy w testach
// ŹLE - test zna implementację @Test public void shouldUseArrayListInternally() { UserService service = new UserService(); // Test sprawdza typ wewnętrznej kolekcji - źle! assertTrue(service.getUsers() instanceof ArrayList); } // DOBRZE - test sprawdza zachowanie @Test public void shouldReturnAllAddedUsers() { UserService service = new UserService(); service.addUser(new User("Jan")); service.addUser(new User("Anna")); assertEquals(2, service.getUsers().size()); }
// BŁĄD - testy zależne od kolejności private static int counter = 0; @Test public void testFirst() { counter++; assertEquals(1, counter); } @Test public void testSecond() { // Zależy od testFirst() - źle! assertEquals(1, counter); } // POPRAWNIE - każdy test niezależny @Before public void setUp() { counter = 0; // Reset przed każdym testem }
Test Coverage – pokrycie kodu testami
**W IntelliJ IDEA:**
– Run → Run with Coverage
– Zobaczysz kolorowanie kodu: zielone = pokryte, czerwone = niepokryte
**Typy pokrycia:**
– **Line Coverage** – jaki % linii jest wykonywany
– **Branch Coverage** – jaki % ścieżek w if/switch jest testowany
– **Method Coverage** – jaki % metod jest wywoływany
Przynajmniej jeden test dla happy path, po jednym dla każdego błędnego scenariusza, i testy dla edge cases (wartości graniczne jak 0, null, maksymalne wartości).
Zazwyczaj nie – to proste metody bez logiki biznesowej. Testuj je tylko jeśli zawierają walidację lub złożoną logikę.
To flaky test – trzeba go naprawić! Często przyczyną są problemy z czasem, wielowątkowością, lub zewnętrznymi zależnościami. Użyj @Before/@After do ustabilizowania warunków testowych.
Nie testuj metod private bezpośrednio! Testuj public metody które je wywołują. Jeśli private metoda jest na tyle złożona że wymaga testów, może powinna być public w osobnej klasie.
Unikaj logiki w testach – mają być proste i przewidywalne. Jeśli potrzebujesz if/for, rozważ napisanie kilku prostszych testów zamiast jednego złożonego.
Nie ma magicznej liczby! 70-80% to dobry cel, ale ważniejsza jest jakość testów. Lepiej mieć 50% dobrych testów niż 90% testów które nic nie sprawdzają.
Tylko do debugowania! Usuń przed commitem. Testy powinny być „ciche” – komunikują wyniki przez asercje, nie przez wypisywanie na konsolę.
Przydatne zasoby:
🚀 Zadanie dla Ciebie
Stwórz klasę „ShoppingCart” z metodami: addItem(String name, double price), removeItem(String name), getTotalPrice(), getItemCount(), clear(). Napisz kompletne testy JUnit 4 pokrywające wszystkie scenariusze – pozytywne, błędne i graniczne. Zadbaj o dobre nazwy testów i użyj @Before do przygotowania danych testowych!
Czy już piszesz testy do swojego kodu? Jakie są Twoje największe wyzwania z testowaniem? Podziel się swoimi doświadczeniami!