Testowanie kohort Kotlin na Androidzie

Kod do testowania jednostkowego, który używa współrzędnych, wymaga dodatkowej uwagi, ponieważ jego wykonanie może być asynchroniczne i być wykonywane w wielu wątkach. W tym przewodniku omawiamy sposób testowania funkcji zawieszania, konstrukcje testowe, które musisz znać, i sposób testowania kodu korzystającego z współrzędnych.

Interfejsy API używane w tym przewodniku wchodzą w skład biblioteki kotlinx.coroutines.test. Pamiętaj, aby dodać artefakt jako zależność testową do projektu, aby uzyskać dostęp do tych interfejsów API.

dependencies {
    testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
}

Wywoływanie funkcji zawieszania w testach

Aby wywoływać funkcje zawieszania w testach, musisz działać w współudziale. Ponieważ funkcje testowe JUnit nie zawieszają funkcji, musisz wywołać w testach konstruktor współprogramów, aby uruchomić nową współpracę.

runTest to narzędzie do tworzenia współpracowników przeznaczone do testowania. Służy do pakowania wszystkich testów, w których są współrzędne. Pamiętaj, że współrzędne można uruchamiać nie tylko bezpośrednio w treści testowej, ale też przez obiekty używane w teście.

suspend fun fetchData(): String {
    delay(1000L)
    return "Hello world"
}

@Test
fun dataShouldBeHelloWorld() = runTest {
    val data = fetchData()
    assertEquals("Hello world", data)
}

Ogólnie zalecamy jedno wywołanie funkcji runTest na test i zalecamy używanie treści wyrażenia.

Opakowanie kodu testu w środowisku runTest sprawdzi się podczas testowania podstawowych funkcji zawieszania, a wszystkie opóźnienia w współrzędnych zostaną automatycznie pominięte, dzięki czemu powyższy test zakończy się znacznie szybciej niż jedna sekunda.

Musisz jednak pamiętać o dodatkowych kwestiach, które zależą od tego, co dzieje się w testowanym kodzie:

  • Gdy Twój kod tworzy nowe współrzędne inne niż współrzędna testowa najwyższego poziomu utworzona przez runTest, musisz kontrolować sposób ich planowania, wybierając odpowiednie TestDispatcher.
  • Jeśli Twój kod przeniesie to samo wykonanie do innych dyspozytorów (na przykład za pomocą withContext), runTest nadal będzie działać, ale opóźnienia nie będą już pomijane, a testy będą mniej przewidywalne, gdy kod będzie uruchamiany w wielu wątkach. Dlatego podczas testów należy wstrzyknąć dyspozytorów testowych, aby zastąpić prawdziwych dyspozytorów.

Dyspozytorzy testów

TestDispatchers to implementacje CoroutineDispatcher do celów testowych. Jeśli w trakcie testu zostaną utworzone nowe współrzędne, musisz użyć interfejsu TestDispatchers, aby zapewnić przewidywalność ich wykonania.

Dostępne są 2 implementacje funkcji TestDispatcher: StandardTestDispatcher i UnconfinedTestDispatcher, które wykonują różne harmonogramy nowo uruchomionych współprogramów. Wykorzystują one TestCoroutineScheduler do kontrolowania czasu wirtualnego i zarządzania uruchomionymi współrzędnymi w ramach testu.

W teście powinna być używana tylko jedna instancja algorytmu szeregowania, używana przez wszystkie zasoby TestDispatchers. Więcej informacji o udostępnianiu algorytmów szeregowania znajdziesz w artykule Wstrzykiwanie algorytmów TestDispatchers.

Aby uruchomić korytę testową najwyższego poziomu, runTest tworzy obiekt TestScope, który jest implementacją CoroutineScope, która zawsze używa TestDispatcher. Jeśli nie podasz żadnej wartości, TestScope domyślnie utworzy StandardTestDispatcher i użyje go do uruchomienia współrzędu testowego najwyższego poziomu.

runTest śledzi współrzędne, które znajdują się w kolejce w algorytmie szeregowania używanym przez dyspozytora instancji TestScope, i nie wraca, dopóki są oczekujące prace nad tym algorytmem szeregowania.

StandardDispatcher Testów

Gdy uruchamiasz nowe współrzędne w StandardTestDispatcher, są one umieszczane w kolejce w alternatywnym algorytmie szeregowania, dzięki czemu są uruchamiane zawsze, gdy wątek testowy jest dowolny. Aby uruchomić te nowe współrzędne, musisz przekazać wątek testowy (zwolnij go na potrzeby innych współrzędnych). Ten sposób dodawania do kolejki daje precyzyjną kontrolę nad tym, jak nowe współrzędne są uruchamiane podczas testu. Przypomina ono planowanie współrzędnych w kodzie produkcyjnym.

Jeśli wątek testowy nigdy nie jest zwracany podczas wykonywania współrzędu testowego najwyższego poziomu, wszelkie nowe współprogramy będą uruchamiane dopiero po zakończeniu współrzędu testowego (ale przed powrotem funkcji runTest):

@Test
fun standardTest() = runTest {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ❌ Fails
}

Istnieje kilka sposobów uzyskania współrzędu testowego umożliwiającego działanie współrzędnych oczekujących w kolejce. Wszystkie te wywołania pozwalają innym współrzędnym na uruchomienie w wątku testowym, zanim zwrócą odpowiedź:

  • advanceUntilIdle: uruchamia wszystkie inne współprogramy w algorytmie szeregowania, aż w kolejce nie ma nic. Jest to dobry wybór domyślny, który pozwala uruchamiać wszystkie oczekujące współudziały. Działa w większości scenariuszy testowych.
  • advanceTimeBy: przyspiesza czas wirtualny o podaną ilość i uruchamia wszystkie współprogramy, które mają zostać uruchomione przed tym momentem w czasie wirtualnym.
  • runCurrent: uruchamia współrzędne zaplanowane w bieżącym czasie wirtualnym.

Aby naprawić poprzedni test, można za pomocą funkcji advanceUntilIdle pozwolić, by 2 oczekujące współprogramy wykonały pracę, zanim przejdziesz do potwierdzenia:

@Test
fun standardTest() = runTest {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }
    advanceUntilIdle() // Yields to perform the registrations

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ✅ Passes
}

UnconfinedTestDispatcher

Gdy w UnconfinedTestDispatcher są uruchamiane nowe współprogramy, są one szybko uruchamiane w bieżącym wątku. Oznacza to, że zostaną one uruchomione natychmiast, bez czekania na powrót kreatora reguł. W wielu przypadkach taki sposób wysyłania powoduje prostszy kod testowy, ponieważ nie trzeba ręcznie generować wątku testowego, aby uruchomić nowe współrzędne.

Różni się to jednak od tego, co zauważysz w wersji produkcyjnej w przypadku dyspozytorów innych firm. Jeśli test skupia się na równoczesności, lepiej jest używać obiektu StandardTestDispatcher.

Aby użyć tego dyspozytora na potrzeby kogutyny testowej najwyższego poziomu w runTest zamiast domyślnej, utwórz instancję i przekaż ją jako parametr. Spowoduje to, że nowe współrzędne utworzone w zasadzie runTest zostaną uruchomione z zaangażowaniem, ponieważ dziedziczą one dyspozytora z elementu TestScope.

@Test
fun unconfinedTest() = runTest(UnconfinedTestDispatcher()) {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ✅ Passes
}

W tym przykładzie wywołania startowe inicjują swoje nowe współprogramy w dniu UnconfinedTestDispatcher, co oznacza, że każde wywołanie jest powtórzone dopiero po zakończeniu rejestracji.

Pamiętaj, że UnconfinedTestDispatcher z zapałem uruchamia nowe współprogramy, ale to nie znaczy, że będzie je także z chęcią dokończyć. Jeśli nowy współprogram zostanie zawieszony, pozostałe współrzędne zostaną wznowione.

Na przykład nowa współrzędna uruchomiona w ramach tego testu spowoduje zarejestrowanie Alicji, ale zostanie ona zawieszona po wywołaniu funkcji delay. Dzięki temu współrzędna najwyższego poziomu może kontynuować asercję, a test kończy się niepowodzeniem, ponieważ Robert nie jest jeszcze zarejestrowany:

@Test
fun yieldingTest() = runTest(UnconfinedTestDispatcher()) {
    val userRepo = UserRepository()

    launch {
        userRepo.register("Alice")
        delay(10L)
        userRepo.register("Bob")
    }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ❌ Fails
}

Dyspozytorzy testu wstrzykiwania

Testowany kod może korzystać z dyspozytorów do przełączania wątków (za pomocą: withContext) lub uruchamiania nowych współprac. Gdy kod jest wykonywany równolegle w wielu wątkach, testy mogą nie działać prawidłowo. Wykonywanie asercji w odpowiednim czasie lub czekanie na zakończenie zadań, jeśli są one uruchamiane w wątkach w tle, nad którymi nie masz kontroli, może być trudne.

W testach zastąp tych dyspozytorów instancjami TestDispatchers. Ma to kilka korzyści:

  • Kod będzie działać w pojedynczym wątku testowym, dzięki czemu testy będą bardziej deterministyczne
  • Możesz kontrolować sposób planowania i wykonywania nowych współudziałów
  • Dyspozytorzy testów korzystają z algorytmu szeregowania w przypadku czasu wirtualnego, który automatycznie pomija opóźnienia i umożliwia ręczne przesunięcie czasu.

Stosuję wstrzykiwanie zależności, aby dostarczyć Dyspozytorzy na Twoich zajęciach ułatwiają zastępowanie prawdziwych dyspozytorów testów. W tych przykładach wstrzykujemy element CoroutineDispatcher, ale możesz też wstrzyknij szerszą grupę CoroutineContext. co daje większą elastyczność podczas testów.

W przypadku klas, które uruchamiają współprogramy, możesz też wstrzyknąć CoroutineScope zamiast dyspozytora, jak opisano w sekcji Wstrzyknięcie zakresu. .

Domyślnie TestDispatchers tworzy nowy algorytm szeregowania podczas tworzenia instancji. W usłudze runTest możesz uzyskać dostęp do właściwości testScheduler elementu TestScope i przekazać ją do każdego nowo utworzonego elementu TestDispatchers. Dzięki temu będą mogli podzielić się wiedzą na temat czasu wirtualnego, a metody takie jak advanceUntilIdle będą do końca uruchamiać współdziałanie wszystkich dyspozytorów testów.

W poniższym przykładzie widać klasę Repository, która tworzy nową współrzędną przy użyciu dyspozytora IO w metodzie initialize i przełącza element wywołujący na dyspozytora IO w metodzie fetchData:

// Example class demonstrating dispatcher use cases
class Repository(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
    private val scope = CoroutineScope(ioDispatcher)
    val initialized = AtomicBoolean(false)

    // A function that starts a new coroutine on the IO dispatcher
    fun initialize() {
        scope.launch {
            initialized.set(true)
        }
    }

    // A suspending function that switches to the IO dispatcher
    suspend fun fetchData(): String = withContext(ioDispatcher) {
        require(initialized.get()) { "Repository should be initialized first" }
        delay(500L)
        "Hello world"
    }
}

W testach możesz wstrzyknąć implementację TestDispatcher, aby zastąpić dyspozytora IO.

W poniższym przykładzie wstrzykujemy obiekt StandardTestDispatcher do repozytorium i używamy metody advanceUntilIdle, aby przed kontynuowaniem upewnić się, że nowa współrzędna uruchomiona w tym miejscu: initialize.

Działanie fetchData będzie korzystne również na podstawie reguły TestDispatcher, ponieważ będzie działać w wątku testowym z pominięciem opóźnienia w trakcie testu.

class RepositoryTest {
    @Test
    fun repoInitWorksAndDataIsHelloWorld() = runTest {
        val dispatcher = StandardTestDispatcher(testScheduler)
        val repository = Repository(dispatcher)

        repository.initialize()
        advanceUntilIdle() // Runs the new coroutine
        assertEquals(true, repository.initialized.get())

        val data = repository.fetchData() // No thread switch, delay is skipped
        assertEquals("Hello world", data)
    }
}

Nowe współprogramy uruchomione w elemencie TestDispatcher można ulepszać ręcznie, jak pokazano powyżej w zasadzie initialize. Pamiętaj jednak, że nie jest to możliwe lub pożądane w kodzie produkcyjnym. Należy przeprojektować tę metodę, tak aby była zawieszana (w przypadku wykonywania sekwencyjnego) lub zwracała wartość Deferred (w przypadku wykonywania równoczesnego).

Możesz na przykład użyć narzędzia async, aby rozpocząć nową współudział i utworzyć Deferred:

class BetterRepository(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
    private val scope = CoroutineScope(ioDispatcher)

    fun initialize() = scope.async {
        // ...
    }
}

Pozwoli Ci to bezpiecznie await dokończyć uzupełnienie kodu zarówno w testach, jak i w kodzie produkcyjnym:

@Test
fun repoInitWorks() = runTest {
    val dispatcher = StandardTestDispatcher(testScheduler)
    val repository = BetterRepository(dispatcher)

    repository.initialize().await() // Suspends until the new coroutine is done
    assertEquals(true, repository.initialized.get())
    // ...
}

runTest poczeka na zakończenie oczekujących współudziałów, zanim zwróci procedurę, jeśli są one na urządzeniu TestDispatcher, z którym współużytkuje algorytm szeregowania. Będzie on również czekać na współudziały podrzędne koutyny testowej najwyższego poziomu, nawet jeśli są one przypisane do innych dyspozytorów (maksymalnie po czasie oczekiwania określonym przez parametr dispatchTimeoutMs, który domyślnie wynosi 60 sekund).

Ustawianie głównego dyspozytora

W testach lokalnych jednostek dyspozytor Main, który opakowuje wątek interfejsu Androida, będzie niedostępny, ponieważ testy są wykonywane na lokalnej maszynie wirtualnej, a nie na urządzeniu z Androidem. Jeśli testowany kod odwołuje się do wątku głównego, podczas testów jednostkowych zostanie zgłoszony wyjątek.

W niektórych przypadkach możesz wstrzyknąć dyspozytora Main w taki sam sposób jak u innych dyspozytorów, co opisano w poprzedniej sekcji, co pozwoli Ci w testach zastąpić go kodem TestDispatcher. Jednak niektóre interfejsy API, takie jak viewModelScope, korzystają z zakodowanego na stałe dyspozytora Main.

Oto przykład implementacji ViewModel, w której użyto viewModelScope do uruchomienia współrzędu ładującego dane:

class HomeViewModel : ViewModel() {
    private val _message = MutableStateFlow("")
    val message: StateFlow<String> get() = _message

    fun loadMessage() {
        viewModelScope.launch {
            _message.value = "Greetings!"
        }
    }
}

Aby we wszystkich przypadkach zastąpić dyspozytora Main elementem TestDispatcher, użyj funkcji Dispatchers.setMain i Dispatchers.resetMain.

class HomeViewModelTest {
    @Test
    fun settingMainDispatcher() = runTest {
        val testDispatcher = UnconfinedTestDispatcher(testScheduler)
        Dispatchers.setMain(testDispatcher)

        try {
            val viewModel = HomeViewModel()
            viewModel.loadMessage() // Uses testDispatcher, runs its coroutine eagerly
            assertEquals("Greetings!", viewModel.message.value)
        } finally {
            Dispatchers.resetMain()
        }
    }
}

Jeśli dyspozytor Main został zastąpiony przez TestDispatcher, nowo utworzona TestDispatchers będzie automatycznie używać algorytmu szeregowania z dyspozytora Main, w tym StandardTestDispatcher utworzonego przez runTest, jeśli nie zostanie do niego przekazany żaden inny dyspozytor.

Dzięki temu łatwiej jest zadbać o to, aby podczas testu był używany tylko jeden algorytm szeregowania. Aby to działało, pamiętaj, aby utworzyć wszystkie pozostałe instancje TestDispatcher po wywołaniu funkcji Dispatchers.setMain.

Typowym wzorcem pozwalającym uniknąć duplikowania kodu zastępującego dyspozytora Main w każdym teście jest wyodrębnienie go do reguły testowej JUnit:

// Reusable JUnit4 TestRule to override the Main dispatcher
class MainDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

class HomeViewModelTestUsingRule {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun settingMainDispatcher() = runTest { // Uses Main’s scheduler
        val viewModel = HomeViewModel()
        viewModel.loadMessage()
        assertEquals("Greetings!", viewModel.message.value)
    }
}

Ta implementacja reguły domyślnie korzysta z UnconfinedTestDispatcher, ale StandardTestDispatcher może zostać przekazany jako parametr, jeśli dyspozytor Main nie powinien wykonać bezprawnie w danej klasie testowej.

Jeśli potrzebujesz wystąpienia TestDispatcher w treści testowej, możesz ponownie użyć atrybutu testDispatcher z reguły, o ile jest to odpowiedni typ. Jeśli chcesz wyraźnie określić typ obiektu TestDispatcher używanego w teście lub jeśli potrzebujesz TestDispatcher innego typu niż używany w przypadku Main, możesz utworzyć nowy TestDispatcher w usłudze runTest. Ponieważ dyspozytor Main jest ustawiony na TestDispatcher, każdy nowo utworzony TestDispatchers będzie automatycznie udostępniać swój algorytm szeregowania.

class DispatcherTypesTest {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun injectingTestDispatchers() = runTest { // Uses Main’s scheduler
        // Use the UnconfinedTestDispatcher from the Main dispatcher
        val unconfinedRepo = Repository(mainDispatcherRule.testDispatcher)

        // Create a new StandardTestDispatcher (uses Main’s scheduler)
        val standardRepo = Repository(StandardTestDispatcher())
    }
}

Tworzenie dyspozytorów poza testem

W niektórych przypadkach poza metodą testową może być potrzebny TestDispatcher. Na przykład podczas inicjowania właściwości w klasie testowej:

class Repository(private val ioDispatcher: CoroutineDispatcher) { /* ... */ }

class RepositoryTestWithRule {
    private val repository = Repository(/* What TestDispatcher? */)

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun someRepositoryTest() = runTest {
        // Test the repository...
        // ...
    }
}

Jeśli zastępujesz dyspozytora Main w sposób pokazany w poprzedniej sekcji, TestDispatchers utworzony po zastąpieniu dyspozytora Main automatycznie udostępni swój algorytm szeregowania.

Nie dotyczy to jednak obiektu TestDispatchers utworzonego jako właściwości klasy testowej lub obiektu TestDispatchers utworzonego podczas inicjowania właściwości w klasie testowej. Są one inicjowane przed zastąpieniem dyspozytora Main. W związku z tym tworzy nowe algorytmy szeregowania.

Aby upewnić się, że w teście występuje tylko 1 algorytm szeregowania, najpierw utwórz właściwość MainDispatcherRule. Następnie użyj ponownie jego dyspozytora (lub algorytmu szeregowania, jeśli potrzebujesz obiektu TestDispatcher innego typu) w inicjatorach innych właściwości na poziomie klasy.

class RepositoryTestWithRule {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    private val repository = Repository(mainDispatcherRule.testDispatcher)

    @Test
    fun someRepositoryTest() = runTest { // Takes scheduler from Main
        // Any TestDispatcher created here also takes the scheduler from Main
        val newTestDispatcher = StandardTestDispatcher()

        // Test the repository...
    }
}

Pamiętaj, że zarówno runTest, jak i TestDispatchers utworzone w teście nadal będą automatycznie korzystać z funkcji algorytmu szeregowania dyspozytora Main.

Jeśli nie zastępujesz dyspozytora Main, utwórz pierwszą właściwość TestDispatcher (co spowoduje utworzenie nowego algorytmu szeregowania) jako właściwość klasy. Następnie ręcznie przekazuj ten algorytm szeregowania do każdego wywołania runTest i każdego nowo utworzonego elementu TestDispatcher zarówno jako właściwości, jak i w teście:

class RepositoryTest {
    // Creates the single test scheduler
    private val testDispatcher = UnconfinedTestDispatcher()
    private val repository = Repository(testDispatcher)

    @Test
    fun someRepositoryTest() = runTest(testDispatcher.scheduler) {
        // Take the scheduler from the TestScope
        val newTestDispatcher = UnconfinedTestDispatcher(this.testScheduler)
        // Or take the scheduler from the first dispatcher, they’re the same
        val anotherTestDispatcher = UnconfinedTestDispatcher(testDispatcher.scheduler)

        // Test the repository...
    }
}

W tym przykładzie algorytm szeregowania z pierwszego dyspozytora jest przekazywany do runTest. Spowoduje to utworzenie nowego StandardTestDispatcher dla obiektu TestScope korzystającego z tego algorytmu szeregowania. Możesz też przekazać dyspozytora bezpośrednio do runTest, aby uruchomić u tego dyspozytora cykl testowy.

Tworzenie własnego zakresu TestScope

Tak jak w przypadku usługi TestDispatchers, może być konieczne uzyskanie dostępu do TestScope poza treścią testową. runTest automatycznie tworzy TestScope dla zaawansowanych, ale możesz też utworzyć własny element TestScope do użycia z runTest.

Podczas wykonywania tej czynności pamiętaj, by zadzwonić do użytkownika runTest w utworzonym przez Ciebie TestScope:

class SimpleExampleTest {
    val testScope = TestScope() // Creates a StandardTestDispatcher

    @Test
    fun someTest() = testScope.runTest {
        // ...
    }
}

Powyższy kod tworzy domyślnie StandardTestDispatcher dla obiektu TestScope, a także nowy algorytm szeregowania. Wszystkie te obiekty można też utworzyć bezpośrednio. Może to być przydatne, jeśli musisz zintegrować je z konfiguracjami wstrzykiwania zależności.

class ExampleTest {
    val testScheduler = TestCoroutineScheduler()
    val testDispatcher = StandardTestDispatcher(testScheduler)
    val testScope = TestScope(testDispatcher)

    @Test
    fun someTest() = testScope.runTest {
        // ...
    }
}

Wstrzykiwanie zakresu

Jeśli masz klasę, która tworzy współprogramy, którymi musisz sterować podczas testów, możesz wstrzyknąć do tej klasy zakres współrzędny, zastępując go TestScope w testach.

W poniższym przykładzie klasa UserState zależy od obiektu UserRepository do rejestrowania nowych użytkowników i pobierania listy zarejestrowanych użytkowników. Ponieważ te połączenia do UserRepository to zawieszanie wywołań funkcji, UserState używa wstrzykniętego parametru CoroutineScope, aby uruchomić nową współudział w funkcji registerUser.

class UserState(
    private val userRepository: UserRepository,
    private val scope: CoroutineScope,
) {
    private val _users = MutableStateFlow(emptyList<String>())
    val users: StateFlow<List<String>> = _users.asStateFlow()

    fun registerUser(name: String) {
        scope.launch {
            userRepository.register(name)
            _users.update { userRepository.getAllUsers() }
        }
    }
}

Aby przetestować te zajęcia, możesz zdać TestScope z runTest podczas tworzenia obiekt UserState:

class UserStateTest {
    @Test
    fun addUserTest() = runTest { // this: TestScope
        val repository = FakeUserRepository()
        val userState = UserState(repository, scope = this)

        userState.registerUser("Mona")
        advanceUntilIdle() // Let the coroutine complete and changes propagate

        assertEquals(listOf("Mona"), userState.users.value)
    }
}

Aby wstawić zakres poza funkcją testową, na przykład do obiektu w utworzonym jako właściwość w klasie testowej, zobacz Tworzenie własnego obiektu TestScope

Dodatkowe materiały