Wraz z rozwojem aplikacji AnimalShelter natrafiam na ciekawe przypadki. Jeden z nich dotyczył klasy BeanPropertyRowMapper
służącej do mapowania wiersza bazodanowego do instancji klasy. Błąd wyszedł na jaw dopiero podczas testów integracyjnych. Wiersz, który chciałem wyciągnąć z bazy nie mapował pól do odpowiedniego obiektu. Otrzymywałem po prostu domyślne wartości w polach takie jak np. null czy 0. Przejdźmy zatem do sedna problemu.
Analizowany przypadek
Zaczynając od krótkiego wprowadzenia, jednym z zadań aplikacji jest przyjmowanie zwierząt do schroniska. Jednak nic nie jest workiem bez dna, więc i nasze schronisko ma swoje limity miejsc. Z tego powodu powstała klasa pakietowa ShelterAnimalRow
, która reprezentuje już przyjęte zwierzątko do schroniska. Dzięki niej jesteśmy w stanie pobrać aktualną listę zwierząt i sprawdzić czy mamy jeszcze miejsce, aby przyjąć kolejne pupile. Już teraz warto zwrócić uwagę, że wszystkie elementy w tej klasie ustawiłem na dostęp pakietowy: samą klasę, pola, konstruktor oraz setter (SPOILER!).
class ShelterAnimalRow { UUID animal_id; ShelterAnimal toShelterAnimal() { return new ShelterAnimal(new AnimalId(animal_id)); } void setAnimal_id(final UUID animal_id) { this.animal_id = animal_id; } }
Przy okazji warto zaznaczyć, że korzystam z biblioteki vavr stąd zapis Stream.ofAll(...)
. Następnie przy pomocy JdbcTemplate wykonywane jest zapytanie SELECT pobierające tylko te rekordy z bazy, których wartość kolumny adopted_at
jest null
. Musimy zmapować pobrane dane na klasę ShelterAnimalRow
wykorzystując właśnie BeanPropertyRowMapper
. Robimy to tworząc jego nowy obiekt używając konstruktora przyjmującego parametr typu java.lang.Class
. Kolejnym krokiem jest zmapowanie wszystkich elementów strumienia na klasę ShelterAnimal
i wrzucenie całości do zbioru.
@Override public Set<ShelterAnimal> queryForAnimalsInShelter() { return Stream.ofAll( jdbcTemplate.query( "SELECT a.animal_id FROM shelter_animal a WHERE a.adopted_at IS NULL", new BeanPropertyRowMapper<>(ShelterAnimalRow.class) )) .map(ShelterAnimalRow::toShelterAnimal) .toSet(); }
Pora na test
Teraz pora przetestować nasze rozwiązanie. Wykorzystamy do tego celu test integracyjny, który będzie używał Testcontainers stawiając cały kontekst Springa. Dzięki temu odwzorujemy produkcyjne działanie aplikacji.
@Test @Transactional @DisplayName("Should fetch animals which are in shelter") void should_fetch_animals_which_are_in_shelter(@Autowired ShelterDatabaseRepository repository) { Animal animal = animal(); repository.save(animal); Set<ShelterAnimal> shelterAnimals = repository.queryForAnimalsInShelter(); assertThat(shelterAnimals).containsOnly(new ShelterAnimal(animal.getId())); }
Po uruchomieniu testu od razu dostaniemy wyjątek java.lang.IllegalArgumentException: id cannot be null
. Jest to zabezpieczenie przed stworzeniem nieprawidłowego obiektu klasy AnimalId
. Oznacza to, że zmiennej UUID animal_id
klasy ShelterAnimalRow
została przypisana wartość null
. Tylko dlaczego skoro stworzyliśmy obiekt Animal
, który ma podany losowy UUID
i zapisaliśmy go w bazie? Odpowiedź została znaleziona w dokumentacji, a dokładniej w Javadoc klasy BeanPropertyRowMapper
.
Column values are mapped based on matching the column name as obtained from result set meta-data to public setters for the corresponding properties. The names are matched either directly or by transforming a name separating the parts with underscores to the same name using „camel” case.
Wynika z tego, że klasa może być pakietowa, konstruktor i pola też, ale setter musi być PUBLICZNY! W innym przypadku dane pole po prostu nie zostanie wypełnione danymi. Robiąc szybką poprawkę test od razu przechodzi na zielono. Z dokumentacji możemy dodatkowo dowiedzieć się, że jeżeli kolumny mają podkreślenie w nazwie jak np. animal_id
to BeanPropertyRowMapper
potrafi je zmapować na pola w konwencji „camelCase”.
Podsumowanie
Najważniejsza lekcja z tego artykułu to, że zawsze trzeba uważnie czytać dokumentację. Błędy wynikające z jej nieprzestrzegania nie powinny mieć odzwierciedlenia w testach integracyjnych. Nie ma żadnej wartości biznesowej w tym, że nie mamy wiedzy jak wykorzystywać dane narzędzie. Musimy się po prostu z tym obeznać, a testy tego typu powinny chronić główne części systemu.
Przy okazji zachęcam Cię do zapoznania się z całym procesem powstawania aplikacji AnimalShelter, który opisuję w tej serii artykułów.
2 KOMENTARZE
Możliwość komentowania została wyłączona.
Z tego powodu powstała klasa pakietowa ShelterAnimalRow, która reprezentuje już przyjęte zwierzątko do schroniska. Dzięki niej jesteśmy w stanie pobrać aktualną listę zwierząt i sprawdzić czy mamy jeszcze miejsce, aby przyjąć kolejne pupile.
A co jak to zostanie wywołane równocześnie przez dwa żądania?
Cześć Rrrtrrtrtrtr! Dzięki, że napisałeś komentarz pod artykułem.
Nie za bardzo wiem w jakim kontekście umieścić Twoje pytanie? 🙂 Podejrzewam, że zostanie utworzone wiele obiektów `ShelterAnimalRow`. Może też się zdarzyć, że jedno żądanie będzie musiało poczekać aż skończy drugie. Jest wiele możliwych odpowiedzi i szczerze nie za bardzo wiem jak mogę Ci pomóc. Mógłbyś rozszerzyć swoje pytanie? 🙂