Dywagacje o testach - nauka

0

Hej,

Chcę nauczyć się testów i nie chce iść dalej z nauką póki tego jakkolwiek nie opanuje.
Czytam, czytam, próbuje i chciałbym zasięgnąć porady jak testować, poprosić o sprawdzone publikacje na ten temat bo mam trochę mętlik w głowie i wydaje mi się, że to słabo rozumiem.

Testuję implementacje Repozytorium, implementacja ma dwie zależności, ApiService oraz AuthConfig.
Problem mam z podejściem do mocków, w wielu artykułach są odradzane ale w tym wypadku trochę nie widzę dlaczego miałbym tych dependencies nie mockować, przecież one mają swoje własne testy w innej klasie, dlaczego powinienem(?) podawać konkretne implementacje, szczególnie, że robi się drabina dependencies, których w ogóle Repozytorium nie powinno obchodzić.

Proszę o porade jak to poprawnie rozwiązać i jak podejść do testowania.

Implementacja

class AuthRepositoryImpl(
    private val jwtAuthConfig: AuthConfig,
    private val userApiService: UserApiService
) : AuthRepository {

    override suspend fun registerUserWithToken(input: AuthRequest?): RootResponse<Any> {
        return input?.let { authRequest ->
            if (!authRequest.isValidRequest()) {
                return RootResponse.ErrorResponse(
                    HttpStatusCode.BadRequest,
                    INCORRECT_REQUEST
                )
            }

            return when (checkIfUserExists(authRequest.email!!)) {
                true -> {
                    RootResponse.ErrorResponse(
                        HttpStatusCode.Conflict,
                        USER_EXISTS
                    )
                }
                false -> {
                    val userIdFromEmail = getHashFromString(authRequest.email).toId<User>()
                    val user = User(
                        userIdFromEmail,
                        authRequest.firstName,
                        authRequest.lastName,
                        authRequest.email,
                        authRequest.password
                    )
                    val isAcknowledged = userApiService.insertUser(user)
                    when {
                        isAcknowledged -> {
                            val token = jwtAuthConfig.generateToken(user.id.toString())
                            RootResponse.SuccessResponse(
                                HttpStatusCode.Created,
                                token,
                                USER_CREATED
                            )
                        }
                        else ->
                            RootResponse.ErrorResponse(
                                HttpStatusCode.BadRequest,
                                INCORRECT_REQUEST
                            )
                    }
                }
            }
        } ?: RootResponse.ErrorResponse(
            HttpStatusCode.BadRequest,
            INCORRECT_REQUEST
        )
    }

    override suspend fun loginUserWithToken(input: LoginRequest?): RootResponse<Any> {
        input?.let { loginRequest ->
            if (!loginRequest.isValidRequest()) {
                return RootResponse.ErrorResponse(
                    HttpStatusCode.BadRequest,
                    INCORRECT_REQUEST
                )
            }

            val user = userApiService.findUserByEmail(loginRequest.email!!)
                ?: return RootResponse.ErrorResponse(
                    HttpStatusCode.Unauthorized,
                    WRONG_CREDENTIALS
                )
            val isPasswordMatch = isPasswordHashMatch(loginRequest.password!!, user.password!!)
            return when {
                isPasswordMatch -> {
                    val token = jwtAuthConfig.generateToken(user.id.toString())
                    RootResponse.SuccessResponse(
                        HttpStatusCode.OK,
                        token,
                        USER_LOGGED_IN
                    )
                }
                else -> {
                    RootResponse.ErrorResponse(
                        HttpStatusCode.Unauthorized,
                        WRONG_CREDENTIALS
                    )
                }
            }
        } ?: return RootResponse.ErrorResponse(
            HttpStatusCode.BadRequest,
            INCORRECT_REQUEST
        )
    }

    private suspend fun checkIfUserExists(email: String): Boolean {
        return userApiService.findUserByEmail(email) != null
    }
}

Test

class AuthRepositoryTest {

    private val mockAuthConfig = mockk<AuthConfig>()
    private val mockUserApiService = mockk<UserApiService>()
    private val mockPasswordHash = mockkStatic(::isPasswordHashMatch)

    private lateinit var authRepository: AuthRepository

    @Before
    fun setUp() {
        authRepository = AuthRepositoryImpl(mockAuthConfig, mockUserApiService)
    }


    @Test
    fun `given valid register data returns 201`() {
        val validAuthRequest = fakeValidAuthRequest()
        val validUser = fakeValidUser()

        coEvery { mockUserApiService.findUserByEmail(validAuthRequest.email!!) } returns null
        coEvery { mockUserApiService.insertUser(validUser) } returns true
        coEvery { mockAuthConfig.generateToken(validUser.id.toString()) } returns fakeValidToken()

        runBlocking {
            val response = authRepository.registerUserWithToken(validAuthRequest)
            assertThat(response.statusCode).isEqualTo(HttpStatusCode.Created)
        }
    }

    @Test
    fun `given null register data returns 400`() {
        val nullData = null

        runBlocking {
            val response = authRepository.registerUserWithToken(nullData)
            assertThat(response.statusCode).isEqualTo(HttpStatusCode.BadRequest)
        }
    }

    @Test
    fun `given existing user returns 409`() {
        val validAuthRequest = fakeValidAuthRequest()

        coEvery { mockUserApiService.findUserByEmail(validAuthRequest.email!!) } returns fakeValidUser()

        runBlocking {
            val response = authRepository.registerUserWithToken(validAuthRequest)
            assertThat(response.statusCode).isEqualTo(HttpStatusCode.Conflict)
        }
    }

    @Test
    fun `not acknowledged insert returns 400`() {
        val validAuthRequest = fakeValidAuthRequest()
        val validUser = fakeValidUser()

        coEvery { mockUserApiService.findUserByEmail(validAuthRequest.email!!) } returns null
        coEvery { mockUserApiService.insertUser(validUser) } returns false

        runBlocking {
            val response = authRepository.registerUserWithToken(validAuthRequest)
            assertThat(response.statusCode).isEqualTo(HttpStatusCode.BadRequest)
        }
    }

    @Test
    fun `given valid login data returns OK`() {
        val validLoginRequest = fakeValidLoginRequest()
        val validUser = fakeValidUser()

        coEvery { mockUserApiService.findUserByEmail(validLoginRequest.email!!) } returns fakeValidUser()
        coEvery { isPasswordHashMatch(validLoginRequest.password!!, validLoginRequest.password!!) } returns true
        coEvery { mockAuthConfig.generateToken(validUser.id.toString()) } returns fakeValidToken()

        runBlocking {
            val response = authRepository.loginUserWithToken(validLoginRequest)
            assertThat(response.statusCode).isEqualTo(HttpStatusCode.OK)
        }
    }

    @Test
    fun `given null login data returns 400`() {
        val nullData = null

        runBlocking {
            val response = authRepository.loginUserWithToken(nullData)
            assertThat(response.statusCode).isEqualTo(HttpStatusCode.BadRequest)
        }
    }

    @Test
    fun `given invalid login request returns 400`() {
        val invalidRequest = fakeInvalidLoginRequest()

        runBlocking {
            val response = authRepository.loginUserWithToken(invalidRequest)
            assertThat(response.statusCode).isEqualTo(HttpStatusCode.BadRequest)
        }
    }

    @Test
    fun `given invalid login email returns 401`() {
        val invalidEmailRequest = fakeInvalidEmailLoginRequest()

        coEvery { mockUserApiService.findUserByEmail(invalidEmailRequest.email!!) } returns null

        runBlocking {
            val response = authRepository.loginUserWithToken(invalidEmailRequest)
            assertThat(response.statusCode).isEqualTo(HttpStatusCode.Unauthorized)
        }
    }

    @Test
    fun `given invalid login password returns 401`() {
        val invalidPasswordRequest = fakeInvalidPasswordLoginRequest()

        coEvery {
            isPasswordHashMatch(
                invalidPasswordRequest.password!!,
                invalidPasswordRequest.password!!
            )
        } returns false
        coEvery { mockUserApiService.findUserByEmail(invalidPasswordRequest.email!!) } returns fakeValidUser()

        runBlocking {
            val response = authRepository.loginUserWithToken(invalidPasswordRequest)
            assertThat(response.statusCode).isEqualTo(HttpStatusCode.Unauthorized)
        }
    }
}

7

dlaczego miałbym tych dependencies nie mockować, przecież one mają swoje własne testy w innej klasie, dlaczego powinienem(?) podawać konkretne implementacje,

;)

I generalnie fajnie jednak mieć testy które sprawdzają jak działa produkcyjny kod, a nie mocki. Zapraszam: https://github.com/Pharisaeus/almost-s3

0

@Shalom: :D

No tu ewidentnie mam problem z bazą danych jak zauważyłeś, w projektach Androidowych mocków nigdy nie używałem bo i nie było potrzeby, tutaj mockuje bo ciągle nie umiem jeszcze in memory db.

Dzięki za linka, z readme wygląda jakby miał mieć to czego potrzebuje.

0

Masz takie coś jak piramida testów, czyli jednostkowo testujesz wszystko w odosobnieniu. Ale też robisz trochę testów integracyjnych (mniej szczegółowych, sprawdzających, czy moduły się dogadują).
Jednostkowo testujesz czy login dobry, czy hasło dobre, czy hasło ma 3 znaki i 2 liczby i tego typu szczegółowe testy.
Dalej robisz dwa testy integracyjne, czy jak wpiszesz dobry login i hasło to zadziała i czy jak wpiszesz błędne hasło to zadziała. Nie testujesz wszystkich możliwości to sprawdziłeś je jednostkowo, ale tylko tak ogólnie czy dogadują się te dwie części

0

Kilka dni temu zrobiłem testy klasy z pierwszego posta, nie mało było z tym problemów przez conflicting dependencies.

Finalnie zrobiłem lekki refactor, dodałem JUnit 5, Rule dla in-memory mongo i pare innych drobnostek, sam test będzie jeszcze poprawiany ale musze się zająć inną częścią tego projektu aby złapać troche świeżości w temacie:

@ExperimentalCoroutinesApi
class AuthRepositoryImplTest {

    @get:Rule
    val rule: FlapdoodleRule = FlapdoodleRule()

    @get:Rule
    var coroutineRule: MainCoroutineRule = MainCoroutineRule()

    private val database by lazy {
        rule.database
    }

    private val dbProvider = DatabaseProviderImpl(database)
    private val authConfig = AuthConfig()
    private val userApiService = UserApiServiceImpl(dbProvider)
    private val authRepository = AuthRepositoryImpl(authConfig, userApiService)

    @Nested
    inner class RegisterUserWithToken {
        @Test
        fun `given valid register data returns 201`() {
            val validAuthRequest = fakeValidAuthRequest()

            runBlocking {
                val response = authRepository.registerUserWithToken(validAuthRequest)
                assertThat(response.statusCode).isEqualTo(HttpStatusCode.Created)
            }
        }

        @Test
        fun `given null register data returns 400`() {
            val nullData = null

            runBlocking {
                val response = authRepository.registerUserWithToken(nullData)
                assertThat(response.statusCode).isEqualTo(HttpStatusCode.BadRequest)
            }
        }

        @Test
        fun `given existing user returns 409`() {
            val validAuthRequest = fakeValidAuthRequest()


            runBlocking {
                authRepository.registerUserWithToken(validAuthRequest)
                val response = authRepository.registerUserWithToken(validAuthRequest)
                assertThat(response.statusCode).isEqualTo(HttpStatusCode.Conflict)
            }
        }
    }

    @Nested
    inner class LoginUserWithToken {
        @Test
        fun `given valid login data returns OK`() {
            val validLoginRequest = fakeValidLoginRequest()
            val validUser = fakeValidUser()

            rule.populateDB(
                "user",
                Document()
                    .append("id", validUser.id)
                    .append("email", validUser.email)
                    .append("password", validUser.password)

            )

            runBlocking {
                val response = authRepository.loginUserWithToken(validLoginRequest)
                assertThat(response.statusCode).isEqualTo(HttpStatusCode.OK)
            }
        }

        @Test
        fun `given null login data returns 400`() {
            val nullData = null

            runBlocking {
                val response = authRepository.loginUserWithToken(nullData)
                assertThat(response.statusCode).isEqualTo(HttpStatusCode.BadRequest)
            }
        }

        @Test
        fun `given invalid login request returns 400`() {
            val invalidRequest = fakeInvalidLoginRequest()

            runBlocking {
                val response = authRepository.loginUserWithToken(invalidRequest)
                assertThat(response.statusCode).isEqualTo(HttpStatusCode.BadRequest)
            }
        }

        @Test
        fun `given invalid login email returns 401`() {
            val invalidEmailRequest = fakeInvalidEmailLoginRequest()

            runBlocking {
                val response = authRepository.loginUserWithToken(invalidEmailRequest)
                assertThat(response.statusCode).isEqualTo(HttpStatusCode.Unauthorized)
            }
        }

        @Test
        fun `given invalid login password returns 401`() {
            val invalidPasswordRequest = fakeInvalidPasswordLoginRequest()


            runBlocking {
                val response = authRepository.loginUserWithToken(invalidPasswordRequest)
                assertThat(response.statusCode).isEqualTo(HttpStatusCode.Unauthorized)
            }
        }
    }
}

Dzięki każdemu za pokierowanie.

PS

Błąd, który miałem to:

java.lang.NoSuchMethodError: 'com.mongodb.client.result.InsertOneResult com.mongodb.client.MongoCollection.insertOne(java.lang.Object)'

Błąd pojawiał się zarówno przy użyciu Fongo, TestContainers jak i Flapdoodle, spowodowany dodaniem java-driver dependency. Java-driver był wymagany aby użyć metody getDatabase() dla *.client.mongo.
Rozwiązaniem było dodanie test dependecies od Kmongo, konkretnie helpera dla flapdoodle co pozwoliło mi korzystać z potrzebnych metod. Po tym usunąłem java-driver i poszło w miare gładko choć dałoby się to na pewno zrobić lepiej :)

2

W tych testach nie podoba mi się:

  1. Repository które zwraca ci RESTowe errory. Warstwa repozytorium w ogóle nie powinna wiedzieć że nad nią jest HTTP a nie jakiś inny protokół.
  2. Wołasz bezpośrednio metody na repozytorium zamiast uderzyć RESTowym klientem, mimo ze potem robisz asercje na restowe error code. Przez to pomijasz w ogóle testowanie warstwy RESTowej i jeszcze robisz silny coupling do interfejsu tego twojego repozytorium.
  3. W setupie testów explicite umieszczasz detale implementacyjne kiedy wypełniasz bazę jakimiś danymi. Z punktu widzenia testu nie powinno być istotne czy te dane idą do bazy czy np. aplikacja ciagnie je RESTem z innego mikroserwisu (a w testach z jakiegoś wiremocka). Lepszy byłby jakiś DSL albo chociaż helper który "ukrywa" ten detal.
  4. Masz tylko i wyłącznie asercje na response code, co powiedzmy że ma sens dla jakiegoś 401 czy 400, ale już jakieś register to powinno jednak mieć dodatkowo asercje która upewnia sie że faktycznie rekord dodał sie do bazy. Albo explicite asercja sprawdza w bazie, albo np. robisz login i upewniasz sie ze user nie istnieje, potem register a potem znów login i teraz juz się da zalogować. Bo teraz ten twój test niczego nie sprawdza.
0

Ad1
Tak, na ten moment robie dość proste implementacje, mam task na refactor i repozytorium będzie zwracać internal cody ale to za jakiś czas, musze doczytać i zastanowić się jak to zrobić. Dzięki za informacje, dodatkowo repo nie zwraca po prostu gołych statusów a Success/Error Response gdzie te statusy są tylko częscią zwracanych informacji.

Ad2
Mam osobne testy, gdzie faktycznie mam customowego klienta i sprawdzam faktyczne requesty, ktor ma(jak pewnie inne frameworki) biblioteki do wewnętrznych zapytań.

Lepiej było by mieć to po prostu w jednym teście i nie wołać metod tylko faktycznie testować requesty?

Ad3
Chodzi konkretnie o populateDB()? Z tego co pamiętam w większosci artykułów po prostu w teście pokazywano żeby dodać rekord do bazy, jako czesc given.
Znaczy ja doskonale zdaje sobie sprawę, że masz racje i bardzo szanuje Twoją pomoc, po prostu nie rozumiem dlaczego do końca ale nie ma problemu żeby na poziomie configu zasilać baze danymi. Czy o coś takiego chodzi?

Ad4
To dla prostoty, chce dopisać inne routy żeby trochę odpocząć od tego tematu i za pare dni dopisać resztę w tych testach. Jeśli ten test będzie faktycznie 'wykonywał' zapytania(jak zastanawiam się w punkcie 2) to mogę na jego poziomie przetestować to w pełni bo oczywiści repo zleca dodanie do bazy usera, inna klasa generuje token, hashuje dane etc etc To wszystko ma swoje testy w osobnych klasach.
201 zostanie zwrócone tylko i wyłącznie kiedy User dodał się do bazy, wygenerowany został token i wszystko przebiegło poprawnie ale to co opisujesz brzmi dobrze, spróbuje.

1

Lepiej było by mieć to po prostu w jednym teście i nie wołać metod tylko faktycznie testować requesty?

To zależy, ale w tym przypadku czy nie powielasz niepotrzebnie testów? Bo wątpię że po drodze masz jakąś "logikę".

Czy o coś takiego chodzi?

Nie. Chodzi mi o to, ze test nie powinien wiedzieć że takie dane idą z bazy a inne dane z wiremocka. Linkowałem wyżej repo https://github.com/Pharisaeus/almost-s3 popatrz na sam setup testu (czyli given):

TestUser user = TestUser.builder().build();
TestConfiguration testConfiguration = testHelper.createNewConfiguration()
        .withFile(TestFile.builder()
                .withAccessibleFor(user)
                .withAvailableForDownload()
                .build())
        .setup();

Czy widzisz w tym setupie która informacja pochodzi z bazy a która przychodzi z jakiegoś RESTa? :) Czy mam tam w ogóle jakąś bazę? Na poziomie testu takie szczegóły implementacji w ogóle nie powinny cię interesować. given definiuje stan systemu, czyli u ciebie np. że istnieje w systemie user XYZ. To że system userów bierze z jakiejś bazy danych to jest szczegół implementacyjny, który może się jeszcze 10 razy zmienić, ale same testy powinny nadal działać i być od tego zupełnie niezależne. Jeśli nie są, to znaczy że piszesz testy "implementacji" a nie "zachowania" i niestety będziesz te testy "poprawiać" non stop, bo będą się sypać po każdej małej zmianie w kodzie.

201 zostanie zwrócone tylko i wyłącznie kiedy User dodał się do bazy, wygenerowany został token i wszystko przebiegło poprawnie

Nie. To jest twoje założenie. Poprawny kod powinien tak się zachować. Ale test piszesz właśnie po to żeby się upewnić że to założenie jest prawdziwe! Możesz mieć buga i zwrócic 201 mimo że nic sie nigdzie nie zapisało.

3

@szprotki_w_oleju: piramida testów to kłamstwo. Nie można generalizować w taki sposób, bo każdy projekt jest inny. IMO każdy CRUDowy serwis albo relatywnie mały mikroserwis powinień być testowany tak najbardziej funkcjonalnie jak się da. Przykładowo jak piszesz aplikację w stylu serverless w taki sposób, że 90% logiki to integracja AWSa to testy unitowe są bezużyteczne (ciekawy post o takim stylu programowania https://journal.plain.com/posts/2022-02-08-a-magical-aws-serverless-developer-experience/)

@Kerubyte: pisząc unit test powinieneś mieć w głowie: ten kawałek kodu wydaje się być stabilny, warto napisać do niego testy, bo to może zaprocentować w przyszłości. Gdzie jest problem? To. że w większości przypadków nie wiesz tego w czasie developmentu. Pisząc unit test cementujesz kod i ograniczasz przyszły refaktor. Testy integracyjne/funkcjonalne i tak są konieczne, więc warto opierać się głównie o nie. Unity mają tym więcej sensu gdy:

  • dany kawałek kodu jest używany w wielu miejscach. Taki kod jest mniej podatny na refaktor, więc cementowanie ma sens
  • testowanie funkcjonalne kosztuje dużo zasobów, zwłaszcza czasowych
  • napisanie unitów jest dużo prostsze i pozwala przetestować więcej scieżek. Np. ciężko przetestować E2E logikę cachowania, bo jest ona bardzo transparentna i jest duża szansa, że testy na poziomie aplikacji czegoś nie wychwycą

Oraz ukryte zalety testów funkcjonalnych o których nie każdy ma pojęcie:

  • na takich testach można puścić jakieś bieda testy performancowe albo race detector, wiadomo, że to nie to samo co ruch produkcyjny, ale może być to pomocne w wykrywaniu oczywistych problemów
  • ładnie widać jakie logi produkuje aplikacja. Dzięki temu masz szybki feedback np. warto patrzeć na logi a nie asercje jak coś się wywali. Jak w czasie developmentu nie jesteś w stanie ogarnąć z logów co się popsuło to na produkcji nie będzie lepiej
  • praktycznie znika potrzeba stawiania aplikacji i puszczania testowych requestów, bo takie testy to w sumie to samo tylko w sposób zautomatyzowany
  • testujemy framework i używane liby. Często założenie, że coś powinno działać nie jest prawdziwe
  • można stosować YOLO development tj. w początkowej fazie piszemy kod tak szybko i brzydko jak się da. Dzięki temu mamy dobre tempo i łatwy refaktor w przyszłości bo: wiemy więcej, nie ma unitów przeszkadzających w refaktorze i mamy testy wykrywające, czy wszystko działa jak należy. Przykładowo w jednym projekcie stosowałem takie podejście, że encja na twarz i pchasz. Większość kodu została zrefaktoryzowana ale nie cały, bo część kodu to głupi CRUD
  • możesz dowolnie mieszać bebechy aplikacji. Nie ma nic przyjemniejszego niż przeoranie połowy kodu bez zmiany ani jednej linijki kodu w testach.
0

Dziękuje bardzo, mnóstwo rzeczy się nauczyłem w ciągu tych kilku dni. Przepisuje te testy, tworze helper z pierwszymi dslami etc, bardzo pomocne.

Macie może w pamięci dobre publikacje/artykuły/prezentacje odnośnie samych zasad testowania, jak nie pisać brittle tests, nie cementować logiki, to o czym mi piszecie generalnie. Oglądam sporo konferencji o testach ale one bardziej dotykają tematu niż wyjaśniają dlaczego coś robimy tak a nie inaczej, testy 'funkcjonalne' etc. To co Wy piszecie ma zapewne podłoże w książkach i doświadczeniu i ja również chce potrafić w ten sposób myśleć o testowaniu, w szerszym kontekscie.

Dzięki z góry

1

Ogólnie:

  1. Myśl o testach jak o specyfikacji systemu. Dokumentuj jak system się zachowuje z punktu widzenia jego użycia.
  2. Szczegóły implementacyjne nie powinny wypływać do testów. Tzn. powinieneś moc przeprowadzić refaktoring bez konieczności poprawiania testów (bo nie zmieniło się zachowanie systemu). Oczywiście w teorii, im bliżej tego ideału, tym lepiej.
  3. Poczytaj o regułach FIRST - to totalna podstawa.
  4. Piramida testów
  5. Testuj system przez jego „API”, unikaj haków i mocków. Testuj większe całości, które stanowią jakaś logiczna całość - na pewno nie pojedyncze metody.
  6. Staraj się stosować regule „test first”, potem opcjonalnie możesz pojsc w kierunku TDD. Samo „test first” w moim przypadku było totalnym game changerem.

Polecam książeczki:
https://www.amazon.pl/Growing-Object-Oriented-Software-Guided-Tests/dp/0321503627
http://kaczanowscy.pl/books/bad_tests_good_tests.html

Oraz prezki:

1 użytkowników online, w tym zalogowanych: 0, gości: 1