Задавались ли вы вопросом, почему Robolectric не работает JUnit 5? В этой статье я расскажу, как можно подружить Robolectric и JUnit 5 и как мы смогли это сделать в Альфа-Банке. 

Вы узнаете, как запустить JUnit 4 тесты при совместном использовании с JUnit Jupiter с помощью тестового движка Vintage. А ещё — как с JUnit Platform разработчики Kotest и Spock Framework создавали свои тестовые фреймворки.

Чтобы разобраться со всем этими вопросами, нужно понять мотивы разработчиков, которые создавали JUnit 5. Для этого мы сравним архитектуру JUnit 4 и JUnit 5 и ограничения и возможности, которые предоставляют нам эти библиотеки.

Авторы статьи

Никита Горбунов

Android TechLead, развивает CI/CD/INFRA

Абакар Магомедов

Android TechLead, любитель пописать тесты

Ограничения JUnit 4

Давайте внимательно посмотрим на архитектуру JUnit 4.

Как можно видеть, архитектура очень простая и состоит лишь из одного jar-файла, то есть является монолитной. Сторонним производителям крайне сложно реализовывать свой функционал поверх JUnit 4. Им часто приходится полагаться на внутреннюю реализацию JUnit 4, что сильно усложняет жизнь всем разработчикам, в том числе и разработчикам JUnit 4.

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

Ещё одним недостатком было то, что тесты в JUnit 4 могли иметь только один Runner на один класс. Именно по этой причине невозможно (или практически невозможно) использовать тестовые фреймворки поверх JUnit 4 совместно с Robolectric, например, параметризованные тесты или Spock Framework версии 1.0. И Robolectric, и параметризованные тесты, и Spock Framework имеют свои тестовые ранеры.

TR;DL Именно из-за ограниченности платформы JUnit 4 и невозможности использования нескольких ранеров все тесты с применением Robolectric пишутся со стандартным JUnit 4 фреймворком.

Архитектура JUnit 5

Теперь давайте посмотрим на архитектуру JUnit 5 и сравним её с JUnit 4.

Скриншот из доклада https://youtu.be/751gMXH-lEE?si=Hh1XEzv9snzP1FBX
Скриншот из доклада https://youtu.be/751gMXH-lEE?si=Hh1XEzv9snzP1FBX

Как видно, JUnit 5 не является монолитной библиотекой и состоит из 3 компонентов: JUnit Platform, JUnit Jupiter, и JUnit Vintage.

JUnit Platform содержит большое количество точек расширения JUnit 5, в том числе средства запуска тестов, средства создания тестовых движков, средства интеграции с IDE и многое другое.

JUnit Jupiter является стандартной реализацией или фреймворком JUnit Platform от команды JUnit. Содержит тестовый движок для запуска всех тестов, помеченных аннотацией org.junit.jupiter.api.Test. Этот движок не создавался для обратной совместимости с JUnit 4, поэтому в нём невозможно запустить ранеры или рулы из Junit 4. По этой причине Robolectric со своим RobolectricTestRunner не будет работать с JUnit Jupiter.

JUnit Vintage — ещё одна реализация JUnit Platform. Движок VintageTestEngine создавался специально для обратной совместимости с JUnit 4 и используется для всех тестов, помеченных аннотацией org.junit.Test. К сожалению, в VintageTestEngine не работают расширения из JUnit Jupiter. С этим движком можно запустить RobolectricTestRunner, но смысла от этого немного.

Модульная система JUnit 5 получилась такой гибкой благодаря разделению JUnit на платформенную часть и реализации (Jupiter и Vintage). Благодаря этому любой сторонний разработчик может создать свой тестовый фреймворк на базе JUnit Platform. Так, например, работает Kotest и Spock Framework версии 2.0 и выше.

Запуск двух раннеров в одном тесте

Как ранее мы обсудили ранее в разделе «Ограничения JUnit 4», в одном тесте одновременно может быть подключен только один ранер. По этой причине невозможно запустить RobolectricTestRunner вместе с другим ранером (например, с возможностью запуска параметризованных тестов). Единственным способом запустить два ранера является объединение кода двух ранеров в один.

На просторах GitHub можно найти несколько подобных примеров, но я остановлюсь на библиотеке ElectricSpock, которую мы в Альфе ранее использовали для тестов на Groovy. Про то, как мы писали тесты с этим фреймворком, читайте в другой статье на Хабре.

ElectricSpock объединяет два ранера: RobolectricTestRunner из Robolectric и Sputnik из Spock Framework. Эти два ранера объединяются в новом ранере ElectricSputnik.

Spock Framework позволяет очень просто писать тесты в BDD-стиле. Вот так может выглядеть тест с применением ElectricSpock:

import android.widget.TextView
import hkhc.electricspock.ElectricSpecification
import org.robolectric.Robolectric

class SpockTest extends ElectricSpecification {

    def test() {
        given:
            def controller = Robolectric.buildActivity(MainActivity)
            def activity = controller.get()
            TextView mainTextView = null

        when: 'moves Activity to RESUMED state'
            controller.setup()
            mainTextView = activity.findViewById(R.id.main_text_view)

        then:
            mainTextView != null
            mainTextView.text == 'Hello World!'
    }
}

Класс ElectricSpecification — это класс спецификации из библиотеки ElectricSpock. Посмотрев на его реализацию, можно увидеть, что этот класс наследует класс Specification, необходимый для написания тестов на Spock, и запускается с помощью ранера ElectricSputnik.

package hkhc.electricspock

import org.junit.runner.RunWith
import spock.lang.Specification;

@RunWith(ElectricSputnik)
class ElectricSpecification extends Specification {
}

Детали ранера ElectricSputnik я разбирать не буду, поскольку это тема отдельной статьи. Вкратце в ElectricSputnik происходит следующее:

  1. Создаётся класс с единственным методом, помеченным аннотацией org.junit.Test, и передаётся в конструктор класса ElectricSputnik. Этот моковый класс подменяет реальный тестовый класс для нормальной инициализации RobolectricTestRunner.

  2. Класс исходного теста загружается с помощью загрузчика классов Robolectric (SdkSandboxClassLoader). Это необходимо, чтобы Robolectric смог загрузить свои моки для Android-классов в тесте.

  3. Создаётся ранер Sputnik, который также загружается с помощью загрузчика классов Robolectic. Этот ранер выступает в качестве делегата для ранера ElectricSputnik.

  4. Создаётся AbstractMethodInterceptor — перехватчик событий жизненного цикла Spock-тестов, также с помощью класслоадера Robolectric. Далее перехватчик регистрируется в ранере Sputnik для исполнения во время запуска тестов. В AbstractMethodInterceptor переопределяется метод interceptSpecExecution. Этот метод выполняется каждый раз при запуске тестового сценария. В нём подменяется контекстный загрузчик классов и выполняется необходимая настройка Roblectric до и после запуска тестов.

TR;DL Таким образом можно запустить ранер Sputnik в контексте загрузчика классов Robolectric и запустить методы жизненного цикла Robolectric. Это в свою очередь позволяет использовать моки Android-классов от Robolectric в тестах.

Из всего вышесказанного можно сделать вывод: чтобы запустить Robolectric совместно с JUnit 5, нужно найти адекватную замену для ранеров из JUnit 4.

Альтернативы для @RunWith в JUnit 5

В JUnit 5 существует несколько альтернатив для ранеров из JUnit 4: расширения (Extension) и тестовые движки (TestEngine).

Расширения JUnit 5

Расширения JUnit 5 являются заменой сразу для нескольких концепций из JUnit 4: Runner, TestRule, MethodRule.

Модель расширений JUnit 5 — это обширная тема для отдельной статьи, поэтому подробно на них я не буду останавливаться. Выделим лишь важные для нас особенности.

Самое главное в понимании расширений JUnit 5 то, что они не являются частью JUnit Platform. Расширения JUnit 5 располагается в пакете org.junit.jupiter.api.extension и принадлежат JUnit Jupiter. Из этого следует, что расширения — это реализация видения авторов JUnit о том, как должно происходить расширение возможностей тестового движка. 

Другие авторы, например, авторы Kotest или Spock, совершенно не обязаны использовать (или в принципе использовать) ту же самую концепцию модели расширения, что используется в JUnit Jupiter. Это приводит к тому, что даже если вы изучите все расширения в JUnit Jupiter, вам всё равно придётся заново изучать аналогичные расширения в других тестовых фреймворках:

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

Такая ситуация совершенно не способствует переходу Robolectric на JUnit 5, поскольку в Junit Platform нет API, на которое мог бы положиться Robolectric. Вместо этого Robolectric придётся тесно интегрироваться с расширениями, которые есть в каждой конкретной реализации, если, конечно, фреймворк в принципе реализует концепцию расширений.

Из этого следует, что без тесного сотрудничества разработчиков популярных фреймворков тестирования — команды JUnit, и Robolectric — нормальная интеграция Robolectric во все фреймворки в принципе невозможна.

Тестовые движки JUnit 5

Как я уже писал ранее, тестовые фреймворки предоставляются либо для конкретной цели (например, Vintage), либо для реализации фреймворка тестирования (Jupiter, Kotest, Spock). Но, как и в случае с ранерами из JUnit 4, один тест может быть запущен только с применением одного тестового движка. 

Это означает несуразность написания собственного тестового движка для Robolectric, поскольку были бы получены все те же самые ограничения, что имеются у ранеров в JUnit 4, то есть невозможность смешивания Robolectric с любыми тестовыми фреймворками (движками).

Интеграция Robolectric и JUnit 5

Хоть у нас и не получилось найти инструмент, который бы позволил нам интегрировать Robolectric и JUnit 5, всё же существует несколько обходных путей.

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

Так, например, получилось сделать у разработчиков Kotest, которые смогли с собственными расширениями загрузить тестовый класс с помощью загрузчика классов Robolectric и запустить его жизненный цикл.

Во-вторых, можно написать свой врапер над существующим тестовым движком и зарегистрировать его вместе или вместо оригинального. Этот способ чем-то похож на объединение функционала нескольких ранеров из JUnit 4 в один. В таком случае мы также дозволены достаточно сильно влиять на то, как будут запускаться тесты.

При использовании данного способа всё так же необходимо перезагрузить все тестовые классы с загрузчиком классов Robolectric, менять контекстный загрузчик лассов и вызывать методы жизненного цикла Robolectric.

Пример такого врапера можно найти в моём видении современного ElectricSpock (работает с Gradle 8.0. groovy-android плагин прилагается). Если вам нравится Gradle так же, как и мне, очень советую заглянуть в репозиторий, там вы, возможно, найдёте несколько интересных для себя идей.

Заключение

К сожалению, Robolectric скорее всего никогда не получит официальной интеграции с JUnit 5, поскольку для этого нужно значительно менять не только сам Robolectric, но и возможности интеграции со стороны JUnit Platform и разработчиков конкретных тестовых фреймворков.

Сейчас на просторах интернета можно найти неофициальные интеграции Robolectric с различными тестовыми фреймворками, но использовать вы их можете только на свой страх и риск. Поддерживать такие интеграции довольно сложно из-за необходимости постоянно обновлять версии Robolectric.


Если остались вопросы — пишите, мы с радостью ответим на них в комментариях, ну, и делитесь вашим опытом работы и впечатлениями от платформы JUnit.

Источники и полезные ссылки

Комментарии (0)