Как мы играли в тесты на Groovy и проиграли
В начале у меня будет один вопрос к тебе дорогой читатель. Кодил ли ты когда-нибудь unit тесты на Groovy? Если твоя профессия андроид-разработчик, то вероятность этого крайне мала. И я с таким не сталкивался до проекта Альфы.
Давай представим, что ты приходишь на проект и видишь такой тест:
class InvestmentsInstrumentsRepositorySpec extends Specification {
@ClassRule
@Shared
RxJavaRule rxJavaRule
def service = Mock(InvestmentsInstrumentsService)
def repository = new InvestmentsInstrumentsRepository(service)
def 'should call service and return proper response'() {
given:
def expectedResponse = InvestmentsInstrumentsTestDataKt.getInvestmentsInstrumentsResponse()
when:
def observer = repository.getInstruments().test()
then:
1 * service.getInvestmentsInstruments() >> Single.just(expectedResponse)
observer.assertValue(expectedResponse)
}
}
Не буду таить, моя реакция была примерно такой:
Но давай пока оставим помидоры в стороне и дадим шанс такому тесту.
Если присмотреться, в целом, ничего страшного в нём нет. Это тест на репозиторий, который мокает зависимости и проверяет, что при вызове определённого метода, один раз дергается нужный метод мока. Причем тест выглядит довольно красиво, очень помогают метки given/when/then (чуть позже мы посмотрим за счёт чего это работает).
Вот как выглядит такой же тест на JUnit 5, для сравнения:
@ExtendWith(RxJavaExtension::class)
class InvestmentsInstrumentsRepositoryTest {
private val service = mockk()
private val repository = InvestmentsInstrumentsRepository(service)
@Test
fun `should call service and return proper response`() {
// given
val expectedResponse = getInvestmentsInstrumentsResponse()
every { service.getInvestmentsInstruments() } returns Single.just(expectedResponse)
// when
val observer = repository.getInstruments().test()
// then
verify(exactly = 1) { service.getInvestmentsInstruments() }
observer.assertResult(expectedResponse)
}
}
Пока можем заметить, что работа с метками given/when/then выглядит не так приятно, но давай пойдём дальше.
Для написания тестов на Groovy у нас используется библиотека Spock. Spock как тестовый движок использует JUnit, причём начиная с версии 2.x это уже JUnit 5. Однако в нашем случае мы пользовались той версией, которая использует JUnit 4.
А теперь вопрос — слышал ли ты про Data Driven Testing? Если нет, то сейчас я покажу пример:
@Unroll
def 'should call paymentsMediator startActivity'() {
given:
featureToggle.isDisabled(Feature.REDESIGN) >> REDESIGN_TOGGLE
featureToggle.isDisabled(Feature.WIDGET_PAYMENT_HUB) >> PAYMENT_TOGGLE
when:
router.openPayScreen(expectedAccountNumber)
then:
1 * paymentsMediator.startActivity(*_) >> { activity, actualAccountNumber ->
assert actualAccountNumber == expectedAccountNumber
}
where:
REDESIGN_TOGGLE | PAYMENT_TOGGLE
false | true
true | false
false | false
}
И ещё один пример:
@Unroll
def 'should map unread count and reports to common items list'() {
when:
def actualMappedData = mapper.map(
AccountStatementsTestDataKt.getReportsListResponse(),
unreadCountResponse
)
then:
actualMappedData == expectedMappedData
where:
unreadCountResponse | expectedMappedData
AccountStatementsTestDataKt.gerUnreadCountResponseWithZero() | AccountStatementsTestDataKt.getBaseItemsListWithZeroCount()
AccountStatementsTestDataKt.gerUnreadCountResponse() | AccountStatementsTestDataKt.getBaseItemsListWithOneCount()
}
Такие тесты называются параметризованными. В блоке where:
описываем параметры и тест будет запускаться с каждым из этих параметров.
Такой подход позволяет сильно экономить на количестве кода, так как нам не надо описывать несколько однотипных тестов, чтобы проверить все условия. Такие тесты можно объединить в один параметризованный тест. И в Споке, как ты видишь, это выглядит довольно красиво. Если интересно можешь глянуть как такое делать в голом JUnit 4 или JUnit 5. Спойлер — это будет выглядеть более громоздко :
object ProvideAccountStatusNotClosed : ArgumentsProvider {
override fun provideArguments(context: ExtensionContext?): Stream {
return TestParamsUtils.getParams(
InvestmentsDocumentsTestData.createBrokerageAccountStatusOpenResponse(),
InvestmentsDocumentsTestData.createBrokerageAccountStatusPendingResponse(),
InvestmentsDocumentsTestData.createBrokerageAccountStatusErrorResponse(),
InvestmentsDocumentsTestData.createBrokerageAccountStatusUnknownResponse(),
)
}
}
@ParameterizedTest
@ArgumentsSource(ProvideAccountStatusNotClosed::class)
fun `should load documents if brokerage account not closed`(expectedStatusResponse: BrokerageAccountStatusesResponse) {
// when
presenter.onViewCreated()
// then
verify(exactly = 1) { mapper.prepareDocModelsList(expectedDocsResponse) }
}
Выглядит не так уж поэтично, согласись.
Давай еще учитывать факт, что показан один из самых простых кейсов. Это я еще не говорю об остальных проблемах, которые появляются когда мы захотим иметь несколько параметризованных тестов в одном тест классе. А также я не говорю о том, что в JUnit 4 с параметризованными тестами дела обстоят ещё хуже.
И ещё один небольшой факт, у нас сейчас на проекте 3 тысячи параметризованных тестов.
Но у тебя может возникнуть вопрос. «Вот автор только и делает, что нахваливает тесты на Groovy, в чём же он проиграл?»
Не торопись, сейчас я все расскажу, до этого я пытался объяснить, почему мы решили писать тесты на Groovy:)
Минус №1
Всё ли нормально в этом тесте?
Выглядит так, что всё нормально, студия ничего красным не подсвечивает. Но давай попробуем его запустить:
Не работает, в чем же дело? А дело в том, что в Groovy используется динамическая компиляция. Это одновременно и огромная сила, которая позволяет делать красивые тесты и одновременно огромное проклятье.
Но есть один workaround, мы можем поставить аннотацию @CompileStatic.
Мы забыли проставить импорт и теперь это подсвечивается. Но эту аннотацию не получится проставить на всех тестах, так как она убивает большинство синтаксических фич, которые мы видели в тестах выше. Ее можно использовать как своего рода маркер, когда ты не можешь понять в чём проблема.
Проблема выше стреляет часто, даже у тех, кто уже привык писать тесты на Groovy. А теперь представь, первый день на проекте и тебя попросили написать тесты. Готов поспорить, что все возможные шишки будут собраны прежде, чем тест запустится. Да и я ещё ни разу на собесе не сталкивался с ситуацией когда кандидат говорил «Да вы что, у вас тесты на Groovy! Как здорово, у меня как раз много опыта в таких тестах!» То есть такие тесты добавляют дополнительный порог вхождения в проект.
Минус №2
Когда мы хотим написать тест, где используется зависимость из Android (так называемый интеграционный тест) мы используем robolectric. Для Spock есть обвязка вокруг robolectric, которая называется electricspock.
Видишь проблемы в скриншоте ниже?
Последние актуальные коммиты 4 года назад и electricspock, в целом, выглядит заброшенным. Можно сказать, что это не супер большая проблема.
Но такое утверждение работает ровно до того момента, когда тебе понадобится обновить версию AGP (Android Gradle plugin). В нашем кейсе мы получили большое количество неработающих тестов, которые использовали electricspock. Проблема была зарыта глубоко и нам пришлось форкать electricspock и добавлять свои фиксы. Во-первых, это создает громадный bus factor, а во-вторых, это решение работает ровно до следующего раза, когда понадобится обновить AGP.
К тому же, чтобы доработать electricspock, надо сначала разобраться как работает сам Spock. Сейчас я покажу небольшой фрагмент кода оттуда ты поймешь насколько это нетривиальная задача. Не будем погружаться супер глубоко:
Начинается все с класса SpockTransform. То есть мы уже имеем дело с компиляторным плагином.
Внутри SpockTransform создается SpecParser, SpecRewriter, SpecAnnotator, которые работает с AST нашего теста. Также нам придется разобраться как работает Sputnik — JUnit runner для Spock тестов:
В целом ничего сложного, нужно всего лишь изучить как работает рантайм Spock и тогда можно спокойно фиксить проблемы electricspock:
Но от проблемы bus factor это не спасает слова совсем. Особенно если учитывать то, как быстро развивается Android, Gradle и AGP вместе с ними.
Итоги
В какой-то момент проблемы стали бить слишком больно. И мы приняли решение начать постепенный переход на другой фреймворк для тестирования, а именно Kotest, который зиждется на JUnit 5. Почему мы выбрали именно его и как начали миграцию, я опишу в отдельной статье. Однако не стоит недооценивать Spock и тесты на Groovy, они действительно получаются менее объемными по количеству кода и более читаемыми.
Подписывайтесь на Телеграм-канал Alfa Digital — там мы постим новости, опросы, видео с митапов, краткие выжимки из статей, иногда шутим.