Po co początkującemu programiście testy jednostkowe i JUnit 5
Test jednostkowy – o co tak naprawdę chodzi
Test jednostkowy to automatyczny fragment kodu, który sprawdza pojedynczą, małą jednostkę logiki – najczęściej pojedynczą metodę w klasie. W przeciwieństwie do testów integracyjnych czy end-to-end, test jednostkowy nie interesuje się bazą danych, siecią czy interfejsem użytkownika. Skupia się na pytaniu: „Czy ta metoda, w izolacji, robi dokładnie to, czego od niej oczekuję?”.
Testy integracyjne badają współpracę wielu komponentów naraz (np. serwisu z bazą danych), a testy manualne polegają na klikaniu aplikacji i patrzeniu, czy wszystko działa. Testy jednostkowe są szybsze, tańsze i dokładniejsze, jeśli chodzi o logikę biznesową. Można je uruchamiać setki razy dziennie, w kilka sekund, bez udziału człowieka.
Dla początkującego programisty różnica jest kluczowa: test jednostkowy uczy myślenia o kodzie na poziomie małych, zrozumiałych fragmentów, zamiast ogromnego, nielogicznego monolitu. To jak ćwiczenie jednej kombinacji w sporcie, zamiast próby od razu rozegrania całego meczu.
Realne korzyści: mniej stresu, więcej odwagi do zmian
Testy jednostkowe w Javie z JUnit 5 przekładają się bezpośrednio na codzienny komfort pracy. Po pierwsze, błędy wychodzą wcześniej – jeszcze zanim uruchomisz aplikację „na poważnie”. Zamiast godziny debugowania po stronie frontendu okazuje się, że test od razu pokazuje, że metoda licząca rabat źle obsługuje wartość ujemną.
Po drugie, testy dają odwagę do refaktoryzacji. Gdy masz pokrycie kodu testami, możesz śmiało zmieniać wnętrze metod, przerabiać strukturę klasy, dzielić ją na mniejsze komponenty. Jeśli coś zepsujesz – testy krzykną. Dzięki temu deploy do produkcji przestaje być „modlitwą o brak błędów”, a staje się powtarzalnym, przewidywalnym procesem.
Po trzecie, dobrze napisane testy to żywa dokumentacja. Patrząc na testy metody, wiesz, jak powinna się zachowywać w konkretnych sytuacjach. Dla nowej osoby w zespole to często szybsza droga do zrozumienia logiki niż czytanie suchych opisów wymagań.
Dlaczego JUnit 5 jest naturalnym wyborem w świecie Javy
JUnit 5 to obecnie standardowe narzędzie do testów jednostkowych w Javie. Większość środowisk programistycznych (IntelliJ IDEA, Eclipse, VS Code) ma do niego wbudowaną obsługę: możesz uruchamiać testy jednym kliknięciem, przefiltrować tylko nieudane, zobaczyć czas wykonania każdego testu.
Ekosystem Javy jest zbudowany wokół JUnit: biblioteki do mockowania (Mockito), frameworki webowe (Spring), narzędzia CI (Jenkins, GitLab CI, GitHub Actions) – wszędzie znajdziesz wsparcie dla JUnit 5. Dzięki temu nie walczysz z narzędziami, tylko skupiasz się na testowaniu logiki.
Dodatkowy plus: składnia JUnit 5 jest przyjazna i nowoczesna. Adnotacje (@Test, @BeforeEach, @ParameterizedTest) i asercje są intuicyjne, a całość da się opanować „krok po kroku”, zaczynając od najprostszych przypadków. To dobry fundament, jeśli chcesz iść dalej – w kierunku TDD, testów integracyjnych czy automatyzacji w pipeline CI/CD.
Testy zmieniają sposób projektowania kodu
Programista, który na co dzień pisze testy jednostkowe, zaczyna naturalnie projektować kod „pod testowalność”. To oznacza m.in. mniejsze, spójne metody, sensowne podziały na klasy, unikanie zależności „na twardo” (np. new w środku metody, które później trudno obejść w testach). Taki styl przekłada się nie tylko na jakość testów, ale i na jakość samego kodu produkcyjnego.
Bez testów łatwo skończyć z klasą, która ma 1000 linii, 10 odpowiedzialności i zero możliwości bezpiecznej zmiany. Z testami szybciej zauważasz, że coś jest „nie do przetestowania” – to sygnał, że logika jest zbyt sklejona i warto ją rozdzielić. Sam proces testowania staje się mini-audytorem architektury.
Im szybciej zaczniesz myśleć kategoriami: „jak to przetestuję?”, tym mniej długów technicznych odłożysz na później. To szczególnie ważne, gdy wchodzisz do świata profesjonalnych projektów komercyjnych.
Zaczynaj z testami od pierwszych kroków
Dobry nawyk to pisanie testów od razu, jeszcze przy małych projektach, zadaniach z kursu czy ćwiczeniach z algorytmów. Kto zaczyna testować dopiero na „poważnym projekcie”, często czuje się przytłoczony. Gdy testy są częścią Twojej codziennej pracy, wchodzisz do większych projektów z przewagą – nie uczysz się testów pod presją deadline’ów.
Nawet jeśli tworzysz małą aplikację konsolową lub rozwiązujesz zadania z książki, dodanie dwóch–trzech testów jednostkowych pod trudniejszą metodę już buduje dobry nawyk. To inwestycja, która później bardzo ułatwia rozwój kariery programisty Java.

Przygotowanie środowiska: od zera do pierwszego uruchomionego testu
Podstawowe narzędzia: Java, IDE i system budowania
Na start wystarczy kilka elementów:
- JDK (Java Development Kit) – najlepiej wersja 11 lub wyższa.
- IDE: IntelliJ IDEA (Community wystarczy), Eclipse lub VS Code z rozszerzeniami do Javy.
- System budowania: Maven lub Gradle – większość projektów komercyjnych używa jednego z nich.
IDE ułatwi tworzenie klasy testowej, automatyczne generowanie szablonów metod testowych, a także integrację z JUnit 5. Maven lub Gradle zadbają o pobranie bibliotek JUnit z repozytorium Maven Central oraz uruchamianie testów z linii komend czy w pipeline CI.
Dodanie JUnit 5 w Maven: konkretny przykład konfiguracji
Przy Mavenie konfiguracja sprowadza się do dodania odpowiedniej zależności w pliku pom.xml w sekcji <dependencies>:
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0-M9</version>
<configuration>
<useModulePath>false</useModulePath>
</configuration>
</plugin>
</plugins>
</build>
Zalegający w wielu tutorialach JUnit 4 (junit:junit) to starsza wersja – dla nowych projektów stawiaj na JUnit 5 (Jupiter). Scope test oznacza, że biblioteka jest dostępna tylko przy kompilacji i uruchamianiu testów, nie w kodzie produkcyjnym.
Po dodaniu zależności wykonaj w katalogu projektu:
mvn testMaven pobierze JUnit 5, a jeśli nie ma jeszcze żadnych testów, po prostu zakończy działanie bez błędów. To dobry punkt startowy przed dodaniem pierwszej klasy testowej.
Dodanie JUnit 5 w Gradle: konfiguracja dla Javy
W przypadku Gradle (w wersji z Groovy DSL) plik build.gradle może wyglądać tak:
plugins {
id 'java'
}
repositories {
mavenCentral()
}
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.0'
}
test {
useJUnitPlatform()
}
Kluczowe elementy to testImplementation z biblioteką JUnit 5 oraz useJUnitPlatform(), które mówi Gradle’owi, aby użył nowej platformy JUnit. Po zapisaniu konfiguracji uruchom:
gradle testJeśli wszystko jest poprawnie ustawione, Gradle pobierze zależności i wykona (na razie puste) zestawy testów.
Struktura katalogów: gdzie trzymać testy
Konwencja przyjęta w Maven/Gradle jest prosta i warto jej się trzymać, bo narzędzia jej oczekują:
Jeśli interesują Cię konkrety i przykłady, rzuć okiem na: Jak zbudować skalowalną architekturę Java na Kubernetes.
- kod produkcyjny:
src/main/java - kod testowy:
src/test/java
Pakiety w testach odwzorowują strukturę kodu produkcyjnego. Jeśli masz klasę:
src/main/java/pl/example/service/PriceCalculator.javato testy trafiają do:
src/test/java/pl/example/service/PriceCalculatorTest.javaDzięki temu IDE łatwo dopasuje klasę testową do produkcyjnej, a Ty zawsze wiesz, gdzie szukać testów danej klasy. W większych projektach to ogromna ulga – nie trzeba skakać po losowych pakietach.
Pierwszy zielony test: szybki sukces
Najprostszy przykład: mała klasa z jedną metodą dodającą dwie liczby.
public class Calculator {
public int add(int a, int b) {
return a + b;
}
}
Kod testowy w JUnit 5 może wyglądać tak:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CalculatorTest {
@Test
void shouldAddTwoNumbers() {
// given
Calculator calculator = new Calculator();
// when
int result = calculator.add(2, 3);
// then
assertEquals(5, result, "2 + 3 powinno dać 5");
}
}
Uruchomienie tej klasy testowej z IDE (przycisk „Run test”) lub z linii komend (mvn test / gradle test) powinno dać pierwszy zielony test. Ten moment jest ważny psychologicznie – widzisz pełen przepływ: od kodu, przez test, do raportu z wynikiem.
Po takim małym sukcesie łatwiej dodać kolejne metody i testy, a testy jednostkowe w Javie przestają być „magią” i stają się normalnym elementem warsztatu.
Podstawy JUnit 5: adnotacje, cykl życia testu i asercje
Najważniejsze adnotacje w JUnit 5
JUnit 5 opiera się na adnotacjach, które informują framework, co jest testem, a co ma się wydarzyć przed/po testach. Najczęściej używane to:
- @Test – oznacza pojedynczą metodę testową.
- @BeforeEach – metoda uruchamiana przed każdym testem.
- @AfterEach – metoda uruchamiana po każdym teście.
- @BeforeAll – metoda uruchamiana raz przed wszystkimi testami w klasie.
- @AfterAll – metoda uruchamiana raz po wszystkich testach w klasie.
Przykładowa klasa wykorzystująca cykl życia testów:
import org.junit.jupiter.api.*;
class UserServiceTest {
@BeforeAll
static void initAll() {
System.out.println("Start klasy testowej");
}
@BeforeEach
void init() {
System.out.println("Start pojedynczego testu");
}
@Test
void shouldCreateUser() {
System.out.println("Test tworzenia użytkownika");
// asercje...
}
@Test
void shouldDeleteUser() {
System.out.println("Test usuwania użytkownika");
// asercje...
}
@AfterEach
void tearDown() {
System.out.println("Koniec pojedynczego testu");
}
@AfterAll
static void tearDownAll() {
System.out.println("Koniec klasy testowej");
}
}
W logach zobaczysz, że @BeforeEach i @AfterEach wywołują się przed i po każdym teście, a @BeforeAll i @AfterAll tylko raz. To dobry sposób na przygotowanie i sprzątanie zasobów (np. stubów, mocków, plików testowych).
Cykl życia klasy testowej w JUnit 5
Domyślnie JUnit 5 tworzy nową instancję klasy testowej dla każdego testu. Dzięki temu testy są izolowane – żaden test nie dziedziczy stanu po poprzednim. Jeśli trzymasz w polach obiekty używane w testach, każde uruchomienie metody oznaczonej @Test działa „na świeżo”.
Dla początkującego to dobra wiadomość: nie trzeba martwić się o ręczne resetowanie pól między testami. Jeśli chcesz współdzielić jakiś zasób (np. ciężką konfigurację), użyj statycznych metod z @BeforeAll i @AfterAll lub specjalnego trybu cyklu życia @TestInstance(TestInstance.Lifecycle.PER_CLASS), ale to temat na późniejszy etap.
Na starcie wystarczy świadomość: każdy test to osobny „świat”. Gdy testy zaczynają na siebie wpływać (np. ustawiasz pole w jednym teście, a w drugim liczysz, że będzie już ustawione), to czytelny sygnał, że dzieje się coś niepożądanego.
Podstawowe asercje JUnit 5 na prostych przykładach
Najczęściej używane rodzaje asercji
Asercje to serce testów jednostkowych – dzięki nim sprawdzasz, czy wynik działania kodu zgadza się z oczekiwaniami. W JUnit 5 wszystko znajduje się w klasie org.junit.jupiter.api.Assertions, którą zwykle importuje się statycznie:
import static org.junit.jupiter.api.Assertions.*;
Najbardziej podstawowe asercje:
- assertEquals(expected, actual) – porównanie dwóch wartości.
- assertNotEquals(unexpected, actual) – upewnienie się, że wartości się różnią.
- assertTrue(condition) / assertFalse(condition) – warunek logiczny.
- assertNull(value) / assertNotNull(value) – sprawdzenie, czy obiekt jest (nie)null.
- assertThrows() – oczekiwanie, że kod rzuci wyjątek.
- assertAll() – kilka asercji grupowanych razem.
Prosty zestaw przykładów:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class BasicAssertionsTest {
@Test
void equalityAssertions() {
int sum = 2 + 3;
assertEquals(5, sum);
assertNotEquals(6, sum);
}
@Test
void booleanAssertions() {
boolean isAdult = 20 >= 18;
assertTrue(isAdult);
assertFalse(5 > 10);
}
@Test
void nullAssertions() {
String name = null;
assertNull(name);
name = "Jan";
assertNotNull(name);
}
}
Dobrą praktyką jest dodawanie trzeciego argumentu tekstowego z opisem, gdy test dotyczy czegoś mniej oczywistego – w raporcie od razu widzisz, co poszło nie tak.
Testowanie wyjątków z assertThrows
Spora część logiki biznesowej opiera się na walidacjach i błędach. Kod, który nigdy nie rzuca wyjątków, jest podejrzany. W JUnit 5 do testowania wyjątków służy assertThrows:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ExceptionTest {
@Test
void shouldThrowIllegalArgumentExceptionForNegativeAmount() {
// given
PaymentService service = new PaymentService();
// when & then
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> service.pay(-100)
);
assertEquals("Amount must be positive", exception.getMessage());
}
}
class PaymentService {
void pay(int amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
// logika płatności...
}
}
assertThrows przyjmuje klasę wyjątku oraz lambdę z kodem, który ma go rzucić. Zwraca instancję wyjątku, więc możesz jeszcze sprawdzić jego komunikat lub inne właściwości. Po takim teście masz pewność, że walidacja faktycznie reaguje na niepoprawne dane.
Grupowanie asercji z assertAll
Gdy testujesz bardziej złożony obiekt, pojedyncza asercja to za mało. Zamiast pisać kilka testów, można pogrupować sprawdzenia w jednym teście za pomocą assertAll:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class OrderSummaryTest {
@Test
void shouldBuildCorrectOrderSummary() {
// given
Order order = new Order("ORD-1", "Jan", 3, 150.0);
// when & then
assertAll("order",
() -> assertEquals("ORD-1", order.getNumber()),
() -> assertEquals("Jan", order.getCustomerName()),
() -> assertEquals(3, order.getItemsCount()),
() -> assertEquals(150.0, order.getTotalPrice())
);
}
}
Różnica w stosunku do kilku kolejnych asercji polega na tym, że assertAll próbuje wykonać wszystkie przekazane lambdy i raportuje wszystkie błędy naraz. Przy debugowaniu skomplikowanych obiektów oszczędzasz kilka przebiegów testów. Warto się przyzwyczaić do tej konstrukcji już od prostych przykładów.
Asercje z komunikatami – szybsza diagnoza błędów
Każda asercja może otrzymać komunikat, który pojawi się przy niepowodzeniu testu. Krótkie, konkretne zdanie potrafi zaoszczędzić sporo czasu:
@Test
void shouldCalculateDiscount() {
DiscountService service = new DiscountService();
double discount = service.calculate(200);
assertEquals(20.0, discount, 0.001,
"Rabat 10% od 200 zł powinien wynosić 20 zł");
}
Dodatkowy parametr (np. 0.001 dla liczb zmiennoprzecinkowych) określa dopuszczalną różnicę. Przy liczbach double czy float to absolutna podstawa, bo działanie na nich bywa „niedokładne”. Komunikaty traktuj jak mini-dokumentację – przyszły Ty (albo kolega z zespołu) naprawdę to doceni.

Jak pisać czytelne testy: nazewnictwo, struktura i organizacja kodu testowego
Konwencje nazewnicze metod testowych
Nazwa testu powinna mówić, co jest testowane i w jakim scenariuszu. Zamiast domyślnego test1() czy testCreate(), lepiej używać opisowych nazw:
shouldCreateUserWhenDataIsValid()shouldRejectRegistrationWhenEmailIsInvalid()shouldReturnEmptyListForUnknownUser()
Często stosuje się prosty schemat: should[OczekiwaneZachowanie]When[Warunek]. W katalogu z testami od razu widać, co dana metoda sprawdza. Jeśli lubisz polskie nazwy, nic nie stoi na przeszkodzie, aby pisać np. powinienZapisacUzytkownikaGdyDaneSaPoprawne() – ważniejsze jest to, żeby nazwy były jednoznaczne niż „idealnie angielskie”.
Struktura testu: given–when–then
Aby test był czytelny, dobrze podzielić go wizualnie na trzy sekcje:
- given – przygotowanie danych i obiektów,
- when – wykonanie akcji,
- then – asercje (sprawdzenie wyniku).
Przykład z prostą logiką biznesową:
@Test
void shouldApplyLoyaltyDiscountForLongTermCustomer() {
// given
Customer customer = new Customer("Jan", 5); // 5 lat z nami
DiscountService discountService = new DiscountService();
// when
double discount = discountService.calculateFor(customer, 1000.0);
// then
assertEquals(50.0, discount, 0.001,
"Klient z 5-letnim stażem powinien dostać 5% rabatu");
}
Komentarze z // given, // when, // then można powtarzać konsekwentnie w całym projekcie. Dzięki temu nawet dłuższy test da się „przeskanować” wzrokiem w kilka sekund. Spróbuj tak ułożyć chociaż kilka pierwszych testów – różnica w czytelności jest ogromna.
Jedna asercja na test czy wiele? Zdrowy kompromis
Często powtarzane hasło brzmi: „jeden test – jedna asercja”. W praktyce ważniejsze jest, żeby test miał jedną odpowiedzialność, niż żeby zawierał dokładnie jedną instrukcję assert*. Dwa rozsądne podejścia:
- jeśli testujesz prostą funkcję, zwykle starczy jedna asercja,
- jeśli testujesz złożony obiekt zwracany z metody, użyj kilku asercji (lub
assertAll), ale nadal trzymaj się jednego scenariusza.
Zły przykład – test „od wszystkiego”:
@Test
void shouldTestEverything() {
// ...
assertEquals(10, result1);
assertEquals(20, result2);
assertTrue(user.isActive());
assertEquals("PLN", currency);
// ...
}
Taki test trudno zrozumieć i jeszcze trudniej utrzymać. Lepiej rozbić go na kilka mniejszych, każdy dla jednego fragmentu zachowania aplikacji. Dzięki temu szybciej zlokalizujesz źródło błędu, gdy coś się wysypie.
Organizacja klas testowych: co z czym łączyć
Najprostsza i bardzo skuteczna strategia to jedna klasa testowa na każdą klasę produkcyjną. Jeśli masz UserService, robisz UserServiceTest. Dodatkowo można grupować testy według modułów lub pakietów, tak jak w kodzie aplikacji:
src/main/java/pl/example/user/UserService.java
src/test/java/pl/example/user/UserServiceTest.java
Gdy klasa jest większa i ma dużo logiki, sensowne może być wydzielenie testów w osobne wewnętrzne klasy (nested tests), np. „testy rejestracji”, „testy logowania”. Pozwala to uporządkować scenariusze, ale na początek spokojnie wystarczy proste 1:1 – klasa produkcyjna do klasy testowej.
Unikanie powtórzeń w testach: setup w @BeforeEach
Jeśli w kilku testach pojawia się ta sama konfiguracja obiektu, wrzuć ją do metody oznaczonej @BeforeEach. Zyskasz krótsze testy i jedno miejsce do modyfikacji, gdy zajdzie taka potrzeba:
Jeśli chcesz pójść krok dalej, pomocny może być też wpis: Jak zbudować własny model klasyfikacji maili spam/nie-spam.
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class LoyaltyServiceTest {
private LoyaltyService loyaltyService;
private Customer silverCustomer;
private Customer goldCustomer;
@BeforeEach
void setUp() {
loyaltyService = new LoyaltyService();
silverCustomer = new Customer("Jan", 2);
goldCustomer = new Customer("Anna", 6);
}
@Test
void shouldGiveSmallBonusForSilverCustomer() {
// when
int bonus = loyaltyService.calculateBonus(silverCustomer);
// then
assertEquals(20, bonus);
}
@Test
void shouldGiveBiggerBonusForGoldCustomer() {
// when
int bonus = loyaltyService.calculateBonus(goldCustomer);
// then
assertEquals(60, bonus);
}
}
Dzięki temu każdy test koncentruje się na tym, co zmienne (dane wejściowe, oczekiwany wynik), a to co wspólne zostaje schowane w setupie. W kilku projektach taki prosty porządek uratował autorom skórę, gdy trzeba było hurtowo zmienić sposób tworzenia obiektów.

Testowanie logiki biznesowej na przykładach: od prostych metod do warunków brzegowych
Testowanie prostych metod: szybkie wygrane
Na rozgrzewkę najlepiej wziąć małe metody, które przyjmują parametry i zwracają wartość. Taki kod jest łatwy do pokrycia testami, a efekty widać od razu. Przykład – prosty serwis z obliczaniem prowizji:
class CommissionService {
double calculate(double amount) {
if (amount <= 0) {
return 0.0;
}
if (amount < 1000) {
return amount * 0.02;
}
return amount * 0.015;
}
}
Odpowiadające testy mogą sprawdzać kilka podstawowych scenariuszy:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class CommissionServiceTest {
private final CommissionService service = new CommissionService();
@Test
void shouldReturnZeroForNonPositiveAmount() {
// when
double result = service.calculate(0);
// then
assertEquals(0.0, result, 0.0001);
}
@Test
void shouldUseHigherRateForAmountBelow1000() {
// when
double result = service.calculate(500);
// then
assertEquals(10.0, result, 0.0001); // 2% z 500
}
@Test
void shouldUseLowerRateForAmountAboveOrEqual1000() {
// when
double result = service.calculate(2000);
// then
assertEquals(30.0, result, 0.0001); // 1.5% z 2000
}
}
Takie testy pisze się ekspresowo, a przy każdej późniejszej zmianie logiki masz natychmiastową informację, czy niczego nie zepsułeś. Zacznij właśnie od nich – pierwsze sukcesy motywują do sięgania po trudniejsze przypadki.
Scenariusze typowe vs. brzegowe
Przy logice biznesowej najczęściej występują trzy rodzaje scenariuszy:
- scenariusze typowe – normalne, codzienne przypadki użycia,
- scenariusze brzegowe – minimalne/maksymalne wartości, „zero klientów”, „pusta lista”,
- scenariusze błędne – niepoprawne dane wejściowe, które powinny zostać odrzucone.
Dobry zestaw testów obejmuje wszystkie trzy grupy. Weźmy przykład prostego kalkulatora rabatów uzależnionych od liczby produktów:
class BulkDiscountService {
int calculate(int itemsCount) {
if (itemsCount <= 0) {
throw new IllegalArgumentException("Items count must be positive");
}
if (itemsCount < 10) {
return 0;
}
if (itemsCount < 50) {
return 5;
}
return 10;
}
}
Testy adresujące różne typy scenariuszy:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class BulkDiscountServiceTest {
private final BulkDiscountService service = new BulkDiscountService();
@Test
void shouldReturnNoDiscountForLessThan10Items() {
assertEquals(0, service.calculate(1));
assertEquals(0, service.calculate(9));
}
@Test
void shouldReturn5PercentFor10To49Items() {
assertEquals(5, service.calculate(10));
assertEquals(5, service.calculate(49));
}
@Test
void shouldReturn10PercentFor50OrMoreItems() {
assertEquals(10, service.calculate(50));
assertEquals(10, service.calculate(100));
}
@Test
void shouldThrowExceptionForNonPositiveItemsCount() {
assertThrows(IllegalArgumentException.class,
() -> service.calculate(0));
assertThrows(IllegalArgumentException.class,
() -> service.
```html
() -> service.calculate(-5));
}
}
Taki zestaw testów daje przyzwoite pokrycie: są przypadki „środka” (typowe zamówienia), okolice progów (10, 49, 50) oraz dane niepoprawne (0, wartości ujemne). Przy każdej zmianie logiki wiadomo od razu, co zostało naruszone.
Testowanie zachowania w pobliżu progów (off-by-one)
Błędy typu „off-by-one” (o jeden za dużo / za mało) pojawiają się wszędzie tam, gdzie jest porównanie z granicą: <, <=, >, >=. Dlatego testy powinny obejmować nie tylko sam próg, ale też wartości tuż obok. Prosty przykład limitu dziennego przelewów:
class DailyTransferLimitValidator {
private final double dailyLimit;
DailyTransferLimitValidator(double dailyLimit) {
this.dailyLimit = dailyLimit;
}
boolean canTransfer(double todaysTotal, double newTransferAmount) {
return todaysTotal + newTransferAmount <= dailyLimit;
}
}
Testy koncentrują się na trzech punktach: trochę poniżej limitu, dokładnie na limicie i trochę powyżej:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class DailyTransferLimitValidatorTest {
private final DailyTransferLimitValidator validator =
new DailyTransferLimitValidator(1000.0);
@Test
void shouldAllowTransferWhenSumIsBelowLimit() {
assertTrue(validator.canTransfer(400.0, 500.0)); // 900 < 1000
}
@Test
void shouldAllowTransferWhenSumIsExactlyOnLimit() {
assertTrue(validator.canTransfer(600.0, 400.0)); // 1000 == 1000
}
@Test
void shouldRejectTransferWhenSumExceedsLimit() {
assertFalse(validator.canTransfer(900.0, 200.0)); // 1100 > 1000
}
}
Kilka takich testów potrafi ochronić przed produkcyjną „wtopą”, gdzie klient nie może wykonać przelewu mimo że nie przekroczył limitu – albo odwrotnie, system pozwala na zbyt dużo.
Testowanie kodu opartego na datach i czasie
Data i czas to klasyczny generator dziwnych bugów. Jeśli logika zależy od „dzisiaj”, „jutra” albo „końca miesiąca”, lepiej nie opierać się w testach na aktualnym systemowym czasie. Zamiast tego można wstrzyknąć zegar (Clock) i kontrolować go w testach.
Załóżmy, że jest prosty serwis zniżek urodzinowych:
import java.time.Clock;
import java.time.LocalDate;
class BirthdayDiscountService {
private final Clock clock;
BirthdayDiscountService(Clock clock) {
this.clock = clock;
}
boolean hasBirthdayToday(LocalDate birthDate) {
LocalDate today = LocalDate.now(clock);
return today.getMonth() == birthDate.getMonth()
&& today.getDayOfMonth() == birthDate.getDayOfMonth();
}
}
W testach da się ustawić konkretną „dzisiejszą” datę i przewidzieć wynik:
import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import static org.junit.jupiter.api.Assertions.*;
class BirthdayDiscountServiceTest {
@Test
void shouldDetectBirthdayWhenTodayMatchesBirthDate() {
// given
Clock fixedClock = Clock.fixed(
Instant.parse("2023-05-10T10:00:00Z"),
ZoneOffset.UTC);
BirthdayDiscountService service = new BirthdayDiscountService(fixedClock);
LocalDate birthDate = LocalDate.of(1990, 5, 10);
// when
boolean result = service.hasBirthdayToday(birthDate);
// then
assertTrue(result);
}
@Test
void shouldReturnFalseWhenTodayIsDifferentThanBirthDate() {
// given
Clock fixedClock = Clock.fixed(
Instant.parse("2023-05-11T10:00:00Z"),
ZoneOffset.UTC);
BirthdayDiscountService service = new BirthdayDiscountService(fixedClock);
LocalDate birthDate = LocalDate.of(1990, 5, 10);
// when
boolean result = service.hasBirthdayToday(birthDate);
// then
assertFalse(result);
}
}
Taka strategia usuwa z testów element losowości i pozwala powtarzać je w identycznych warunkach za każdym razem. Przy większym projekcie szybko daje to zauważalny spadek „flaky testów”. Warto przejrzeć własny kod i tam, gdzie jest LocalDate.now() lub Instant.now(), rozważyć wprowadzenie zegara do konstruktorów.
Testowanie logiki opartej na kolekcjach i agregacjach
W aplikacjach biznesowych często występuje logika zliczania, sumowania i filtrowania elementów list. Tam też lubią się chować błędy. Prosty przykład – raport sprzedaży zwracający łączną wartość zamówień złożonych przez aktywnych klientów:
import java.util.List;
class SalesReportService {
double totalForActiveCustomers(List<Order> orders) {
if (orders == null || orders.isEmpty()) {
return 0.0;
}
return orders.stream()
.filter(order -> order.getCustomer().isActive())
.mapToDouble(Order::getTotalAmount)
.sum();
}
}
class Order {
private final Customer customer;
private final double totalAmount;
Order(Customer customer, double totalAmount) {
this.customer = customer;
this.totalAmount = totalAmount;
}
Customer getCustomer() {
return customer;
}
double getTotalAmount() {
return totalAmount;
}
}
class Customer {
private final boolean active;
Customer(boolean active) {
this.active = active;
}
boolean isActive() {
return active;
}
}
Testy powinny objąć kilka reprezentatywnych układów danych:
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class SalesReportServiceTest {
private final SalesReportService service = new SalesReportService();
@Test
void shouldReturnZeroForEmptyList() {
// when
double result = service.totalForActiveCustomers(Collections.emptyList());
// then
assertEquals(0.0, result, 0.0001);
}
@Test
void shouldSumOnlyActiveCustomersOrders() {
// given
Customer active = new Customer(true);
Customer inactive = new Customer(false);
List<Order> orders = Arrays.asList(
new Order(active, 100.0),
new Order(inactive, 200.0),
new Order(active, 50.0)
);
// when
double result = service.totalForActiveCustomers(orders);
// then
assertEquals(150.0, result, 0.0001);
}
@Test
void shouldReturnZeroWhenNoActiveCustomers() {
// given
Customer inactive1 = new Customer(false);
Customer inactive2 = new Customer(false);
List<Order> orders = Arrays.asList(
new Order(inactive1, 100.0),
new Order(inactive2, 200.0)
);
// when
double result = service.totalForActiveCustomers(orders);
// then
assertEquals(0.0, result, 0.0001);
}
}
Przy takim podejściu łatwo uniknąć sytuacji, w której po kilku zmianach logika „przepuszcza” nieaktywne rekordy albo źle traktuje pustą listę. Dobrym zwyczajem jest zawsze mieć chociaż jeden test z pustą listą i jeden z miksowaną zawartością (różne typy elementów).
Testy parametryzowane w JUnit 5: mniej duplikacji, więcej pokrycia
Dlaczego testy parametryzowane oszczędzają czas
Przy logice z wieloma wariantami wejścia standardowe testy szybko zaczynają się powtarzać: ten sam kod, inne wartości. Testy parametryzowane pozwalają zdefiniować scenariusz raz, a potem „podstawić” różne dane w formie tabelki. Dzięki temu:
- łatwiej dopisać nowe przypadki – dodaje się tylko kolejną linię danych,
- zmiana oczekiwanej logiki wymaga modyfikacji jednego testu zamiast kilku podobnych,
- sam test staje się bardziej czytelny, bo widać wszystkie kombinacje wejścia i wyjścia w jednym miejscu.
Podstawy: @ParameterizedTest i @ValueSource
Najprostszy wariant to test z jedną listą wartości. Załóżmy metodę sprawdzającą, czy liczba jest parzysta:
class NumberUtils {
static boolean isEven(int number) {
return number % 2 == 0;
}
}
Zamiast pisać kilka osobnych testów, można użyć @ParameterizedTest i @ValueSource:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;
class NumberUtilsTest {
@ParameterizedTest
@ValueSource(ints = {0, 2, 4, 100, -2})
void shouldReturnTrueForEvenNumbers(int number) {
// when
boolean result = NumberUtils.isEven(number);
// then
assertTrue(result);
}
}
Wszystkie podane wartości zostaną przetestowane tym samym kodem. W raportach testów każda wartość jest widoczna jako osobne uruchomienie, co ułatwia diagnozowanie błędów.
Więcej niż jedna kolumna: @CsvSource
Gdy trzeba przetestować zależność wejście → oczekiwane wyjście, wygodniejsza jest forma „tabelki”. Testy stają się wtedy bardzo przejrzyste. Przykład znajomego już kalkulatora prowizji, ale przepisany na test parametryzowany:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.*;
class CommissionServiceParameterizedTest {
private final CommissionService service = new CommissionService();
@ParameterizedTest(name = "amount={0} should result in commission={1}")
@CsvSource({
"0, 0.0",
"500, 10.0",
"1000, 15.0",
"2000, 30.0"
})
void shouldCalculateCommissionForVariousAmounts(double amount, double expectedCommission) {
// when
double result = service.calculate(amount);
// then
assertEquals(expectedCommission, result, 0.0001);
}
}
Adnotacja name w @ParameterizedTest poprawia opisy poszczególnych przypadków w raportach. Dzięki temu przy nieudanym teście wiadomo od razu, dla której wartości coś poszło nie tak.
Testy parametryzowane dla scenariuszy brzegowych
Testy parametryzowane świetnie nadają się do „obstrzeliwania” granic i rzadkich przypadków. Zamiast pisać kilka prawie identycznych metod, wystarczy po prostu dodać kolejną linię w @CsvSource lub @ValueSource. Weźmy wcześniejszy BulkDiscountService i dopiszmy testy w formie zestawu danych:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.*;
class BulkDiscountServiceParameterizedTest {
private final BulkDiscountService service = new BulkDiscountService();
@ParameterizedTest(name = "items={0} should give discount={1}")
@CsvSource({
"1, 0",
"9, 0",
"10, 5",
"49, 5",
"50, 10",
"100,10"
})
void shouldCalculateProperDiscountForDifferentItemsCount(int itemsCount, int expectedDiscount) {
// when
int discount = service.calculate(itemsCount);
// then
assertEquals(expectedDiscount, discount);
}
}
Jeśli pojawi się zgłoszenie od użytkownika, że dla 51 sztuk rabat jest zły, wystarczy dodać "51, 10" do listy i od razu widać, czy problem faktycznie istnieje. To bardzo wygodny sposób na zamianę zgłoszeń błędów w powtarzalne testy.
Oddzielanie danych testowych: @MethodSource
Gdy zestawy danych robią się większe albo wymagają bardziej złożonego przygotowania (np. obiekty domenowe), lepiej przenieść je do osobnej metody. Do tego służy @MethodSource. Przykład – serwis naliczania punktów lojalnościowych:
To ten sam zestaw narzędzi, którego używa się przy bardziej zaawansowanych tematach, takich jak więcej o programowanie, architektury rozproszone czy aplikacje w chmurze. Im szybciej się z nimi oswoisz, tym mniej barier technicznych napotkasz później.
class LoyaltyPointsService {
int calculatePoints(int orderAmount, boolean isVip) {
if (orderAmount <= 0) {
return 0;
}
int base = orderAmount / 10;
return isVip ? base * 2 : base;
}
}
Test parametryzowany z MethodSource może wyglądać tak:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.Arguments;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
class LoyaltyPointsServiceTest {
private final LoyaltyPointsService service = new LoyaltyPointsService();
@ParameterizedTest(name = "amount={0}, vip={1} => points={2}")
@MethodSource("pointsData")
void shouldCalculatePointsForDifferentAmountsAndStatuses(
int orderAmount, boolean isVip, int expectedPoints) {
// when
int result = service.calculatePoints(orderAmount, isVip);
// then
assertEquals(expectedPoints, result);
}
static Stream<Arguments> pointsData() {
return Stream.of(
Arguments.of(0, false, 0),
Arguments.of(0, true, 0),
Arguments.of(50, false, 5),
Arguments.of(50, true, 10),
Arguments.of(99, false, 9),
Arguments.of(100, true, 20)
);
}
}
Takie podejście pozwala lepiej organizować dane: w razie potrzeby można je wydzielić nawet do osobnej klasy pomocniczej lub rozbudować o komentarze. Gdy wariantów przybywa, dopisanie kolejnego wiersza w pointsData() trwa dosłownie chwilę.
Parametryzowane testy wyjątków
Wyjątki też nadają się do parametryzacji. Często występuje kilka kombinacji danych, które wszystkie powinny kończyć się tym samym błędem. Na przykład prosty walidator hasła:
class PasswordValidator {
void validate(String password) {
if (password == null || password.isBlank()) {
throw new IllegalArgumentException("Password cannot be empty");
}
if (password.length() < 8) {
throw new IllegalArgumentException("Password too short");
}
}
}
Test parametryzowany może zebrać „złe” przypadki w jednym miejscu:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;
class PasswordValidatorTest {
private final PasswordValidator validator = new PasswordValidator();
@ParameterizedTest
@ValueSource(strings = {"", " ", "abc", "1234567"})
void shouldRejectInvalidPasswords(String password) {
assertThrows(IllegalArgumentException.class,
() -> validator.
Najczęściej zadawane pytania (FAQ)
Po co początkującemu programiście JUnit 5 i testy jednostkowe w Javie?
JUnit 5 i testy jednostkowe pomagają szybko wychwytywać błędy w logice biznesowej, zanim uruchomisz aplikację „na poważnie”. Zamiast godzinnego debugowania, od razu widzisz, która metoda zachowuje się nie tak, jak trzeba (np. źle liczy rabat dla wartości ujemnych).
Dodatkowo testy dają psychiczny komfort przy zmianach: możesz śmiało refaktoryzować kod, bo jeśli coś zepsujesz, testy od razu to pokażą. To świetny nawyk już na etapie nauki – wchodzisz potem w komercyjne projekty z dużo większą pewnością siebie.
Czym różnią się testy jednostkowe od integracyjnych i manualnych?
Testy jednostkowe sprawdzają pojedynczą metodę lub mały fragment logiki w całkowitej izolacji – bez bazy danych, sieci, UI. Odpowiadają na pytanie: „Czy ta konkretna metoda robi dokładnie to, czego od niej oczekuję?”.
Testy integracyjne badają współpracę wielu komponentów naraz (np. serwisu z bazą danych), a testy manualne polegają na „klikalii” w aplikację i obserwowaniu, czy wszystko działa. Testy jednostkowe są najszybsze i najtańsze w utrzymaniu, dlatego świetnie nadają się do codziennej pracy i częstego uruchamiania.
Dlaczego do testów w Javie lepiej użyć JUnit 5 niż JUnit 4?
JUnit 5 jest aktualnym standardem w świecie Javy – ma nowoczną, czytelną składnię, lepsze wsparcie w IDE i pełną integrację z narzędziami takimi jak Spring, Mockito czy systemy CI (Jenkins, GitLab CI, GitHub Actions). Nie musisz walczyć z konfiguracją, skupiasz się na pisaniu testów.
Starszy JUnit 4 (junit:junit) nadal działa w wielu projektach, ale dla nowych aplikacji lepiej startować od razu z JUnit 5 (moduł Jupiter). Oszczędzasz sobie późniejszej migracji i od razu uczysz się współczesnych praktyk.
Jak dodać JUnit 5 do projektu Maven lub Gradle?
W Mavenie dodajesz zależność do sekcji <dependencies>:
org.junit.jupiter:junit-jupiter:5.10.0 ze <scope>test</scope>, aby biblioteka była widoczna tylko w testach.
- Konfigurujesz plugin
maven-surefire-plugin, by poprawnie uruchamiał testy na platformie JUnit 5.
W Gradle (Groovy DSL) dodajesz testImplementation 'org.junit.jupiter:junit-jupiter:5.10.0' oraz w sekcji test {} wywołujesz useJUnitPlatform(). Po zapisaniu konfiguracji wystarczy uruchomić mvn test albo gradle test i masz gotowe środowisko do pisania pierwszych testów.
Gdzie w projekcie Java trzyma się testy jednostkowe?
Standardowa struktura w Maven/Gradle jest bardzo prosta i warto się jej trzymać:
- kod produkcyjny:
src/main/java
- kod testowy:
src/test/java
Pakiety w testach zwykle odwzorowują strukturę kodu produkcyjnego. Jeśli klasa jest w src/main/java/com.example.service, to jej test trzymasz w src/test/java/com.example.service. Dzięki temu IDE i narzędzia budujące automatycznie znajdują i uruchamiają Twoje testy.
Czy warto pisać testy jednostkowe już przy małych, „naukowych” projektach?
Zdecydowanie tak. Nawet dwa–trzy testy pod trudniejszą metodę w małej aplikacji konsolowej uczą Cię myślenia o kodzie w małych, logicznych całościach. Ten nawyk procentuje później, gdy trafisz do większego projektu z realnymi deadline’ami.
Jeśli od początku pytasz siebie „jak to przetestuję?”, naturalnie projektujesz prostsze, czytelniejsze metody i klasy. W efekcie szybciej się rozwijasz jako programista i dużo swobodniej wchodzisz w świat profesjonalnych zespołów.
Jak testy jednostkowe wpływają na sposób projektowania kodu w Javie?
Regularne pisanie testów sprawia, że zaczynasz projektować kod „pod testowalność”: metody stają się krótsze, klasy mają jedną odpowiedzialność, a zależności nie są tworzone „na sztywno” wewnątrz metod. To naturalnie prowadzi do lepszej architektury i mniejszej liczby „magicznych” zależności.
Jeśli coś jest trudne do przetestowania, to często sygnał, że logika jest za bardzo sklejona i warto ją podzielić. Traktuj testy jak małego audytora architektury – dzięki nim szybciej wyłapujesz problemy strukturalne i robisz zdrowszy kod od pierwszych tygodni nauki.
Najważniejsze wnioski
- Test jednostkowy to krótki, automatyczny kod sprawdzający pojedynczą metodę w pełnej izolacji, bez bazy danych, sieci czy UI – ma odpowiedzieć jasno, czy dana logika działa zgodnie z oczekiwaniem.
- Regularne pisanie testów jednostkowych zmniejsza stres i skraca debugowanie, bo błędy wychodzą natychmiast, zanim aplikacja trafi „na poważnie” do uruchomienia czy na środowisko testowe.
- Pokrycie kodu testami daje realną odwagę do refaktoryzacji – można zmieniać implementację, dzielić klasy i metody, a w razie pomyłki testy od razu sygnalizują problem zamiast klienta na produkcji.
- Dobrze napisane testy stają się żywą dokumentacją: z samych przypadków testowych da się szybko zrozumieć, jak ma się zachowywać metoda w różnych sytuacjach, co przyspiesza wdrożenie nowych osób do projektu.
- JUnit 5 jest naturalnym standardem w Javie – integruje się z IDE, Mavenem/Gradle, narzędziami CI i bibliotekami typu Mockito, dzięki czemu możesz skupić się na logice, a nie na walce z konfiguracją.
- Myślenie „jak to przetestuję?” wymusza lepszy design: mniejsze, spójne metody, sensowny podział na klasy i luźniejsze zależności; w praktyce to mniej długów technicznych i kod, który łatwiej rozwijać latami.
- Najlepszy moment na start z testami to małe projekty i ćwiczenia – kilka prostych testów do trudniejszych metod buduje nawyk, który później daje ogromną przewagę w komercyjnych projektach Java, więc zacznij od kolejnego zadania.






