/var/log/jsoizo

メモ帳 技術とか趣味とか

個人的に好きなKotestのmatcherたち 〜JUnitやkotlin.testでも使えるよ〜

この記事は検索エンジンプロダクトを一緒に開発してた同窓会 Advent Calendar 2023 の3日目。の代打です。

第二子出産に立ち会うまでのもどかしい待ち時間で心を落ち着けるために書いている。日本語がぐっちゃぐちゃかもしれない。

さて、現在の仕事ではKotlinプログラムのユニットテストを書くときにKotestを利用している。
KotestはScalaTestからインスパイアされたもので、Specの書き方が豊富だったりProperty Based Testingのサポートなどの特徴があるが、他にScalaTestから引き継いだ良い点としてmatcherの豊富さがある。

その中でも個人的に好きなものをいくつか列挙しつつ、利用するシチュエーションを説明していく。なお、"JUnitやkotlin.testでも利用可能である" ということを示すためにサンプルはすべてkotlin.testで記載する。

使い方

詳細は公式ドキュメントを参照

kotest.io

以下はbuild.gradle.ktsのdependency部分だけ抽出したもの。

dependencies {
    val kotestVersion = "5.8.0"
    testImplementation("io.kotest:kotest-assertions-core:$kotestVersion")
    testImplementation("io.kotest:kotest-assertions-json:$kotestVersion")

    implementation("io.arrow-kt:arrow-core:1.2.0")
    testImplementation("io.kotest.extensions:kotest-assertions-arrow:1.4.0")
}

testing {
    suites {
        val test by getting(JvmTestSuite::class) {
            useKotlinTest("1.9.20")
        }
    }
}

コレクション編

コレクションのテストって、とりあえず list shouldBe expected みたいに値の一致で検査しがちなんだけど、関数の仕様によっては順序を持たない集合や条件付きの集合を返すためにコレクションを利用することがあるはずで。そういう際にはこれらのmatcherを利用していくことでテストからそういった仕様を見通すことが容易になり、リファクタリングがしやすくなったり。

shouldContainExactlyInAnyOrder: コレクションの要素が引数に与えた値と完全に一致していることを検査するが、順序は問わない

これの好きなところは、以下の例にもある通りに同一の値が2回出現したとして、expectedにも同様に同じ値が2回出現していないとテストがしっかりコケてくれる点。assertする実装を都度書くと結構たいへんだと思うこれ。

@Test
fun shouldContainExactlyInAnyOrder() {
    val list = listOf(1, 2, 3, 4, 4)
    val expected = listOf(3, 4, 2, 1, 4)
    list.shouldContainExactlyInAnyOrder(expected)
}

shouldHaveLowerBound : Collection<Comparable<T>> が特定の値よりも大きいことを検査する。値オブジェクトはよくCompalableを実装していることが多いので、それらを含む配列のフィルタ条件に関する仕様を表現しやすかったり。 shouldHaveUpperBound とセットで使えば範囲がわかる。

@JvmInline
value class Foo(private val v: String): Comparable<Foo> {
    override fun compareTo(other: Foo): Int = this.v.compareTo(other.v)
}

@Test
fun shouldHaveLowerBound() {
    val list = listOf(Foo("b"), Foo("d"), Foo("c"))
    list.shouldHaveLowerBound(Foo("b"))
}

JSON

仕事だとGaugeでATDD的なことをすることが多くて、その際にgauge-javaを利用してKotlinでstepを実装することになっている。
レスポスボディがJSONなWeb APIの受け入れテストを書くとき、返却するHTTP Bodyと同じ内容のJSONファイルを用意してassertしたい。

KotestのJSON matcherの強力なところで、assert関数の設定次第でJSONのフィールドの順序や配列内の要素の順序などに関して寛容に検査することができる。寛容にと言うのは、含んではいるが順序が異なる場合を許可する というもので、先述の配列の順序は問わないが集合として期待通りであるかを検査したいときなどに有用。

kotest.io

たとえばこのようなJSONを返却するものとして、、、

val json = """
    {
        "name": "Alice",
        "skills": ["Kotlin", "Java", "Scala"],
        "languages": ["Japanese", "English"],
        "address": {
            "country": "Japan",
            "city": "Tokyo"
        }
    }
    """.trimIndent()

デフォルトでは PropertyOrder.Lenient であるため、フィールドの順序は問わず存在していればよい。

@Test
fun default() {
    val expectedJson = """
    {
        "skills": ["Kotlin", "Java", "Scala"],
        "languages": ["Japanese", "English"],
        "address": {
            "country": "Japan",
            "city": "Tokyo"
        },
        "name": "Alice"
    }
    """.trimIndent()
    json.shouldEqualJson { expectedJson }
}

配列内の順序まで寛容で良い場合は ArrayOrder.Lenient 設定を入れて検査する。

@Test
fun arrayOrderLenient() {
    val expectedJson = """
    {
        "name": "Alice",
        "skills": ["Scala", "Kotlin", "Java"],
        "languages": ["Japanese", "English"],
        "address": {
            "country": "Japan",
            "city": "Tokyo"
        }
    }
    """.trimIndent()
    json.shouldEqualJson {
        this.arrayOrder = ArrayOrder.Lenient
        expectedJson
    }
}

ただし、すべての配列に対して順序を寛容に判定することになるため、順序が厳格な配列が同一のJSONオブジェクト内に含まれていた場合にその点が期待通りにassertすることができない。この設定を入れた上で、JSONから厳格にチェックしたい部分だけ取り出してそこだけ配列のチェックする?などが必要になりテストの可読性が落ちそうである。よいプラクティスはないだろうか。。。

shoudContainJsonKeyがUnitではなく対象のJSON値を返却してくれると、こういう感じにできて良さそうなんだけど。

val languagesJson: String = json.shouldContainJsonKey("languages")
val expected = val expected = """["Japanese", "English"]"""
languagesJson.shouldEqualJson{ expected }

Arrow編

あまり国内だと事例の少ない arrow-kt だけど、Either型の定義と便利関数が多く提供されており、それらを組み合わせることでエラーハンドリングまわりの複雑度下げられるのでよく使っている。具体どう使うと良いかは↓の翻訳記事を参照。

jsoizo.hatenablog.com

Either<A,B>.shouldBeRight : Either型の値がRightであることを検査する。 shouldBeLeft ならLeftであることを検査する。引数に elem: B を与える場合はEitherにラップされた値がelem: Bと合致するかどうかも検査できる。

@Test
fun shouldBeRight() {
    val e: Either<Throwable, String> = Either.Right("example")
    e.shouldBeRight("example")
}

さらに、 shouldBeRight なり shouldBeLeft はassertできた場合にEither<A, B>のBやAを返すので、このように値をEither型の値をテストコード中でアンラップするのにも利用できる。 .getOrNull!! みたいなコードでunsafeに値を取り出すことがなくなって良い。

@Test
fun shouldBeLeft() {
    val e: Either<Throwable, String> = Either.Left(Exception("error!!!"))
    val error = e.shouldBeLeft()
    assert(error.message == "error!!!")
}

さいごに

ざっとこんな感じで、テストコードの可読性を上げたりテストによって仕様を表現するためのテクニックとしてのmatcherをいくつか紹介した。他にも見つけたら使っていきたい所存。

余談なんだけど、このアドカレのための前職のLINEグループに現職の先輩がいることに驚いてびっくりしている。ちょうどつい先日退職されてしまったので、もう少し話すことができたら良かったと悔いている。。。 他の方の記事読んでてもためになることが多いし、きっかけを作っていただいたOrganizerの @mado-m さんに感謝。

ジェネリクスを含む関数を関数オブジェクトとして扱いたい

このようなジェネリクスがある関数やクラスがあるものとする。

fun <T> foo(): T = TODO()

class Bar<T> (val value: T)

これらの関数やクラスのコンストラクタを関数オブジェクトとして扱いたいとき、このように :: を付与して KFunction1<T> のような値を宣言したい。

val fooStringFunc = ::foo<String>

val barInt = ::bar<Int>

だが、このコードは以下のエラーによりコンパイルすることができない。

Type arguments are not allowed

宣言する関数オブジェクトの型を明示してあげれば良い。
もう少しいい具合に推論して欲しいところではあるが。。。

val fooStringFunc:KFunction0<String> = ::foo

val barInt: KFunction1<Int, Bar<Int>> = ::bar

なお、クラスのコンストラクタの場合はtypealiasをつけると代入先の型を明示しなくても良くなる。
このとき、 barInt の型は KFunction1<Int, BarInt> となる

class Bar<T> (val value: T)
typealias BarInt = Bar<Int>

val barInt = ::BarInt

KotlinでPartialFunctionを実装しcollect, collectFirstしたい

Scalaでいうcollect的な関数がほしいことがあって、このような関数を実装してみた。

fun <A, B> List<A>.collect(vararg patterns: Pair<(A) -> Boolean, (A) -> B>): List<B> = this.mapNotNull { a ->
    patterns.firstOrNull { (condition, _) -> condition(a) }?.let { (_, transform) -> transform(a) }
}

使ってみるとこんな感じ。
ぱっと見は悪くはないが、 Pair({ it > 0 }, { "Positive: $it" }){ it > 0 } to { "Positive: $it" } と書こうとするとコンパイルエラーになったり、条件が増えたり変換が複雑になるとテストがしづらくfatになり、保守性の低下につながるという問題がある。

val list = listOf(-1, 0, 1, 2, -3, 4)

val positiveOrZero = list.collect(
    Pair({ it > 0 }, { "Positive: $it" }),
    Pair({ it == 0 }, { "Zero" })
)

println(positiveOrZero)
// ["Zero", "Positive: 1", "Positive: 2", "Positive: 4"]

ゆえにやはりPartialFunctionを引数に取るのが良いだろうということになり、KotlinにはPartialFunctionがないので実装してみることにする。

PartialFunctionの実装

PartialFunctionつまり部分関数の説明はこのへんを参考に。

関数の中でも部分関数というのが出てきますが。この定義をわかりや... - Yahoo!知恵袋

部分関数 - mrsekut-p

collectを実装するのに最低限必要な関数だけを持ったPartialFunctionがこちら。
compositeやandThenなどはないが、collectに限らず大抵のPartialFunctionの要件には充分なはず。
ポイントは (A) -> B? を継承しておりinvokeの戻りがnullableである点で、これにより PartialFunctionの "すべてのA型の値に対してB型の値に変換できる必要はない" という性質を表現している。

interface PartialFunction<A, B> : (A) -> B? {
    fun isDefinedAt(a: A): Boolean
    fun apply(a: A): B
    override fun invoke(a: A): B? = if (isDefinedAt(a)) apply(a) else null

    fun orElse(other: PartialFunction<A, B>): PartialFunction<A, B> = object : PartialFunction<A, B> {
        override fun isDefinedAt(a: A): Boolean = this@PartialFunction.isDefinedAt(a) || other.isDefinedAt(a)
        override fun apply(a: A): B = when {
            this@PartialFunction.isDefinedAt(a) -> this@PartialFunction.apply(a)
            else -> other.apply(a)
        }
    }
}

たとえばInt型の値に対して偶数の場合に文字列に変換するPartialFunctionは以下のように宣言できる。

val even = object : PartialFunction<Int, String> {
    override fun isDefinedAt(a: Int): Boolean = a % 2 == 0
    override fun apply(a: Int): String = "Even: $a"
}

ただ、PartialFunctionの生成を行うのに無名クラスの宣言を行う必要があり冗長になるので、以下のようなPartialFunction生成用関数partialFunctionやその省略表記pfを用意しておく。
これとPartialFunction.orElseを組み合わせることで条件分岐のような書き方になり、前述のcollectの例のようにできて見通しも良くなるはずである。

fun <A, B> partialFunction(isDefinedAt: (A) -> Boolean, apply: (A) -> B): PF<A, B> =
    object : PartialFunction<A, B> {
        override fun isDefinedAt(a: A): Boolean = isDefinedAt(a)
        override fun apply(a: A) = apply(a)
    }

typealias PF<A, B> = PartialFunction<A, B>

fun <A, B> pf(isDefinedAt: (A) -> Boolean, apply: (A) -> B): PF<A, B> =
    partialFunction(isDefinedAt, apply)

PartialFunctionを利用したcollect関数

PartialFunctionをinvokeした結果として条件を満たさない場合はnullを返すので、単にmapNotNullの内側でPartialFunctionを呼び出してあげるだけでcollectが出来上がる。

fun <A, B> List<A>.collect(pf: PartialFunction<A, B>): List<B> =
    this.mapNotNull { pf(it) }

なお、mapNotNullをfirstNotNullOfOrNullにしてあげるとcollectFirstとなる。

fun <A, B> List<A>.collectFirst(pf: PartialFunction<A, B>): B? = 
    this.firstNotNullOfOrNull { pf(it) }

collectの利用はこのように。

val list = listOf(-1, 0, 1, 2, -3, 4)

val positive: PF<Int, String> = pf({ it > 0 }) { "Positive: $it" }
val zero: PF<Int, String> = pf({ it == 0 }) { "Zero" }
val positiveOrZero = list.collect(positive.orElse(zero))

println(positiveOrZero)
// ["Zero", "Positive: 1", "Positive: 2", "Positive: 4"]

PartialFunctionであるpositive, zeroを生成してorElseでチェーンさせることで条件分岐を表現できている。
また、positive, zeroが独立した関数になっているため、それぞれの関数を独立して小さくテスト可能ということでありテスト容易性と関数の変更容易性が高くなる。この程度ならあまり気にならないが、パターンが複雑になるとメリットが感じられるはず。

その他PartialFunctionの使いどころ

Resultが特定のエラーの場合のみ回収する関数なども簡単に実装できて良い。
これもScalaのTry[T].recoverの挙動に近くなる。

fun <T> Result<T>.recover(pf: PartialFunction<Throwable, T>): Result<T> =
    when (val exception = exceptionOrNull()) {
        null -> this
        else -> when (val recovered = pf(exception)) {
            null -> this
            else -> result { recovered }
        }
    }

buildpackでビルドしたSpring BootコンテナのHTTPヘルスチェック

composeしてたりECS使ってるとサービスディスカバリしたりblue/greenデプロイをするための前提としてコンテナのヘルスチェックを行うことが多い。 個人的には、デプロイ成否の条件にも使えたりするので、webサーバなどコンテナでデーモンを実行するときはヘルスチェックを必ず設定するようにしている。

例えばwebサーバのヘルスチェックを行いたくて、パス /health がヘルスチェックのエンドポイントだとする。そういった場合にはdocker-composeならこのように書けば良いはずである。curlがないコンテナならwgetを使うという手もある。なければDockerfile内でインストールのコマンドを宣言すればよい。

healthcheck:
    test: [ "CMD-SHELL", "curl -k -f http:///localhost:8080/health" ]

だが、Spring Bootかつbuildpackでコンテナイメージをビルドするとイメージの中にhttpリクエストを投げるコマンドが入っていないためにデフォルトではHTTPヘルスチェックを行うことが出来ない。かつ自前でcurlをインストールするビルダーを定義するのもなかなかにだるい(もしかしたらbuildpack素人なだけで楽なのかも)

というお悩みを paketo-buildpacks/health-checker で解決できる。

以下のように BootBuildImage タスクに以下の設定を追加。

tasks.withType<BootBuildImage> {
    environment.put("BP_HEALTH_CHECKER_ENABLED", "true")
    buildpacks.set(listOf("urn:cnb:builder:paketo-buildpacks/java", "gcr.io/paketo-buildpacks/health-checker:latest"))
}

そのうえでコンテナの設定を以下のように変更する。
THC_PATHという環境変数は、このヘルスチェックを行っているのがtiny-health-checkerというbuildpackなのだが、これがいくつかの環境変数を要求してくるので追加しているものである(コマンドライン引数に渡せないのがなかなかに面倒だ..........)

environment:
    THC_PATH: "/ping"
healthcheck:
    test: [ "CMD-SHELL", "/cnb/process/health-check" ]

すると無事にHTTPヘルスチェックを行ってくれる。

とはいえ、正直メジャーではないソフトウェアを利用しているのは微妙だなと感じるので、もっといい方法を模索したい所存。

Playwright Javaをマルチスレッド環境下で平行に動かす(Kotlinで)

注: これが正解かはわかっていない

一度にいくつかのe2eテストを高速に実行する目的で並列にしたくて、かつブラウザのコンテキストつまりCookieなどをテスト間で共有したくない場合がある。
たとえば、並列実行したい2つのテストがそれぞれwebサービスのログイン情報が異なるみたいな時などが挙げられる。

残念ながらPlaywright Javaはスレッドセーフではないので、そういったことをするには少々の工夫が必要になってしまう。。。

今回は並列に実行するためのアプローチとしてこのような対応方法を考えた。

  1. 並列に実行したいテストのプロセスを分ける
  2. 複数のスレッドを使い、スレッドごとにPlaywright Javaインスタンスを生成する

今回はgauge-javaと併せて使いたいという事情があり、gauge-javaがスレッドを複数利用することでテストを並列実行するという要件だったので、今回は2のパターンを頑張る必要があった。

実装

Playwrightの Browser BrowserContext Page あたりの値をスレッドごとに管理するシングルトンを用意し、そこからPageを取得するようにしてみる。
BrowserContext, Pageに関してはテストケースごとに都度初期化して利用したい。ゆえにこのような関数が必要になる。

object BrowserManager {
    // ブラウザ設定
    private val launchOptions = BrowserType.LaunchOptions().apply {
        headless = false
    }

    // スレッドごとにBrowser, Context, Pageを保持する
    private val threadLocalBrowser: ThreadLocal<Browser> = ThreadLocal()
    private val threadLocalContext: ThreadLocal<BrowserContext> = ThreadLocal()
    private val threadLocalPage: ThreadLocal<Page> = ThreadLocal()

    fun initPage(): Unit {
        println("[${Thread.currentThread().name}] initPage")
        // すでにスレッドに対応するPageが存在する場合はそれを返す
        val page: Page? = threadLocalPage.get()
        page ?: run {
            // Pageが存在しない場合は作成する
            val browser = threadLocalBrowser.getOrSet {
                val playwright = Playwright.create()
                playwright.chromium().launch(launchOptions)
            }
            val context = browser.newContext()
            val newPage = context.newPage()
            threadLocalContext.set(context)
            threadLocalPage.set(newPage)
        }
    }
    
    fun getPage(): Page {
        println("[${Thread.currentThread().name}] getPage")
        // Pageが存在していることを前提として取得する
        threadLocalPage.get()?.let {
            return it
        } ?: run {
            throw IllegalStateException("Page is not initialized")
        }
    }

    fun removePage() {
        println("[${Thread.currentThread().name}] removePage")
        // BrowserContext, Pageが存在していたら破棄する
        threadLocalPage.get()?.let {
            it.close()
            threadLocalPage.remove()
        }
        threadLocalContext.get()?.let {
            it.close()
            threadLocalContext.remove()
        }
    }

    fun removeBrowser() {
        println("[${Thread.currentThread().name}] removeBrowser")
        threadLocalBrowser.get()?.let {
            it.close()
            threadLocalBrowser.remove()
        }
    }
}

並行動作サンプル

並行動作している例はこのように実装した。
実際はgaugeで実行することになるが、紙面の都合で簡易的に自分で2スレッド生成してBrowserManagerを呼び出している。

fun main() {
    println("start")
    val thread1 = Thread {
        // BeforeSpec想定
        BrowserManager.initPage()

        // Spec想定
        val page = BrowserManager.getPage()
        page.navigate("https://www.google.com")
        println("[${Thread.currentThread().name}] Page: ${page.title()}")
        page.navigate("https://www.yahoo.co.jp")
        println("[${Thread.currentThread().name}] Page: ${page.title()}")

        // AfterSpec想定
        BrowserManager.removePage()

        // AfterAll想定
        BrowserManager.removeBrowser()
    }
    val thread2 = Thread {
        // BeforeSpec想定
        BrowserManager.initPage()

        // Spec想定
        val page = BrowserManager.getPage()
        page.navigate("https://www.google.com")
        println("[${Thread.currentThread().name}] Page: ${page.title()}")
        page.navigate("https://www.yahoo.co.jp")
        println("[${Thread.currentThread().name}] Page: ${page.title()}")

        // AfterSpec想定
        BrowserManager.removePage()

        // AfterAll想定
        BrowserManager.removeBrowser()
    }
    thread1.start()
    thread2.start()

    thread1.join()
    thread2.join()
    println("end")
    exitProcess(0)
}

結果として、出力はこのようになる。
2スレッドで並行してブラウザインスタンスが生成され、スレッド数ぶんのChromiumのプロセスを立ち上げてwebアクセスしていることがわかる。

start
[Thread-0] initPage
[Thread-1] initPage
[Thread-1] getPage
[Thread-0] getPage
[Thread-1] Page: Google
[Thread-0] Page: Google
[Thread-1] Page: Yahoo! JAPAN
[Thread-1] removePage
[Thread-1] removeBrowser
[Thread-0] Page: Yahoo! JAPAN
[Thread-0] removePage
[Thread-0] removeBrowser
end

ThreadLocal使えば良いんじゃないの的な提案をChatGPTに教えてもらったのでマジで便利だなっていうのと、ThreadLocal の使い方を知らなかったので非常に勉強になった。
AIつかってプログラムの改善をすることで自分の知識が深まって引き出しが増えてよいですね。

KotlinでMinimal Cake Pattern

出来そうだなと思ったのでお試し。

Minimal Cake PatternといえばScalaにおけるDIの実装パターンの1つで、コンパイル時に依存性を解決でき、DIコンテナ使ってるときにやりがちなDI用アノテーションのつけ忘れて実行時エラーが起きるみたいなことが無いのが特徴。
かつコンパイルが通ればDIできているという安心感のプログラミング体験も最高。
あとは言語機能だけでDIできるので、AWS Lambdaみたいなフットプリントを小さくしたい環境とかで役立つかもしれない。

Kotlinでもだいたい似たようなことができるぞ

import java.time.Clock
import java.time.Instant
import java.time.ZoneId

interface UseClock {
    val clock: Clock
}

interface ISystemClock: UseClock {
    override val clock: Clock get() = Clock.systemUTC()
}

interface IFixedClock: UseClock {
    override val clock: Clock get() = Clock.fixed(Instant.parse("2023-01-20T12:34:56.999999Z"), ZoneId.of("UTC"))
}



abstract class StopWatch(): UseClock {
    private var started: Instant? = null
    fun start() {
        started = clock.instant()
    }
    fun stop() {
        started?.let { started ->
            val elapsed = clock.instant().toEpochMilli() - started.toEpochMilli()
            println("elapsed: $elapsed")
        } ?: throw IllegalStateException("not started")
    }
}

fun main() {
    val systemClockStopWatch = object : StopWatch(), ISystemClock {}
    systemClockStopWatch.start()
    Thread.sleep(1000)
    systemClockStopWatch.stop()

    val fixedClockStopWatch = object : StopWatch(), IFixedClock {}
    fixedClockStopWatch.start()
    Thread.sleep(1000)
    fixedClockStopWatch.stop()
}

元のScala版のMinimal Cake Patternだと MixinSystemClock となっているがKotlinにmixinの機構はないのでので ISystemClock としている。 また、Scala版では MixinSystemClockUseClock を継承する必要はないが、このKotlinの実装においては ISystemClockUseClock を継承しないと object: Stopwatch(): ISystemClock {}コンパイルが通らない点に留意する。

参考

qiita.com

Viteはライブラリモードの時にアセットを自動的にbase64エンコードしてくれる

たとえばコンポーネントライブラリを作ろうとしていて、そのコンポーネント内で特定の画像ファイルをbackground-imageで埋め込みたいというケースを考える。
このとき、画像をどうやってパッケージに含めるか、とか、ライブラリ利用側でのパス解決がうまいこといくように工夫しなきゃいけない、とかが悩みだったりする。

以下のようなsassファイルに背景画像を設定する記述があったとする

.logo {
    background-image: url("relative/path/to/image.png");
}

この記述のままビルドされた時にurlの中身がそのままだと、利用側プロジェクトにおいてはプロジェクトルートにおける relative/path/to/image.png を参照しようとしてファイルが見つからずレンダリングにコケるだろう。

viteではその辺の問題を回避するために、 ライブラリモード時はすべてのアセットをインライン化しbase64エンコードしてくれる (してしまう)。

.logo {
    background-image: url(data:image/png;base64,iVBORw0KGgo...);
}

特に気にしなくてもライブラリの利用先で画像が確実に読み込まれるメリットが有る一方で、base64エンコードされるということは配布ファイル単体のサイズはでっかくなるので、たとえばsassファイルとかだと最終的に出力されるスタイルシートのファイルがbase64エンコードされた画像のせいでバカでかくなってしまう。ファイルが大きくなると当然ダウンロードにかかる時間が増えるのでフロントエンドのレイアウト確定やレンダリングの速度に影響が出てしまう。。。

ゆえに、むしろ ライブラリモードで作るときはアセットが自動的にbase64エンコードされるので予め表現ごとにリサイズした画像をちゃんと用意しておかないと生成物が肥大化しがちである ということを認識する必要がある。minify等での最適化しようがないので気をつけたほうが良い。

この振る舞いはドキュメント的には build.assetsInlineLimit の注意事項として書かれているだけなので見落としがちである。

vitejs.dev

これを拒否できるオプションを用意してくれ的なissueを切っている人もいる。気持ちはわかる。

github.com

冒頭に書いたような悩みを持っていたところにChatGPT大先生から"画像base64しとけ"的なお告げを受けまして。でこの仕様を知ったときvite最高だなって思ったんだけど、生成されるcssファイルのサイズのことを冷静に考えたら画像を現実的なサイズにリサイズしなきゃとか色々考えることになって(´;ω;`)ウッ…ってなっている。