Site icon AppTractor

Preview-Driven скриншот тестирование для локалей, ключей и масштабов шрифта

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

Они хотели такой же видимости и на Android.

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

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

В этом и заключалась настоящая ценность.

Не драматичная история тестирования. Это не героическая история типа «мы нашли десятки катастрофических ошибок». Честно говоря, удивительно то, что всё прошло довольно гладко, и это настолько редко, что стоит об этом написать.

Поначалу я почти не доверял этому именно по этой причине.

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

Требование на самом деле не заключалось в «делать скриншоты».

Запрос был простым на бумаге:

Если свести это к «скриншот тестированию», это звучит более обыденно, чем и было на самом деле.

Более интересное требование заключалось в следующем:

Как сделать проверку переводов менее зависимой от воображения?

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

Вместо того чтобы спрашивать:

«Где еще мепится этот ключ?»

можно спросить:

«Если мы изменим этот ключ здесь, что еще мы неявно изменим?»

Это гораздо более эффективный способ анализа.

Я использовал предпросмотры Compose как единый источник истины

Больше всего мне понравился подход, при котором первью Compose выступали источником истины.

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

Мне понравилось это ограничение, потому что оно не позволяло маскировать проблемы. Если предпросмотр был недостаточно проработан для создания скриншотов, скорее всего, он был не особенно полезен и в среде разработки.

Сама матрица проверок была простой:

Аннотация предпросмотра выглядела примерно так:

package dev.jamescullimore.app.ui.preview

import androidx.compose.ui.tooling.preview.Preview

// The unsupported locale intentionally falls back to showing string keys.
@Preview(name = "Keys", locale = "eo", fontScale = 1f, group = "Keys")
@Preview(name = "English - 85%", locale = "en", fontScale = 0.85f, group = "English")
@Preview(name = "English - 100%", locale = "en", fontScale = 1.0f, group = "English")
@Preview(name = "English - 150%", locale = "en", fontScale = 1.5f, group = "English")
@Preview(name = "English - 200%", locale = "en", fontScale = 2.0f, group = "English")
@Preview(name = "Arabic - 85%", locale = "ar", fontScale = 0.85f, group = "Arabic")
@Preview(name = "Arabic - 100%", locale = "ar", fontScale = 1.0f, group = "Arabic")
@Preview(name = "Arabic - 150%", locale = "ar", fontScale = 1.5f, group = "Arabic")
@Preview(name = "Arabic - 200%", locale = "ar", fontScale = 2.0f, group = "Arabic")
annotation class CombinedPreviews

Здесь использовалась одна небольшая уловка.

В предпросмотре «Keys» намеренно указывалась неподдерживаемая локаль, чтобы интерфейс вместо переведённого текста отображал значения ключей. Благодаря этому переводчики могли на уровне всего экрана увидеть, какая строка используется в каждом месте, без отдельного режима, созданного исключительно для ручной проверки.

Эта возможность оказалась особенно полезной, поскольку повторяла привычный для переводчиков процесс работы с веб-версией. Им не приходилось переходить на особую модель проверки, характерную только для Android.

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

Режим отображения ключей оказался полезнее, чем можно подумать

Отображение необработанных ключей в интерфейсе может показаться странным — до тех пор, пока вам не приходится поддерживать большой набор переводов.

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

Сам по себе предпросмотр ключей эту проблему не решает, но делает связанность заметной.

Именно это мне и понравилось.

Такой предпросмотр отвечает не только на вопрос «Переведена ли эта строка?». Он также помогает понять:

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

Масштаб шрифта стал ещё одним параметром, который оправдал всю эту работу

Тем, кто уже использует @PreviewFontScale, это, вероятно, покажется очевидным.

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

При стандартном масштабе компоновка может выглядеть совершенно нормально, но при увеличении текста становиться заметно менее убедительной. Это не всегда означает, что интерфейс сломан. Однако такая проверка часто показывает, что UI «работает» только потому, что в наиболее благоприятном сценарии содержимое случайно остается достаточно компактным.

Главная польза заключалась не в абстрактном соблюдении требований доступности. Мы могли одновременно показать переводчикам и дизайнерам практические последствия увеличения текста и использования более длинных формулировок.

Именно здесь скриншоты действительно оправдывали себя.

Короткая английская надпись при масштабе 1.0x — это не полноценная проверка. Более длинная фраза на другом языке при масштабе 1.5x или 2.0x гораздо ближе к реальным условиям.

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

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

Результат должен быть удобен людям, а не только системе сравнения изображений

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

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

Процесс выглядел примерно следующим образом: сначала записывались превью с помощью Paparazzi, а затем на основе полученного отчёта формировалась галерея для проверки.

Сценарий создания галереи находится здесь:

#!/usr/bin/env python3

from __future__ import annotations

import argparse
import datetime as dt
import html
import json
import re
from collections import defaultdict
from pathlib import Path


RUN_PATTERN = re.compile(
    r'^window\.runs\["(?P<run_id>[^"]+)"\]\s*=\s*(?P<payload>\[.*\]);\s*$',
    re.DOTALL,
)
LOCALE_SCALE_PATTERN = re.compile(r"^(?P<locale>.+?) - (?P<scale>\d+%)$")


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Generate a translator-friendly HTML gallery from a Paparazzi report."
    )
    parser.add_argument(
        "--report-dir",
        default="app/build/reports/paparazzi/debug",
        help="Directory containing Paparazzi runs/ and images/.",
    )
    parser.add_argument(
        "--output",
        default=None,
        help="Output HTML path. Defaults to <report-dir>/translator-gallery.html.",
    )
    parser.add_argument(
        "--title",
        default="Translator Preview Gallery",
        help="Page title for the generated gallery.",
    )
    return parser.parse_args()


def parse_timestamp(value: str) -> dt.datetime:
    return dt.datetime.fromisoformat(value.replace("Z", "+00:00"))


def parse_run_file(path: Path) -> list[dict]:
    content = path.read_text(encoding="utf-8").strip()
    match = RUN_PATTERN.match(content)
    if not match:
        return []
    payload = json.loads(match.group("payload"))
    return payload if isinstance(payload, list) else []


def display_name(test_name: str) -> str:
    return test_name.split("#")[-1].replace("_", " ").strip(". ")


def parse_locale_scale(label: str) -> tuple[str, str]:
    match = LOCALE_SCALE_PATTERN.match(label)
    if not match:
        return label, ""
    return match.group("locale"), match.group("scale")


def load_entries(report_dir: Path) -> list[dict]:
    latest_by_key: dict[tuple[str, str], dict] = {}

    for run_file in sorted((report_dir / "runs").glob("*.js")):
        for entry in parse_run_file(run_file):
            test_name = entry.get("testName", "")
            variant_name = entry.get("name", "")
            image_file = entry.get("file", "")
            timestamp = entry.get("timestamp")
            if not test_name or not variant_name or not image_file or not timestamp:
                continue

            parsed = {
                "screen": display_name(test_name),
                "variant_name": variant_name,
                "locale": parse_locale_scale(variant_name)[0],
                "font_scale": parse_locale_scale(variant_name)[1],
                "image_file": image_file,
                "timestamp": timestamp,
                "timestamp_sort": parse_timestamp(timestamp),
            }
            key = (parsed["screen"], parsed["variant_name"])
            previous = latest_by_key.get(key)
            if previous is None or parsed["timestamp_sort"] > previous["timestamp_sort"]:
                latest_by_key[key] = parsed

    return sorted(
        latest_by_key.values(),
        key=lambda item: (
            item["screen"].lower(),
            item["locale"].lower(),
            item["font_scale"],
        ),
    )


def build_html(entries: list[dict], title: str, generated_at: str) -> str:
    grouped: dict[str, list[dict]] = defaultdict(list)
    for entry in entries:
        grouped[entry["screen"]].append(entry)

    cards: list[str] = []
    for screen, screen_entries in grouped.items():
        items: list[str] = []
        for entry in screen_entries:
            image_src = html.escape(entry["image_file"], quote=True)
            items.append(
                f"""
                <article class="shot">
                  <div class="shot__meta">
                    <div class="shot__name">{html.escape(entry["variant_name"])}</div>
                    <div class="shot__time">{html.escape(entry["timestamp"])}</div>
                  </div>
                  <a href="{image_src}" target="_blank" rel="noreferrer">
                    <img src="{image_src}" alt="{html.escape(screen)} {html.escape(entry["variant_name"])}" loading="lazy" />
                  </a>
                </article>
                """
            )

        cards.append(
            f"""
            <section class="screen">
              <header class="screen__header">
                <h2>{html.escape(screen)}</h2>
                <span>{len(screen_entries)} previews</span>
              </header>
              <div class="shots">
                {''.join(items)}
              </div>
            </section>
            """
        )

    return f"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>{html.escape(title)}</title>
  <style>
    body {{
      margin: 0;
      font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      background: #f6f2e8;
      color: #201a16;
    }}
    .page {{
      max-width: 1440px;
      margin: 0 auto;
      padding: 32px 20px 60px;
    }}
    .hero {{
      margin-bottom: 24px;
    }}
    .screen {{
      margin-bottom: 24px;
      padding: 18px;
      border: 1px solid #d9cfbf;
      border-radius: 24px;
      background: rgba(255, 253, 248, 0.86);
    }}
    .screen__header {{
      display: flex;
      justify-content: space-between;
      gap: 12px;
      margin-bottom: 16px;
    }}
    .shots {{
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
      gap: 16px;
    }}
    .shot {{
      display: grid;
      gap: 10px;
      padding: 14px;
      border: 1px solid #e2d8ca;
      border-radius: 18px;
      background: #fffdf8;
    }}
    .shot img {{
      width: 100%;
      height: auto;
      display: block;
      border-radius: 12px;
      border: 1px solid #ebe2d6;
    }}
    .shot__meta {{
      display: flex;
      flex-direction: column;
      gap: 4px;
    }}
    .shot__name {{
      font-weight: 600;
    }}
    .shot__time {{
      color: #6c625a;
      font-size: 0.9rem;
    }}
  </style>
</head>
<body>
  <main class="page">
    <section class="hero">
      <h1>{html.escape(title)}</h1>
      <p>Generated {html.escape(generated_at)} from Paparazzi preview output.</p>
    </section>
    {''.join(cards)}
  </main>
</body>
</html>
"""


def main() -> None:
    args = parse_args()
    report_dir = Path(args.report_dir)
    output_path = Path(args.output) if args.output else report_dir / "translator-gallery.html"
    entries = load_entries(report_dir)
    generated_at = dt.datetime.now(dt.UTC).strftime("%Y-%m-%d %H:%M UTC")
    output_path.write_text(
        build_html(entries=entries, title=args.title, generated_at=generated_at),
        encoding="utf-8",
    )
    print(f"Wrote {output_path}")


if __name__ == "__main__":
    main()

А тестовая сторона выглядела вот так:

package dev.jamescullimore.app.testing

import android.content.res.Configuration.UI_MODE_NIGHT_MASK
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.HtmlReportWriter
import app.cash.paparazzi.Paparazzi
import app.cash.paparazzi.Snapshot
import app.cash.paparazzi.SnapshotHandler
import app.cash.paparazzi.SnapshotVerifier
import app.cash.paparazzi.TestName
import com.android.ide.common.rendering.api.SessionParams
import com.android.resources.Density
import com.android.resources.NightMode
import com.android.resources.ScreenOrientation
import com.android.resources.ScreenRatio
import com.android.resources.ScreenRound
import com.android.resources.ScreenSize
import com.google.testing.junit.testparameterinjector.TestParameter
import com.google.testing.junit.testparameterinjector.TestParameterInjector
import com.google.testing.junit.testparameterinjector.TestParameterValuesProvider
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import sergio.sastre.composable.preview.scanner.android.AndroidComposablePreviewScanner
import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo
import sergio.sastre.composable.preview.scanner.android.device.DevicePreviewInfoParser
import sergio.sastre.composable.preview.scanner.android.device.domain.Device
import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview
import kotlin.math.ceil

@RunWith(TestParameterInjector::class)
class PreviewSnapshotTests(
    @TestParameter(valuesProvider = PreviewProvider::class)
    private val preview: ComposablePreview<AndroidPreviewInfo>,
) {
    @get:Rule
    val paparazzi: Paparazzi = PreviewSnapshotRule.createFor(preview)

    @Test
    fun snapshot() {
        val info = preview.previewInfo
        paparazzi.snapshot(name = info.name) {
            PreviewBackground(
                showBackground = info.showSystemUi || info.showBackground,
                backgroundColor = info.backgroundColor,
            ) {
                if (info.showSystemUi) {
                    DevicePreviewInfoParser.parse(info.device)?.inDp()?.let { device ->
                        PreviewSizedToSystemUi(
                            widthInDp = device.dimensions.width.toInt(),
                            heightInDp = device.dimensions.height.toInt(),
                        ) {
                            preview()
                        }
                    } ?: preview()
                } else {
                    preview()
                }
            }
        }
    }
}

object PreviewProvider : TestParameterValuesProvider() {
    override fun provideValues(context: Context?): List<ComposablePreview<AndroidPreviewInfo>> =
        AndroidComposablePreviewScanner()
            .scanPackageTrees("dev.jamescullimore.app.ui")
            .getPreviews()
}

object PreviewSnapshotRule {
    fun createFor(preview: ComposablePreview<AndroidPreviewInfo>): Paparazzi {
        val info = preview.previewInfo
        return Paparazzi(
            deviceConfig = DeviceConfigBuilder.build(info),
            supportsRtl = true,
            showSystemUi = info.showSystemUi,
            renderingMode =
                when {
                    info.showSystemUi -> SessionParams.RenderingMode.NORMAL
                    info.widthDp > 0 && info.heightDp > 0 -> SessionParams.RenderingMode.FULL_EXPAND
                    info.heightDp > 0 -> SessionParams.RenderingMode.V_SCROLL
                    else -> SessionParams.RenderingMode.SHRINK
                },
            snapshotHandler =
                when (System.getProperty("paparazzi.test.verify")?.toBoolean() == true) {
                    true -> PreviewSnapshotVerifier(maxPercentDifference = 0.0)
                    false -> PreviewHtmlReportWriter()
                },
        )
    }
}

object DeviceConfigBuilder {
    fun build(preview: AndroidPreviewInfo): DeviceConfig {
        val parsedDevice = DevicePreviewInfoParser.parse(preview.device)?.inPx() ?: return DeviceConfig()
        val dimensions = previewDimensions(parsedDevice, preview.widthDp, preview.heightDp)

        return DeviceConfig(
            screenHeight = dimensions.heightPx,
            screenWidth = dimensions.widthPx,
            density = Density(parsedDevice.densityDpi),
            xdpi = parsedDevice.densityDpi,
            ydpi = parsedDevice.densityDpi,
            fontScale = preview.fontScale,
            size = ScreenSize.valueOf(parsedDevice.screenSize.name),
            ratio = ScreenRatio.valueOf(parsedDevice.screenRatio.name),
            screenRound = ScreenRound.valueOf(parsedDevice.shape.name),
            orientation = ScreenOrientation.valueOf(parsedDevice.orientation.name),
            locale = preview.locale.ifBlank { "en" },
            nightMode =
                when (preview.uiMode and UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES) {
                    true -> NightMode.NIGHT
                    false -> NightMode.NOTNIGHT
                },
        )
    }

    private fun previewDimensions(
        parsedDevice: Device,
        widthDp: Int,
        heightDp: Int,
    ): PreviewDimensions {
        val conversionFactor = parsedDevice.densityDpi / 160f
        val widthPx = ceil(widthDp * conversionFactor).toInt()
        val heightPx = ceil(heightDp * conversionFactor).toInt()
        return PreviewDimensions(
            widthPx = if (widthDp > 0) widthPx else parsedDevice.dimensions.width.toInt(),
            heightPx = if (heightDp > 0) heightPx else parsedDevice.dimensions.height.toInt(),
        )
    }
}

data class PreviewDimensions(
    val widthPx: Int,
    val heightPx: Int,
)

@Composable
private fun PreviewSizedToSystemUi(
    widthInDp: Int,
    heightInDp: Int,
    content: @Composable () -> Unit,
) {
    Box(
        Modifier
            .size(width = widthInDp.dp, height = heightInDp.dp)
            .background(Color.White),
    ) {
        content()
    }
}

@Composable
private fun PreviewBackground(
    showBackground: Boolean,
    backgroundColor: Long,
    content: @Composable () -> Unit,
) {
    if (!showBackground) {
        content()
        return
    }

    val color = if (backgroundColor != 0L) Color(backgroundColor) else Color.White
    Box(Modifier.background(color)) {
        content()
    }
}

private class PreviewSnapshotVerifier(
    maxPercentDifference: Double,
) : SnapshotHandler {
    private val delegate = SnapshotVerifier(maxPercentDifference = maxPercentDifference)

    override fun newFrameHandler(
        snapshot: Snapshot,
        frameCount: Int,
        fps: Int,
    ): SnapshotHandler.FrameHandler {
        return delegate.newFrameHandler(
            snapshot = snapshot.normalized(),
            frameCount = frameCount,
            fps = fps,
        )
    }

    override fun close() {
        delegate.close()
    }
}

private class PreviewHtmlReportWriter : SnapshotHandler {
    private val delegate = HtmlReportWriter()

    override fun newFrameHandler(
        snapshot: Snapshot,
        frameCount: Int,
        fps: Int,
    ): SnapshotHandler.FrameHandler {
        return delegate.newFrameHandler(
            snapshot = snapshot.normalized(),
            frameCount = frameCount,
            fps = fps,
        )
    }

    override fun close() {
        delegate.close()
    }
}

private fun Snapshot.normalized(): Snapshot {
    val methodName = testName.methodName.substringAfterLast("_").substringBeforeLast("]")
    return Snapshot(
        name = name,
        testName = TestName(packageName = "", className = "", methodName = methodName),
        timestamp = timestamp,
        tags = tags,
        file = file,
    )
}

В этой схеме не было ничего особенно эффектного, но со своей задачей она справлялась.

Со стороны разработки сохранялось полноценное покрытие снепшотами.

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

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

Сказать это легко, а спроектировать удобный процесс — гораздо сложнее. Результаты работы инженерных инструментов часто становятся «технически доступными» другим специалистам, но при этом остаются неудобными в использовании. Я хотел избежать этой ловушки.

К счастью, история обошлась без особых проблем

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

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

Возникло лишь два практических затруднения.

Первое было связано с самими инструментами предпросмотра. Вначале возникла некоторая путаница с настройкой предпросмотров. В итоге предложенное нами исправление не потребовало никаких серьёзных изменений в рантайме. Всё оказалось гораздо проще: документацию нужно было сделать понятнее. В частности, стоило добавить fontScale в пример deviceConfig, а также показать в файле README разумный пример правил именования файлов.

Это может показаться мелочью. Но изменение всё равно оказалось полезным.

Многие неудобства при работе с инструментами возникают не потому, что «библиотека сломана». Иногда правильный способ её использования просто менее очевиден, чем должен быть. В данном случае уточнение документации сделало настройку заметно понятнее — особенно при использовании @PreviewFontScale, когда ожидается, что имена выходных файлов и конфигурация устройства будут корректно учитывать масштаб шрифта.

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

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

Поэтому здесь мне понадобилось небольшое обходное решение.

Ничего особенно сложного — лишь немного дополнительной структуры, чтобы прокручиваемое содержимое можно было сохранить в виде снимков, которые оставались полезными для проверки. После этого всё заработало нормально. Но ситуация хорошо напомнила, что качество preview-driven тестирования напрямую зависит от честности самого предпросмотра. Если он скрывает часть экрана, которую действительно нужно проверить, покрытие формально существует, но на практике остаётся слабым.

Небольшое обходное решение для предпросмотров с прокручиваемым содержимым вот такое:

package dev.jamescullimore.app.ui.settings

import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.material3.Surface
import dev.jamescullimore.app.ui.preview.CombinedPreviews
import dev.jamescullimore.app.ui.theme.AppTheme

@Composable
fun SettingsContent(
    onBack: () -> Unit,
    onNavigate: (String) -> Unit,
    onSignOut: () -> Unit,
    scrollState: ScrollState = rememberScrollState(),
) {
    // Screen content uses scrollState in a verticalScroll modifier.
}

@CombinedPreviews
@Composable
private fun SettingsScreenPreview() {
    AppTheme {
        Surface(modifier = Modifier.fillMaxSize()) {
            SettingsContent(
                onBack = {},
                onNavigate = {},
                onSignOut = {},
            )
        }
    }
}

@CombinedPreviews
@Composable
private fun SettingsScreenScrolledPreview() {
    AppTheme {
        Surface(modifier = Modifier.fillMaxSize()) {
            SettingsContent(
                onBack = {},
                onNavigate = {},
                onSignOut = {},
                scrollState = rememberScrollState(initial = 420),
            )
        }
    }
}

// For longer screens I kept a second preview with a non-zero initial scroll position.
// That let the screenshot set show more than just the top of the page without needing
// a separate capture pipeline for scrollable content.

На этот паттерн я бы внимательно смотрел в любом будущем проекте.

Настоящая стоимость сопровождения определяется качеством превью

Именно это я бы подчеркнул в первую очередь для тех, кто хочет реализовать похожий подход.

Основная нагрузка по сопровождению связана не с инфраструктурой создания скриншотов.

Она связана с качеством предпросмотров.

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

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

Это означает, что каждый предпросмотр должен создаваться осознанно и включать:

Последний пункт особенно важен.

Слабый предпросмотр создаёт слабые скриншоты. Хорошо продуманный предпросмотр одновременно становится переиспользуемой документацией, переиспользуемым тестовым покрытием и материалом для проверки.

Именно поэтому этот подход показался мне достойным дальнейшего использования.

Почему я бы снова выбрал этот подход

Я определённо реализовал бы такую систему ещё раз, главным образом потому, что она превратила конкретное требование заказчика в нечто более универсальное и долговечное.

Да, она помогла проверять переводы.

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

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

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

Этого вполне достаточно.

Что я рекомендую тем, кто захочет реализовать нечто подобное

Для создания похожей системы я бы придерживался следующей последовательности:

  1. Сначала определите, для кого именно создаются скриншоты.
  2. Используйте предпросмотры как единый источник истины, а не создавайте отдельную матрицу вручную.
  3. Добавьте проверку при разных масштабах шрифта с самого начала, а не откладывайте её на потом.
  4. Добавьте режим отображения ключей, если при проверке переводов важно понимать, какой именно строковый ресурс используется, а не только видеть итоговый текст.
  5. Учитывайте экраны с прокручиваемым содержимым и убедитесь, что предпросмотр действительно показывает всё, что необходимо проверить.
  6. Считайте документацию частью инструментария, особенно если настройки предпросмотров можно легко понять неправильно.

Вероятно, это самый скучно звучащий вывод из всей истории, но одновременно один из самых полезных.

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

В данном случае система действительно заслужила доверие.

И именно поэтому она оказалась полезнее, чем изначально просили.

Источник

Exit mobile version