Микротестирование и Hilt

Многие приложения используют Hilt, чтобы внедрять разные модели поведения в разные варианты сборки. Это может быть особенно полезно при микротестировании приложения, поскольку позволяет заменить компонент, который может исказить результаты. Например, в следующем фрагменте кода показан репозиторий, который извлекает и сортирует список имен:

Kotlin

class PeopleRepository @Inject constructor(
    @Kotlin private val dataSource: NetworkDataSource,
    @Dispatcher(DispatcherEnum.IO) private val dispatcher: CoroutineDispatcher
) {
    private val _peopleLiveData = MutableLiveData<List<Person>>()

    val peopleLiveData: LiveData<List<Person>>
        get() = _peopleLiveData

    suspend fun update() {
        withContext(dispatcher) {
            _peopleLiveData.postValue(
                dataSource.getPeople()
                    .sortedWith(compareBy({ it.lastName }, { it.firstName }))
            )
        }
    }
}}

Java

public class PeopleRepository {

    private final MutableLiveData<List<Person>> peopleLiveData = new MutableLiveData<>();

    private final NetworkDataSource dataSource;

    public LiveData<List<Person>> getPeopleLiveData() {
        return peopleLiveData;
    }

    @Inject
    public PeopleRepository(NetworkDataSource dataSource) {
        this.dataSource = dataSource;
    }

    private final Comparator<Person> comparator = Comparator.comparing(Person::getLastName)
            .thenComparing(Person::getFirstName);

    public void update() {
        Runnable task = new Runnable() {

            @Override
            public void run() {
                peopleLiveData.postValue(
                        dataSource.getPeople()
                                .stream()
                                .sorted(comparator)
                                .collect(Collectors.toList())
                );
            }
        };
        new Thread(task).start();
    }
}

Если вы включаете сетевой вызов при тестировании, реализуйте поддельный сетевой вызов, чтобы получить более точный результат.

Если при тестировании производительности выполняется реальный сетевой вызов, интерпретировать результаты сложнее. На сетевые вызовы может влиять множество внешних факторов, и их продолжительность может варьироваться от итерации к итерации. Вызовы по сети могут занимать больше времени, чем сортировка.

Реализуйте поддельный сетевой вызов с помощью Hilt

Вызов dataSource.getPeople(), как показано в предыдущем примере, содержит сетевой вызов. Однако экземпляр NetworkDataSource внедряется с помощью Hilt, и вы можете заменить его следующей фиктивной реализацией для тестирования:

Kotlin

class FakeNetworkDataSource @Inject constructor(
    private val people: List<Person>
) : NetworkDataSource {
    override fun getPeople(): List<Person> = people
}

Java

public class FakeNetworkDataSource implements NetworkDataSource{

    private List<Person> people;

    @Inject
    public FakeNetworkDataSource(List<Person> people) {
        this.people = people;
    }

    @Override
    public List<Person> getPeople() {
        return people;
    }
}

Этот фиктивный сетевой вызов предназначен для максимально быстрого выполнения при вызове метода getPeople(). Чтобы Hilt мог внедрить это, используется следующий поставщик:

Kotlin

@Module
@InstallIn(SingletonComponent::class)
object FakekNetworkModule {

    @Provides
    @Kotlin
    fun provideNetworkDataSource(@ApplicationContext context: Context): NetworkDataSource {
        val data = context.assets.open("fakedata.json").use { inputStream ->
            val bytes = ByteArray(inputStream.available())
            inputStream.read(bytes)

            val gson = Gson()
            val type: Type = object : TypeToken<List<Person>>() {}.type
            gson.fromJson<List<Person>>(String(bytes), type)
        }
        return FakeNetworkDataSource(data)
    }
}

Java

@Module
@InstallIn(SingletonComponent.class)
public class FakeNetworkModule {

    @Provides
    @Java
    NetworkDataSource provideNetworkDataSource(
            @ApplicationContext Context context
    ) {
        List<Person> data = new ArrayList<>();
        try (InputStream inputStream = context.getAssets().open("fakedata.json")) {
            int size = inputStream.available();
            byte[] bytes = new byte[size];
            if (inputStream.read(bytes) == size) {
                Gson gson = new Gson();
                Type type = new TypeToken<ArrayList<Person>>() {
                }.getType();
                data = gson.fromJson(new String(bytes), type);

            }
        } catch (IOException e) {
            // Do something
        }
        return new FakeNetworkDataSource(data);
    }
}

Данные загружаются из объектов с помощью вызова ввода-вывода потенциально переменной длины. Однако это происходит во время инициализации и не вызывает никаких нарушений при вызове getPeople() во время тестирования.

В некоторых приложениях уже используются поддельные объекты в отладочных сборках, чтобы удалить все зависимости от бэкенда. Однако для тестирования нужно использовать сборку, максимально близкую к конечной сборке. В остальной части этого документа используется структура с несколькими модулями и вариантами, как описано в разделе Полная настройка проекта.

В этом курсе три модуля:

  • benchmarkable – содержит код для тестирования.
  • benchmark – содержит код контрольного показателя.
  • app – содержит остальной код приложения.

У каждого из перечисленных выше модулей есть вариант сборки benchmark, а также обычные варианты debug и release.

Как настроить модуль контрольных показателей

Код для имитации сетевого вызова находится в наборе исходных файлов debug модуля benchmarkable, а полная реализация сети – в наборе исходных файлов release того же модуля. Файл объекта, содержащий данные, возвращаемые поддельной реализацией, находится в наборе исходных файлов debug, чтобы избежать раздувания APK в сборке release. Вариант benchmark должен быть основан на release и использовать набор исходных файлов debug. Конфигурация сборки для варианта benchmark модуля benchmarkable, содержащего поддельную реализацию, выглядит следующим образом:

Kotlin

android {
    ...
    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
        create("benchmark") {
            initWith(getByName("release"))
        }
    }
    ...
    sourceSets {
        getByName("benchmark") {
            java.setSrcDirs(listOf("src/debug/java"))
            assets.setSrcDirs(listOf("src/debug/assets"))
        }
    }
}

Яркий

android {
    ...
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
                'proguard-rules.pro'
            )
        }
        benchmark {
            initWith release
        }
    }
    ...
    sourceSets {
        benchmark {
            java.setSrcDirs ['src/debug/java']
            assets.setSrcDirs(listOf ['src/debug/assets']
        }
    }
}

В модуле benchmark добавьте пользовательский модуль запуска тестов, который создает Application для запуска тестов с поддержкой Hilt, как показано ниже.

Kotlin

class HiltBenchmarkRunner : AndroidBenchmarkRunner() {

    override fun newApplication(
        cl: ClassLoader?,
        className: String?,
        context: Context?
    ): Application {
        return super.newApplication(cl, HiltTestApplication::class.java.name, context)
    }
}

Java

public class JavaHiltBenchmarkRunner extends AndroidBenchmarkRunner {

    @Override
    public Application newApplication(
            ClassLoader cl,
            String className,
            Context context
    ) throws InstantiationException, IllegalAccessException, ClassNotFoundException {
        return super.newApplication(cl, HiltTestApplication.class.getName(), context);
    }
}

В результате объект Application, в котором выполняются тесты, расширяет класс HiltTestApplication. Внесите следующие изменения в конфигурацию сборки:

Kotlin

plugins {
    alias(libs.plugins.android.library)
    alias(libs.plugins.benchmark)
    alias(libs.plugins.jetbrains.kotlin.android)
    alias(libs.plugins.kapt)
    alias(libs.plugins.hilt)
}

android {
    namespace = "com.example.hiltmicrobenchmark.benchmark"
    compileSdk = 34

    defaultConfig {
        minSdk = 24

        testInstrumentationRunner = "com.example.hiltbenchmark.HiltBenchmarkRunner"
    }

    testBuildType = "benchmark"
    buildTypes {
        debug {
            // Since isDebuggable can't be modified by Gradle for library modules,
            // it must be done in a manifest. See src/androidTest/AndroidManifest.xml.
            isMinifyEnabled = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "benchmark-proguard-rules.pro"
            )
        }
        create("benchmark") {
            initWith(getByName("debug"))
        }
    }
}

dependencies {
    androidTestImplementation(libs.bundles.hilt)
    androidTestImplementation(project(":benchmarkable"))
    implementation(libs.androidx.runner)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.junit)
    implementation(libs.androidx.benchmark)
    implementation(libs.google.dagger.hiltTesting)
    kaptAndroidTest(libs.google.dagger.hiltCompiler)
    androidTestAnnotationProcessor(libs.google.dagger.hiltCompiler)
}

Яркий

plugins {
    alias libs.plugins.android.library
    alias libs.plugins.benchmark
    alias libs.plugins.jetbrains.kotlin.android
    alias libs.plugins.kapt
    alias libs.plugins.hilt
}

android {
    namespace = 'com.example.hiltmicrobenchmark.benchmark'
    compileSdk = 34

    defaultConfig {
        minSdk = 24

        testInstrumentationRunner 'com.example.hiltbenchmark.HiltBenchmarkRunner'
    }

    testBuildType "benchmark"
    buildTypes {
        debug {
            // Since isDebuggable can't be modified by Gradle for library modules,
            // it must be done in a manifest. See src/androidTest/AndroidManifest.xml.
            minifyEnabled true
            proguardFiles(
                getDefaultProguardFile('proguard-android-optimize.txt'),
                'benchmark-proguard-rules.pro'
            )
        }
        benchmark {
            initWith debug"
        }
    }
}

dependencies {
    androidTestImplementation libs.bundles.hilt
    androidTestImplementation project(':benchmarkable')
    implementation libs.androidx.runner
    androidTestImplementation libs.androidx.junit
    androidTestImplementation libs.junit
    implementation libs.androidx.benchmark
    implementation libs.google.dagger.hiltTesting
    kaptAndroidTest libs.google.dagger.hiltCompiler
    androidTestAnnotationProcessor libs.google.dagger.hiltCompiler
}

В приведенном выше примере выполняются следующие действия:

  • Применяет к сборке необходимые плагины Gradle.
  • Указывает, что для выполнения тестов используется пользовательский исполнитель тестов.
  • Указывает, что вариант benchmark является типом теста для этого модуля.
  • Добавляет вариант benchmark.
  • Добавляет необходимые зависимости.

Вам нужно изменить testBuildType, чтобы Gradle создал задачу connectedBenchmarkAndroidTest, которая выполняет тестирование.

Создайте микротест

Контрольный показатель рассчитывается следующим образом:

Kotlin

@RunWith(AndroidJUnit4::class)
@HiltAndroidTest
class PeopleRepositoryBenchmark {

    @get:Rule
    val benchmarkRule = BenchmarkRule()

    @get:Rule
    val hiltRule = HiltAndroidRule(this)

    private val latch = CountdownLatch(1)

    @Inject
    lateinit var peopleRepository: PeopleRepository

    @Before
    fun setup() {
        hiltRule.inject()
    }

    @Test
    fun benchmarkSort() {
        benchmarkRule.measureRepeated {
            runBlocking {
                benchmarkRule.getStart().pauseTiming()
                withContext(Dispatchers.Main.immediate) {
                    peopleRepository.peopleLiveData.observeForever(observer)
                }
                benchmarkRule.getStart().resumeTiming()
                peopleRepository.update()
                latch.await()
                assert(peopleRepository.peopleLiveData.value?.isNotEmpty() ?: false)
           }
        }
    }

    private val observer: Observer<List<Person>> = object : Observer<List<Person>> {
        override fun onChanged(people: List<Person>?) {
            peopleRepository.peopleLiveData.removeObserver(this)
            latch.countDown()
        }
    }
}

Java

@RunWith(AndroidJUnit4.class)
@HiltAndroidTest
public class PeopleRepositoryBenchmark {
    @Rule
    public BenchmarkRule benchmarkRule = new BenchmarkRule();

    @Rule
    public HiltAndroidRule hiltRule = new HiltAndroidRule(this);

    private CountdownLatch latch = new CountdownLatch(1);

    @Inject
    JavaPeopleRepository peopleRepository;

    @Before
    public void setup() {
        hiltRule.inject();
    }

    @Test
    public void benchmarkSort() {
        BenchmarkRuleKt.measureRepeated(benchmarkRule, (Function1<BenchmarkRule.Scope, Unit>) scope -> {
            benchmarkRule.getState().pauseTiming();
            new Handler(Looper.getMainLooper()).post(() -> {
                awaitValue(peopleRepository.getPeopleLiveData());
            });
            benchmarkRule.getState().resumeTiming();
            peopleRepository.update();
            try {
                latch.await();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            assert (!peopleRepository.getPeopleLiveData().getValue().isEmpty());
            return Unit.INSTANCE;
        });
    }

    private <T> void awaitValue(LiveData<T> liveData) {
        Observer<T> observer = new Observer<T>() {
            @Override
            public void onChanged(T t) {
                liveData.removeObserver(this);
                latch.countDown();
            }
        };
        liveData.observeForever(observer);
        return;
    }
}

В примере выше создаются правила и для теста производительности, и для Hilt. benchmarkRule выполняет хронометраж теста. hiltRule выполняет внедрение зависимостей в тестовый класс. Чтобы выполнить внедрение перед запуском отдельных тестов, необходимо вызвать метод inject() правила Hilt в функции @Before.

Сам тест производительности приостанавливает отсчет времени, пока зарегистрирован наблюдатель LiveData. Затем он использует защелку, чтобы дождаться обновления LiveData перед завершением. Поскольку сортировка выполняется между вызовом peopleRepository.update() и получением обновления LiveData, ее продолжительность включается в контрольное время.

Запустите микротест

Запустите тест с помощью ./gradlew :benchmark:connectedBenchmarkAndroidTest, чтобы выполнить его несколько раз и вывести данные о времени в Logcat:

PeopleRepositoryBenchmark.log[Metric (timeNs) results: median 613408.3952380952, min 451949.30476190476, max 1412143.5142857144, standardDeviation: 273221.2328680522...

В предыдущем примере показан результат теста от 0,6 мс до 1,4 мс для запуска алгоритма сортировки в списке из 1000 элементов. Однако если вы включите сетевой вызов в тест, то разница между итерациями будет больше, чем время, необходимое для выполнения самой сортировки, отсюда необходимость изолировать сортировку от сетевого вызова.

Вы всегда можете провести рефакторинг кода, чтобы упростить запуск сортировки в изоляции, но если вы уже используете Hilt, то можете использовать его для внедрения подделок для тестирования производительности.