1. Smoke test i uruchomienie kontekstu
Jeśli kiedykolwiek składałeś komputer lub podłączałeś nowy monitor, znasz ten rytuał: najpierw trzeba upewnić się, że wszystko włącza się, a dopiero potem ustalać, dlaczego w ulubionej grze FPS nie jest taki jak chciałeś. W aplikacji Spring jest mniej więcej to samo. Zanim testować scenariusze i zachowanie, przydatny jest szybki sygnał: kontekst startuje, wiring nie jest zepsuty, kluczowe serwisy są na miejscu — znaczy przynajmniej nie próbujemy naprawiać statku kosmicznego, który nie ma silnika.
@SpringJUnitConfig już zdjął z testu ręczny bootstrap kontekstu. Teraz można używać kontenera nie ze względu na samą adnotację, lecz ze względu na najtańsze użyteczne pytanie: czy aplikacja w ogóle się składa, czy pada już przy starcie?
Słowo smoke w „smoke test" — to nie metafora poziomu „poetycka wiosna". To bardzo inżynierska metafora: jeśli urządzenie włączyłeś, a ono nie zakopcziło, znaczy bazowe składanie przynajmniej przypomina prawdę. Dla Spring jest to szczególnie aktualne, bo wiele problemów objawia się przy starcie: brakujący bean, niejednoznaczność kandydatów, zły klucz właściwości, zepsuty fragment XML, cykliczna zależność, nie załadował się zasób, zepsuła się konwersja. Możesz nie wykonać ani jednego wywołania biznesowego, a kontekst już padnie — i dobrze: fail-fast lepiej niż „wszystko padło przez 20 minut demo".
I tu ważne jest utrzymanie właściwej skali: smoke test nie musi (i nie powinien) dowodzić, że „tworzenie zamówienia działa poprawnie". Nie powinien zamieniać się w mini-integracyjny scenariusz. Jego zadanie jest znacznie skromniejsze i użyteczne: sprawdzić, że konfiguracja aplikacji się składa, i że można pobrać kilka krytycznych beanów — tych samych „nośnych belek", na których opiera się cały graf obiektów.
2. Przeznaczenie context smoke test
Gdy pytanie jest już ewidentnie kontenerowe, pozostaje jeszcze jedna różnica: potrzebujesz szybkiego sygnału o starcie czy pełnego scenariusza z kilkoma beanami? Zanim napiszesz smoke test, warto się z sobą umówić: „na jakie pytanie odpowiada ten test?" Idealne sformułowanie brzmi mniej więcej tak: kontekst startuje i kluczowe beany rozwiązują się. To nie jest „w kontekście jest 200 beanów", nie „każdy bean jest nie-null", i zdecydowanie nie „wszystkie przypadki użycia działają". To właśnie weryfikacja wiringu i startu: kontener mógł przeczytać konfigurację, zbudować graf zależności i stworzyć potrzebne obiekty.
Żeby nie mylić typów testów, wygodnie jest trzymać w głowie prostą tabelkę. Ona nie jest o „poprawności" jako religii, lecz o tym, jakie pytanie zadajesz:
| Typ testu | Podnosi kontekst Spring | Główne pytanie | Typyczny wynik padnięcia |
|---|---|---|---|
| Test jednostkowy | nie | „Czy logika jednej klasy działa?" | AssertionError lub twój błąd biznesowy |
| Context smoke test | tak | „Czy aplikacja składa się przez kontener?" | Failed to load ApplicationContext, UnsatisfiedDependencyException i spółka |
| Scenariuszowy test kontekstowy | tak | „Czy mały scenariusz z kilku beanów zachowuje się poprawnie?" | albo błąd wiringu, albo już błąd behawioralny |
Zwróć uwagę: nie idziemy dziś w testy scenariuszowe i event flow (to będzie następny wykład dnia). Teraz świadomie robimy prostszy krok: upewniamy się, że kontekst w ogóle startuje i nie pada przy tworzeniu kluczowych komponentów.
W smoke test zazwyczaj wystarczą minimalne assertions. Co więcej, często sam fakt, że klasa testowa w ogóle się uruchomiła, już oznacza, że kontekst był podniesiony. Wtedy assertNotNull(...) staje się nie tyle „weryfikacją null", co jawnym dokumentowaniem, które beany uważamy za krytyczne. To przydatne, bo za miesiąc otworzysz test i od razu zobaczysz: „aha, te części systemu zawsze powinny być żywe".
3. Minimalny smoke test dla ContextFlow
Teraz złóżmy to w konkretny przykład dla naszego projektu ContextFlow. Logika będzie maksymalnie prosta: podnieść kontekst z głównej konfiguracji i upewnić się, że kilka kluczowych serwisów jest dostępnych. Na początek pokażę wariant, gdzie po prostu wstrzykujemy potrzebne beany bezpośrednio do testu — wygląda to krótko i czyta się jak „kontekst mógł je stworzyć".
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.junit.jupiter.api.Assertions.assertNotNull;
// Podnosimy kontekst Spring na bazie głównej konfiguracji aplikacji.
@SpringJUnitConfig(ContextFlowAppConfig.class)
class ContextFlowSmokeTest {
// Kluczowy serwis: jeśli się nie stworzył, aplikacja w istocie nie startuje.
@Autowired
OrderPlacementService orderPlacementService;
@Test
void contextStarts() {
// Smoke test: ważne, że bean rozwiązuje się przez kontener (a nie zachowanie use-case).
assertNotNull(orderPlacementService);
}
}
Ten test nie robi nic „mądrego". I właśnie dlatego jest użyteczny: jeśli wiring jest zepsuty, kontekst padnie przed wykonaniem metody testowej. Jeśli metoda testowa wystartowała, znaczy kontekst załadował się, a OrderPlacementService naprawdę został stworzony przez kontener. Dodaliśmy assertNotNull(...) nie dlatego, że boimy się null jako zjawiska natury, lecz żeby utrwalić: „ten serwis jest dla nas podstawową nośną częścią systemu".
Często chcesz sprawdzić nie jeden bean, lecz kilka od razu — i to normalna ewolucja smoke test. Ale trzymaj go w małej skali, nie zamieniaj w „spis ludności kontenera":
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.junit.jupiter.api.Assertions.assertNotNull;
// Ten sam kontekst, ale jawnie utrwalamy kilka „nośnych" serwisów.
@SpringJUnitConfig(ContextFlowAppConfig.class)
class ContextFlowSmokeTest {
// Wejście do głównego use-case.
@Autowired OrderPlacementService orderPlacementService;
// Wejście do podsystemu raportowania.
@Autowired ReportingService reportingService;
@Test
void keyBeansAreResolvable() {
// Jawnie dokumentujemy: te części zawsze powinny wstawać.
assertNotNull(orderPlacementService);
assertNotNull(reportingService);
}
}
Czasem wygodniej jest pobierać ApplicationContext, zwłaszcza jeśli chcesz jawnie wyrazić „sprawdzam właśnie kontener" i selektywnie robić getBean(...) do diagnostyki. Dla smoke test jest to też dopuszczalne, tylko nie zamieniaj tego w nawyk dla kodu biznesowego (ten nawyk przez cały kurs wytępialiśmy).
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.junit.jupiter.api.Assertions.assertNotNull;
// Wariant, gdzie sprawdzamy dostępność beana przez API kontekstu.
@SpringJUnitConfig(ContextFlowAppConfig.class)
class ContextFlowContextApiSmokeTest {
// Wstrzykujemy sam kontener: wygodne do punktowych sprawdzeń getBean(...).
@Autowired
ApplicationContext context;
@Test
void reportingServiceIsAvailable() {
// Ważne: smoke test sprawdza, że bean jest tworzony, a nie że „działa poprawnie".
assertNotNull(context.getBean(ReportingService.class));
}
}
Sens smoke test pozostaje ten sam: nie sprawdzamy „poprawności raportu" i nie uruchamiamy scenariusza. Sprawdzamy, że kontener może oddać ReportingService, a więc mógł go stworzyć ze wszystkimi jego zależnościami.
4. Kluczowe beany dla smoke test
W tym miejscu zazwyczaj pojawia się pytanie: „A które dokładnie beany uważać za kluczowe? Dlaczego nie sprawdzić w ogóle wszystkiego? A nuż gdzieś się chowa problem". Teoretycznie można, ale w praktyce szybko dostaniesz test, który albo duplikuje sam kontener, albo rozpada się przy każdym refaktoryzacji, albo startuje wolno, albo wszystko naraz. Dlatego lepiej wybrać kilka „load-bearing beans" — tych, które reprezentują ważne części składania i dotyczą różnych gałęzi grafu zależności.
W ContextFlow wygodna logika wyboru jest taka: bierzemy serwisy, które są „wejściami" do głównych podsystemów. Warstwa serwisowa jest już starannie zbudowana na constructor injection, zdarzenia są wyniesione do listenerów, są zasoby, komunikaty, konfiguracja, a pod koniec kursu dołączył mały fragment legacy XML. Dobry smoke test powinien przynajmniej pośrednio potwierdzić, że ta „układanka" się skleja.
Spójrz na przybliżoną mapę „nośnych" komponentów. Nie musi się pokrywać z twoim projektem bajt w bajt (możesz inaczej nazwać klasy), ale idea powinna być znajoma:
| Podsystem | Co „dowodzi" pod względem wiringu | Przykład beana dla smoke test |
|---|---|---|
| Główny use-case (tworzenie zamówienia) | Złożony jest główny flow orkiestracji: store, generator id, pricing, event publisher | OrderPlacementService |
| Anulowanie zamówienia | Złożony jest alternatywny scenariusz, często z innym zestawem zależności | OrderCancellationService |
| Raportowanie | Podłączona jest część infrastruktury: formatter, output manager, zasoby | ReportingService |
| Model zdarzeniowy | Listenery wstały i zarejestrowały się (przynajmniej kontener nie padł) | dowolny listener-bean, np. |
| Konfiguracja i środowisko | (minimalnie) właściwości odczytały się, konwersja nie jest zepsuta | Environment lub bean, który czyta ustawienia |
| Gałąź legacy XML | Fragment XML nadal ładuje się i daje beany | NotificationSender z kontekstu XML |
Zwróć uwagę na ważny niuans: w projekcie mamy interfejsy z kilkoma implementacjami (NotificationSender, DiscountPolicy). To normalne. Smoke test nie powinien „przypadkowo" żądać NotificationSender po typie i zaczynać padać NoUniqueBeanDefinitionException, jeśli twój design zakłada kilka implementacji i wybierasz je przez @Qualifier lub przez Map<String, NotificationSender>. Czyli smoke test powinien sprawdzać nie „wszystko po kolei", lecz to, co jest naprawdę krytyczne i jednoznacznie oczekiwane w danym kontekście.
Jeśli wątpisz, co wybrać, zadaj sobie proste pytanie: „Jeśli ten bean się nie tworzy, czy aplikacja ma sens uruchamiać?" Jeśli odpowiedź „nie", kandydat do smoke test znaleziony.
5. Smoke test dla legacy XML
Legacy XML w naszym kursie — to mały i izolowany fragment. Ale właśnie w tym tkwi podstęp: rzadko go dotykasz, a może się zepsuć „po drodze", gdy zmieniałeś nazwę klasy, przenosiłeś pakiet lub sprzątałeś zasoby. I wyjdzie to na jaw w najgorszym momencie, bo XML zazwyczaj wspomina się nie podczas codziennych commitów, lecz „kiedy już trzeba".
Dlatego osobny smoke test, który po prostu sprawdza, że kontekst XML startuje — to świetne ubezpieczenie. Przykład jest minimalistyczny:
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.junit.jupiter.api.Assertions.assertNotNull;
// Podnosimy kontekst testowy z fragmentu XML (bez głównej konfiguracji Java).
@SpringJUnitConfig(locations = "classpath:legacy/legacy-notification-context.xml")
class LegacyNotificationXmlSmokeTest {
// Tu ważne: test sprawdza, że XML tworzy potrzebny bean.
@Autowired
NotificationSender notificationSender;
@Test
void xmlFragmentLoads() {
// Jeśli XML się nie załaduje, test nie dotrze do tej linii (kontekst padnie wcześniej).
assertNotNull(notificationSender);
}
}
W tym teście są dwie duże praktyczne korzyści. Po pierwsze, jeśli XML przestał ładować się, zobaczysz problem od razu na testach (a nie w aplikacji, którą ktoś uruchamiał ostatni raz „w poprzednim sprincie"). Po drugie, test pomaga trzymać fragment XML izolowanym: jeśli nagle zacznie wymagać połowy projektu do podniesienia, złapiesz to po tym, że smoke test zacznie „nagle" zależeć od zbyt wiele.
Jeśli chcesz zrobić test trochę bardziej „mówiącym", możesz utrwalić oczekiwanie co do nazwy beana (jeśli jawnie ją podawałeś w XML). Czasem pomaga szybko zrozumieć: „plik się załadował, ale wewnątrz nie to".
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.junit.jupiter.api.Assertions.assertTrue;
// Ten sam kontekst XML, ale weryfikacja idzie po nazwie beana (kontrakt fragmentu XML).
@SpringJUnitConfig(locations = "classpath:legacy/legacy-notification-context.xml")
class LegacyXmlBeanNameSmokeTest {
// Potrzebny dostęp do kontenera, żeby sprawdzić obecność beana po nazwie.
@Autowired
ApplicationContext context;
@Test
void expectedBeanNameExists() {
// Nazwa utrwala minimalny kontrakt XML: bean z tym id powinien istnieć.
assertTrue(context.containsBean("legacyNotificationSender"));
}
}
Tak, nazwa legacyNotificationSender — to przykład. W twoim projekcie będzie taka, jaką wskazałeś w XML. Chodzi o coś innego: smoke test utrwala minimalny kontrakt fragmentu XML.
Jeden smoke test zamiast dziesiątek
Gdy nowicjusz po raz pierwszy rozumie, że testy kontekstowe „łapią wiring", pojawia się pokusa napisania stu testów: AuditServiceNotNullTest, NotificationSenderNotNullTest, ReportFormatterNotNullTest… i w każdym — po jednym assertNotNull(...). Z odczuć wydaje się to „pewniejsze". W rzeczywistości — dostajesz szum, duplikowanie i niekiedy nieoczekiwane spowolnienie testów.
Dlaczego tak się dzieje? Bo wartość smoke test nie leży w liczbie weryfikacji, lecz w tym, że podnosi kontekst i daje wczesną diagnostykę. Jeden test, który podnosi kontekst i sprawdza 3–5 krytycznych beanów, najczęściej już łapie prawie wszystko, co chcesz złapać na tym poziomie. Dziesięć testów „bean nie null" rzadko znajduje więcej błędów, za to dodaje utrzymywania: każdy refaktoryzacja nazw/pakietów zaczyna łamać połowę testów, i zaczynasz „naprawiać testy dla testów".
Jest jeszcze jeden praktyczny moment, o którym warto wiedzieć nawet przy pierwszym zapoznaniu. Spring Test potrafi keszować ApplicationContext między testami, jeśli konfiguracja jest taka sama. To oznacza: raz podniesiony kontekst — kilka klas testowych może go ponownie wykorzystać, i testy działają szybciej. Ale jeśli zaczniesz mnożyć wiele wariantów konfiguracji (różne klasy, różne locations, różne ustawienia), kesz stanie się mniej efektywny, i dostaniesz „dlaczego testy nagle stały się wolne?" bez poczucia, gdzie dokładnie to sobie urządziłeś.
Dobry styl dla smoke test zazwyczaj wygląda tak: jeden główny test na „kontekst aplikacji", i, jeśli trzeba, jeden-dwa dodatkowe na izolowane kawałki jak legacy XML. Wszystko. Pozostałe testy kontekstowe powinny już odpowiadać na pytania poziomu scenariuszy i zachowania, a nie na pytanie „czy bean jest not-null".
6. Analiza padnięcia smoke test
Padnięcie smoke test wygląda przerażająco: dziesiątki linii, Failed to load ApplicationContext, odczucie, że Spring kłóci się ze wszystkim naraz i w ogóle uważa cię za złego człowieka. W rzeczywistości to bardzo ustrukturyzowana historia, tylko trzeba się do niej przyzwyczaić.
Pierwsze, co ważne zrozumieć: górne wyjątek to prawie zawsze „opakowanie". Mówi: „kontekst nie wstał". Ale rzadko wyjaśnia, dlaczego. Przyczyna zazwyczaj leży głębiej — w łańcuchu Caused by:. Twoja główna umiejętność to: dotrzeć do pierwotnej przyczyny (root cause) i przeczytać linię, gdzie Spring po raz pierwszy powiedział coś konkretnego.
Przykład typowego fragmentu (silnie skróconego, żeby się nie utopić):
Failed to load ApplicationContext
Caused by: UnsatisfiedDependencyException: Error creating bean 'orderPlacementService'
Caused by: NoSuchBeanDefinitionException: No qualifying bean of type 'AuditWriter' available
Czytać to trzeba od dołu do góry. Na samym dole powiedziano konkretnie: „nie znaleziono AuditWriter". To jest faktyczny problem. Linijkę wyżej uściślono, gdzie to wyszło: kontener nie mógł stworzyć orderPlacementService, bo ma niespełnioną zależność. Czyli dostajesz od razu odpowiedź na dwa pytania: który bean nie mógł być stworzony i jaka zależność mu brakuje.
Tak samo czyta się niejednoznaczność:
Caused by: NoUniqueBeanDefinitionException: expected single matching bean but found 2
Sens: „chciałem jednego, a ty przyniosłeś dwa". I dalej (zazwyczaj) Spring wskaże typ i nazwy kandydatów. W tym momencie przypominasz sobie materiały o @Primary, @Qualifier, collection injection — i zaczynasz poprawiać konfigurację, a nie „magicznie" tłumić błąd.
I jeszcze jeden ważny moment: w smoke test padnięcie — to nie „zły test". To użyteczny sygnał: coś zepsułeś w wiringu, i lepiej dowiedzieć się teraz, niż gdy aplikacja uruchomi się w trybie demo u wykładowcy (lub u team lidera). Tak, stack trace jest duży. Ale jest uczciwy: pokazuje ścieżkę, którą kontener próbował złożyć graf.
Gdzie leży test i jak uruchamiać
Gdy mówimy „dodamy smoke test do projektu", ważne jest zrobić to starannie, żeby testy nie zamieniły się w osobny wszechświat z własnymi regułami. W projekcie Gradle ze zwykłą strukturą Maven/Gradle testy leżą w src/test/java, i lepiej trzymać je w tym samym base package co aplikacja, żeby nie gubiły się importy i nie tworzyć „drugiego równoległego projektu".
Na przykład dla ContextFlow może to wyglądać tak:
src/test/java/com/example/contextflow
├─ ContextFlowSmokeTest.java
└─ LegacyNotificationXmlSmokeTest.java
Dalej wszystko jest prozaiczne: uruchamiasz testy komendą ./gradlew test, i jeśli wiring jest zepsuty, dowiadujesz się o tym wcześniej, niż zdążysz otworzyć IDE i „potykać palcem". To właśnie jest główna idea smoke test: szybki automatyczny sygnał o tym, że składanie aplikacji przez kontener żyje.
7. Typowe błędy przy context smoke test
Błąd nr 1: zamienianie smoke test w test scenariuszowy.
Bardzo łatwo zacząć „trochę" sprawdzać zachowanie: „skoro już mam OrderPlacementService, to od razu stwórzmy zamówienie". I już uruchamiasz połowę aplikacji wewnątrz smoke test, dostajesz efekty uboczne, pliki w build/, zdarzenia, listenery, i test staje się kruchy. Smoke test powinien pozostać weryfikacją startu i wiringu: kontekst wstał, kluczowe beany są dostępne — koniec.
Błąd nr 2: pisanie dziesiątek testów „bean nie null" zamiast jednego sensownego smoke test.
Gdy w każdym teście jest jeden assertNotNull(...), nie zyskujesz większej pewności, ale zyskujesz więcej utrzymywania. Każdy refaktoryzacja zmusza do naprawiania testów paczką, a wartość znaczeniowa pozostaje prawie zerowa, bo wszystkie sprawdzają tę samą rzecz: kontekst się ładuje.
Błąd nr 3: wybieranie złych celów dla smoke test (np. obiektów domenowych lub „ulubionych" implementacji interfejsów).
Jeśli w aplikacji normalne jest kilka implementacji NotificationSender, nie każ smoke test padać z powodu niejednoznaczności tam, gdzie jest ona dopuszczalna przez design. Smoke test powinien sprawdzać „nośne" części: serwisy orkiestracji, kluczową infrastrukturę, ważne moduły konfiguracyjne. Encje domenowe jak Order w ogóle nie muszą być beanami, i sprawdzanie ich przez kontekst Spring — to błąd metodyczny.
Błąd nr 4: ignorowanie stack trace i patrzenie tylko na „Failed to load ApplicationContext".
Górna linijka prawie zawsze mówi „kontekst nie wstał" — to nie przyczyna, to nagłówek. Przyczyna chowa się niżej, w Caused by:. Jeśli nauczysz się znajdować pierwotną przyczynę (zazwyczaj najbardziej dolna sensowna linijka), szybkość debugowania konfiguracji Spring wzrośnie zauważalnie. I tak, to jeden z rzadkich przypadków, gdy „przewinąć w dół" — to profesjonalna umiejętność.
Błąd nr 5: próba „naprawienia" padnięcia smoke test obejściem zamiast poprawienia wiringu.
Czasem chcesz „ominąć" problem: zrobić zależność @Lazy, ukryć błąd przez ObjectProvider, wyłączyć część konfiguracji — byle test przeszedł. Ale smoke test z sensu powinien być fail-fast. Jeśli kontekst nie startuje, to najczęściej oznacza rzeczywisty problem ze składaniem aplikacji. Napraw konfigurację lub zależności, a nie usuwaj sygnalizację pożarowej alarmy, bo za głośno piszczy.
GO TO FULL VERSION