Inicjalizacja i czyszczenie bazy w testach integracyjnych (Spring Boot, Testcontainers)

Inicjalizacja i czyszczenie bazy w testach integracyjnych (Spring Boot, Testcontainers)
OE
  • Rejestracja: dni
  • Ostatnio: dni
  • Postów: 2
0

Cześć, mam pytanie odnośnie inicjalizacji danych testowych (testy integracyjne) i czyszczenie bazy danych pomiędzy testami. Migracje tabel robię przez flyway, baza na dockerze, mam jeden testcontainer dla wszystkich testów. Początkowo migracje zawierały dane inicjalizacyjne dla testów, testy się wykrzacały ze względu na jeden kontener. Obecnie robię to przez @sql adnotację. Czy takie podejście jest poprawne (zdaję sobie sprawę z ograniczeń - zmiany w tabelach i będę musiał pamiętać o zmianie sql). Czy nie lepiej rozbić to na osobne kontenery (odpalanie ich trwa...)? @Transactional nad testem/klasą z tego co rozumiem powoduje rollback przed zapisem. Prośba o radę jakie podejście jest najbardziej optymalne.

Kopiuj
@Sql(scripts = {"/db/clean_user.sql", "/db/user_data.sql"},
        executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@SpringBootTest
@ActiveProfiles("test")
@AllArgsConstructor(onConstructor_ = @Autowired)
class AccountUserServiceIntegrationTest extends TestContainerConfig {

    private AccountUserService accountUserService;
    private UserService userService;
    private AddressService addressService;
    private LibrarianService librarianService;
    private PasswordEncoder passwordEncoder;

    @Test
    void shouldCreateNewUser_WhenUserDoesNotExist() {
        // phone number is empty because it is optional
        // membershipDate is empty because it is set during newUser creation

        // given
        long initialUserCount = userService.countUser();
        User newUser = DomainDataFactory.userForNewAccount();

        assertThat(userService.findUserByEmail(newUser.getEmail())).isEmpty();

        // when
        User savedUser = accountUserService.createAccountUser(newUser);

        // then
        long updatedUserCount = userService.countUser();
        assertThat(updatedUserCount).isEqualTo(initialUserCount + 1);

        assertThat(savedUser.getUserId()).isNotNull();
        assertThat(savedUser.getAddress().getAddressId()).isNotNull();
        assertThat(savedUser.getMembershipDate()).isNotNull();

        assertThat(savedUser).usingRecursiveComparison()
                .ignoringFields("userId", "password", "membershipDate", "address.addressId", "address.users")
                .isEqualTo(newUser);
        assertThat(passwordEncoder.matches(newUser.getPassword(), savedUser.getPassword())).isTrue();
    }
}
Charles_Ray
  • Rejestracja: dni
  • Ostatnio: dni
  • Postów: 1912
0

Z tego, co pamiętam, tak długo jak wszystkie operacje bazodanowe przechodzą przez sesję JPA, @Transactional zrobi robotę - tzn. test powinien przejść, a zmiany z punktu widzenia bazy danych powinny zostać wycofane po zakończeniu testu.

piotrpo
  • Rejestracja: dni
  • Ostatnio: dni
  • Postów: 3303
4

Mi nie podoba się podejście z Transactional i to z paru powodów.
Transakcje nie są robione po to, żeby je wycofywać. Mają zapewnić atrybuty ACID na poziomie bazy danych, nie zostały zaprojektowane do robienia szkiców danych. W sumie kiepski argument, ale jednak.

Testując operację na danych chcesz (a w każdym razie ja bym chciał), sprawdzić czy operacja CRUD się powiodła. Efektem tego sukcesu jest wywołanie bez błędu transaction.commit(), bo dopiero wtedy następuje utrwalenie danych. Możliwa jest sytuacja w której otwierasz transakcje, dokonujesz jakichś tam operacji z sukcesem, a robiąc commit transakcja jako całość zostaje odrzucona. Opakowując test w transakcję, powodujesz, że istotna część przestaje być sprawdzana (ale chociaż test się wywali, więc jeszcze nie tragedia).

Dodając transaction na początku testu zmieniasz zachowanie kodu, który wywołujesz. Pod spodem możesz mieć przecież otwierane nowe transakcje, mogą być zależne, albo niezależne od operacji, które wykonujesz w teście, mogą mieć różne poziomy izolacji.

Nie wiem, czy test, który przytoczyłeś to jedynie przykład, czy coś rzeczywistego, ale używasz w nim takiej konstrukcji:

Kopiuj
assertThat(userService.findUserByEmail(newUser.getEmail())).isEmpty();
        //given
        long initialUserCount = userService.countUser();
        // when
        User savedUser = accountUserService.createAccountUser(newUser);

        // then
        long updatedUserCount = userService.countUser();
        assertThat(updatedUserCount).isEqualTo(initialUserCount + 1);

Zakładasz, że pomiędzy pierwszym, a drugim count(*) nic się w bazie nie wydarzyło, a przecież mogło. Wystarczy, że ktoś, kiedyś przestawi testy na równoległe i nagle, czasami inny test skasuje jakiegoś użytkownika.

Podejście alternatywne:

  • Tworzysz pojedynczy test container i inicjalizujesz to jakimiś tam danymi testowymi,
  • Wstawiasz użytkownika "Kowalski-UUID"
  • Sprawdzasz, czy użytkownika da się odczytać i czy dane są zgodne z tymi, które tam wstawiłeś

Ogólnie, w każdym teście starasz się operować na unikalnych dla tego testu danych, nie dotykasz innych testów, nie kombinujesz z magią, nadal masz szybkie testy, bo nie marnujesz czasu na tworzenie bazy.

KE
  • Rejestracja: dni
  • Ostatnio: dni
  • Postów: 761
0

Czy nie lepiej rozbić to na osobne kontenery (odpalanie ich trwa...)

docker run --rm -e POSTGRES_PASSWORD=blablabla -ti postgres trwa u mnie gdzieś koło 1 sekundy. Może tutaj popatrz? Ile masz testów, że to wpływa na ogół?

lion137
  • Rejestracja: dni
  • Ostatnio: dni
  • Postów: 5025
0

Czy ten rak wielu asercji na test jest z ejaj?

B1
  • Rejestracja: dni
  • Ostatnio: dni
  • Postów: 499
0

@Sql używam w swoich testach, więc to chyba dobre rozwiązanie (choć nie pamiętam dlaczego)

Koziołek
  • Rejestracja: dni
  • Ostatnio: dni
  • Lokalizacja: Stacktrace
  • Postów: 6823
1

W Springu masz mechanizm TestTransactionContext. Jeżeli metoda oznaczona jako @Test to:

  1. Jeżeli metody wywołane w teście są oznaczone jako @Transactional, czyli:
Kopiuj
@Test
void myTest(){
  //…
  user.saveMe();
  //…
}
////
class User{
  //…
  @Trasactional
  User saveMe(){
    //…
  }
  //…
}

To dane zostaną utrwalone w bazie, a transakcja nie zostanie wycofana po zakończeniu testu. I to jest twój przypadek.

  1. Jeżeli metody wywołane w teście są oznaczone jako @Transactional i metoda testowa jest oznaczona @Tranasactional, czyli:
Kopiuj
@Test
@Trasactional
void myTest(){
  //…
  user.saveMe();
  //…
}
////
class User{
  //…
  @Trasactional
  User saveMe(){
    //…
  }
  //…
}

To test jest uruchamiany w kontekście testowym transakcji i na koniec transakcja jest wycofywana. Czyli masz czystą bazę.

I jeszcze to o czym pisze @lion137, weź te asercje zamknij w jednej pomocniczej klasie/metodzie, bo inaczej to dobrze nie wygląda.

MA
  • Rejestracja: dni
  • Ostatnio: dni
  • Postów: 6
0

U mnie sprawdza się podejście z jedną klasą bazową gdzie tworze bazke w kontenerze. W klasie bazowej dorzucasz też kod do czyszczenia tabel.
Jeśli chodzi o ustawienie stanu to najlepiej zrobić sobie jakies klasy pomocnicze do ustawienia odpowiednio stanu testu + Test Buildery.
@Transactional w ogóle bym nie używał ze względu na to, że może wprowadzać niepotrzebne błędy.
Przy podejściu z jedna klasa bazową ważne jest, żeby nie dać się skusić i nie tworzyc całego kodu pomocnicznego w tej klasie. Najlepiej sprawdzają sie różnego rodzaju interfejsy/traity, które w sobie mają zaimplementowane mocki/operacje IO.

OE
  • Rejestracja: dni
  • Ostatnio: dni
  • Postów: 2
0
lion137 napisał(a):

Czy ten rak wielu asercji na test jest z ejaj?

Nie to moja wesoła twórczość.

Koziołek napisał(a):

I jeszcze to o czym pisze @lion137, weź te asercje zamknij w jednej pomocniczej klasie/metodzie, bo inaczej to dobrze nie wygląda.

Dzięki. Wezmę to pod uwagę.

marpi napisał(a):

U mnie sprawdza się podejście z jedną klasą bazową gdzie tworze bazke w kontenerze. W klasie bazowej dorzucasz też kod do czyszczenia tabel.
Jeśli chodzi o ustawienie stanu to najlepiej zrobić sobie jakies klasy pomocnicze do ustawienia odpowiednio stanu testu + Test Buildery.
@Transactional w ogóle bym nie używał ze względu na to, że może wprowadzać niepotrzebne błędy.
Przy podejściu z jedna klasa bazową ważne jest, żeby nie dać się skusić i nie tworzyc całego kodu pomocnicznego w tej klasie. Najlepiej sprawdzają sie różnego rodzaju interfejsy/traity, które w sobie mają zaimplementowane mocki/operacje IO.

Kombinowałem z klasą pomocniczą czyszczącą, ale ostatecznie zmieniłem to na adnotacje sql. Wezmę pod uwagę to co pisałes o klasach pomocniczych.

Zarejestruj się i dołącz do największej społeczności programistów w Polsce.

Otrzymaj wsparcie, dziel się wiedzą i rozwijaj swoje umiejętności z najlepszymi.