日々常々

ふつうのプログラマがあたりまえにしたいこと。

Recordを使ってく上で気にしとくこと

Java16で導入されたRecordですが、Java17リリースによりこれから一気に使われていくことかと思います。

Java17雑感で「データクラスを新しく作るならRecordを使ってみる」とか書いたんで「よしRecordを使おう!……ところで何気にしなきゃなんだっけ?」な私向けに、現時点で「これくらい知っとくといいんじゃないかな」ってことを書いておきます。だらだら書いたんで順番とか内容の濃淡がひどいかもしれない。

ちゃんとした知識

とか。

あと、うらがみさんとこの「Javaのレコードについてメモ」

・・・・あれ、気になって調べたことが全部書いてある。まいっか。

書き方

record EmptyRecord() { }

record 名前 (レコードコンポーネントのリスト) {ボディー} ですね。ちゃんとしたのは言語仕様の方を。 空っぽでも () とか {} とかは必要。空のRecordの存在意義は知りませんが。

素直に書くとこんなの。

record Point(int x, int y) { }

シンプルで良い。けど { } がちょっとダサい。なくしたらセミコロン書かなきゃいけなさそうだし、仕方ないか(そんな理由じゃないはず)。

  • record って名前はパッケージ名とか変数名とかには使える。
    • クラス名には使えない(Java14以降)
      • f:id:irof:20210924003433p:plain
      • 小文字クラス名とかやらないだろから問題ないでしょ。
  • int x, int y とかを「レコードコンポーネント」と呼ぶらしい。
    • 呼び方は個人的に一番重要なとこだったりする。
    • record宣言で書くのは「レコードコンポーネント」であり、フィールドでもプロパティでもない。
  • レコードコンポーネントに対応したコンストラクタ(CanonicalConstructor)とフィールド、アクセサメソッドが自動的に作られる。
    • 型と名前はレコードコンポーネントと同じ。
    • 自分で定義したら作られない。デフォルトコンストラクタとかと同じ感じ。
  • アクセサメソッドは自分でも定義できる。
    • レコードコンポーネントと同じ型と名前。
    • 引数なし、throwsなしである必要がある。
    • 後述のアノテーションの問題もあるし、自分では作らないほうがいいかも。
  • フィールドは static だけ。インスタンスフィールドを書いたらコンパイルエラー。
  • 他のメソッドは好きに作れる。
  • コンストラクタも好きに作れる。
    • CanonicalConstructorも作れる。型と名前をレコードコンポーネントに合わせる必要がある。
    • CanonicalConstructorを呼ぶようにしなきゃいけないってくらい。 super() は呼べなかった。
  • あと native メソッドは書けない。書かないからいいけど。

CanonicalConstructorとかコンストラクタがちょっと特別なくらいで、他は難しい構文でもないと思います。

お約束

R copy = new R(r.c1(), r.c2(), ..., r.cn()); とやったとき r.equals(copy)true にならなきゃいけない。ってJavadocに書いてる。 アクセサメソッドやCanonicalConstructorを自分で実装せず生成されるものに任せればいい話。

こんな変なことをするなってことです。(コンパイルは通っちゃう)

record NgRecord(int hoge, int fuga) {
    NgRecord(int hoge, int fuga) {
        this.fuga = hoge;
        this.hoge = fuga;
    }
}

アクセサメソッドで計算してもいいけど、その計算結果をコンストラクタで受けたらアクセサメソッドで同じ値を返すようになってなきゃいけない。

これは「お約束」であって崩せるわけだけど、崩しちゃいけない。equals/hashcodeのように実装するなら意識しとけよってものです。「 equalsが同じになるならhashcodeが同じにならないといけない」ってやつね。 equals/hashcodeよりかは何もしなければ守られるものなので、多少ましかな。似たり寄ったりではあるけど。

今あるのをRecordにする?

無理にする必要はないと思うけど……

f:id:irof:20210923234111p:plain

完全コンストラクタを使って全フィールドが final だとIntelliJ IDEAさんは「Recordにしようぜ!」と言ってきます(Weak Warning)。で、サクッと置き換えてくれます。

さすがにアクセサメソッドを置き換えてはくれないので、「recordのアクセサメソッドを使うように置き換えてインライン化」とかはしなきゃな感じ。

自分でツール作ろうと思ったけど、IntelliJ IDEAさんマジIntelliJ IDEAさんだわ。

アノテーションとリフレクション

アプリケーションプログラミングレベルだと特に問題なく使える確信はあります。おそらくイミュータブルなデータコンテナを扱えるかが最大のハードル。 そんなのはRecordに限った話ではないので、気になるのは足回りです。リフレクションとかバイトコードとかです。 バイトコードはどうせJIG対応する時にしっかりみなきゃなので、ここではリフレクションとか、フレームワークやライブラリ目線で。。。

  • private final でも setAccessible(true) したら上書きできるんだけど、recordで作ったクラスだとできない。
    • リフレクションAPIの中で判定入ってる。
    • リフレクションで値をセットしているライブラリはそのままじゃ置き換えられない と言う意味。
      • なんとかしてCanonicalConstructorを使うように変更する必要がある。
      • Java16以降で対応なら次のgetRecordComponent使えるけど、15以下も同じコードで扱うならそれなりに面倒。
  • Class#getRecordComponents() とか java.lang.reflect.RecordComponent とかが追加されてる。
  • Class#isRecord() はRecordかどうかの判定メソッド。親クラスが java.lang.Record かとか見てる。

書けるアノテーション

@Target(ElementType.FIELD)
public @interface FieldAnnotation { }

と言う感じのアノテーションを全ElementTypeの文作った上でこんなコードを書いて、リフレクションで取ってみるなど。

@TypeAnnotation
@TypeUseAnnotation
record AnnotatedRecord(
        @FieldAnnotation
        @MethodAnnotation
        @ParameterAnnotation
        @RecordComponentAnnotation
        @TypeUseAnnotation
        int a,
        int b
) {

    @ConstructorAnnotation
    @TypeUseAnnotation
    AnnotatedRecord {
    }

    @MethodAnnotation
    @TypeUseAnnotation
    public int b() {
        return b;
    }
}
  • @Target(ElementType.FIELD)@Target(ElementType.METHOD) もレコードコンポーネントのとこに書ける。
  • これはそれぞれ生成されるフィールドやメソッドに引き継がれる。
    • clz.getDeclaredFields()*.getAnnotations()a@FieldAnnotation が取れる。他のアノテーションはない。
    • clz.getDeclaredMethods()*.getAnnotations()a()@MethodAnnotation が取れる。他のアノテーションはない。
    • なお アクセサメソッドを自分で定義したらアノテーションは引き継がれない。自分でメソッド書くなら自分でアノテーションも書けということ。
  • ちなみに clz.getRecordComponents()*.getAnnotations() では @RecordComponentAnnotation しか取れない。
    • 他の4つは行方不明。
  • コンストラクタのアノテーションを書きたかったらCompact Canonical Constructorsを作る。
    • record@ConstructorAnnotation 書いたらいけるかな?とか思ったけどそんなことなかった。

CanonicalConstructorの見分けかた

java.lang.reflect.Constructor からは特になさそう。javap で見えるコンストラクタも特別なフラグとか持ってない。

Class#getRecordComponents() で型を特定してコンストラクタ取るのが確実かな。

var parameterTypes = Arrays.stream(clz.getRecordComponents())
        .map(RecordComponent::getType)
        .toArray(Class[]::new);
var constructor = clz.getDeclaredConstructor(parameterTypes);

とはいえこれも生成されたのと手で書いたのの区別はつかないわけだけど。 まぁ「お約束」もあるし、名前とか一致してたらいいでしょ。

2021-10-22T23:16 追記 Javadocにまんま書いてた

Class.html#getRecordComponents()

static <T extends Record> Constructor<T> getCanonicalConstructor(Class<T> cls)
     throws NoSuchMethodException {
   Class<?>[] paramTypes =
     Arrays.stream(cls.getRecordComponents())
           .map(RecordComponent::getType)
           .toArray(Class<?>[]::new);
   return cls.getDeclaredConstructor(paramTypes);
 }

javadocにメソッド書かず Class にメソッド生やしておいてください(真顔

リフレクションでのインスタンスコピー

CanonicalConstructorの識別がなぜ必要かと言うと、リフレクションで特定のレコードコンポーネントだけ値を変えたインスタンスを作りたいとかなった場合。

  • CanonicalConstructorを見つける(上記)
  • レコードコンポーネントの値を取得
    • 前述の「お約束」があるので、フィールドからでもアクセサメソッドからでもどっちからでも良い。
    • けど private 無理矢理見るより、素直にアクセサメソッドかな。
  • 必要な値を準備
  • CanonicalConstructorでインスタンス生成

って感じかしら。たぶんBeanUtilsかなんかで実装してるのもうあるだろし、そのうち見とこう。。

2021-09-24T10:30追記

やりたくはないんや……

「各Recordに手を入れず」「一律」となると厳しい感。自分でならwith作るんだけど、目の届かない領域のある開発体制組まれると、どこかで必要になってくる。 そいやLombokはrecordにいい感じのwithを生やしてくれたりするんだろか。

リフレクション関連で思ったこと

フィールドやメソッドにレコードコンポーネントに書いたアノテーションを持ってってくれるのが良い。 これなら既存のアノテーションベースのライブラリもそれなりに素直に対応できるんじゃなかろーか。

Field#set(Object, Object)accessible = true でも拒否されたのはびっくりしました。 recordじゃないfinal フィールドも蹴るようにしたいけど、そうしたらいろんなライブラリが動かなくなりそうねぇ。

Local record classes

JEPに書いてて面白いと思ったのがLocal record classes。コードを転載。

List<Merchant> findTopMerchants(List<Merchant> merchants, int month) {
    // Local record
    record MerchantSales(Merchant merchant, double sales) {}

    return merchants.stream()
        .map(merchant -> new MerchantSales(merchant, computeSales(merchant, month)))
        .sorted((m1, m2) -> Double.compare(m2.sales(), m1.sales()))
        .map(MerchantSales::merchant)
        .collect(toList());
}

ローカルクラスなんて使わないよとか昔から思ってたわけですが、これはありな気がしなくもなくもなく。

ちなみにRecordは型パラメーターを持てるので、こんなTupleは書ける。

record Tuple<A, B>(A a, B b) { }

そして「こんなの作らずにちゃんと名前つけようよ!!」って書いてる。

A central aspect of Java's design philosophy is that names matter.

御意。。

ところでjava.lang.Recordクラス

コード見ての通りequalshashcodetoStringabstract でOverrideされているだけのクラスです。あとコンストラクタが protected だったりします。 extendsimplementsはありません。 Serializable も実装していないんだなーそりゃそうかーと見てたら、「実装したら serializable recordとして特別扱いする。具体的にはシリアライズにreadObjectやwriteObjectは使わない」とか書いてるので、もしシリアライズするなら注意がいるかもです。うらがみさんが試してるのでそっち参照。

ところでrecordで生成されるクラス

enumextends java.lang.Enum になるように、 recordextends java.lang.Recordになります。 これは Recordに独自の基底クラスを作れない と言う意味になります。あと final にもなるので record で作ったクラスを継承もできません。

Recordの主な用途であるDTOですが、共通項目を基底クラスで宣言して実装継承をしているような用途だと、そのままは代替できません。インタフェースは実装できるのでそちらで共通項目を強制するようなことは可能ですが、レコードコンポーネントは記述する必要はあります。

DTOの実装継承で省力化してたら辛いことありそうですね。

javapのMethodParameters

コンパイルする時に -parameters をつけるとメソッドの仮引数名がクラスファイルに残って、リフレクションとかで使えるようになります。( spring-boot-starter-parentを使ったら有効 になってたりします。)

つけなかったらコンパイル時に消えてリフレクションや javap とかで仮引数名が見えなくなるんですけど、CanonicalConstructorは -parameters をつけなくても MethodParameters が入ってたりします。シリアライズの時に使ってたりするのかな。

思ったこと

素直にDTOの代わりに使うのは思ったよりハードルありそうだなー。

開発環境のJDKセットアップ

最近「どこどこのサイトからダウンロード」とかしてないなぁ、と思ったので。

  • IntelliJ IDEA Community Edition 2021.2.2
  • SDKMAN 5.12.4

macOS(メイン)

SDKMAN!にお任せ。

IntelliJ IDEAからはAdd JDK~/.sdkman/candidates/java に入ってるのの主だったもの(8, 11, 17)を適当に入れてます。 SDKMAN!で新しいバージョンのを入れた時、古いのを削除するとリンク切れになって地味に面倒だったりします。

なんで ~/.sdkman/candidates/java/current/bin/java を current-jdk とか言う名前で追加してたりします。これはjava全部消さないと消えない。 勝手に変わるんでアレだけど。(自分で変えてる)

f:id:irof:20210922215457p:plain
今入ってるの

Windows

SDKMAN!がないので、IntelliJ IDEAさんにダウンロードしてもらってます。楽々。

f:id:irof:20210922191414p:plain
この次にダウンロードするJDKが選べる

コード書く分にはこれだけでいいんで楽なんですが、 PATHJAVA_HOME はセットしてくれないので javap とかを衝動的に叩いても動かなかったりします。 javap 叩けないと地味に困るので、環境変数を自分で編集してIntelliJ IDEAさんが入れてくれたのを指定します。 デコンパイラで見るのもいいんですが、それと javap は別腹なんです。あと jshell もたまに叩きたくなる。JAVA_HOMEmvn とか叩きたくなった時に「要ったなー」とか言いながら。

CI(コンテナ)

Docker Hubから、適当に、良い感じのを……

コンテナのサイズは正直あまり気にしてないです。

JDK入ってたらとりあえずなんでもよくて、ビルドツールは後述のWrapperが基本。 CircleCIとかGitHub ActionsとかのCIサービスのコンテナが使える時はそのまま使ったり。

実行環境

Cloud Native Buildpacksとか、 gradle bootBuildImage とか。

コメント

見ての通りワンクッション挟んでるので、本家でリリースされても使えるようになるまでしばらくかかったりします。 「すぐに試したい!」みたいなタイプではないんで、特に困らないタイムラグです。しばらくって言っても、3日もかからないし。 ありがとうございます、と感謝しながら乗っかってる。

おまけ: ビルドツール

  • Gradle はSDKMAN!で入れてる。メジャーバージョンごとに最新を3世代くらい。
  • MavenHomebrewで入れてる。(何故かは忘れた……)
  • CIでもローカルでも使うのはもっぱらWrapper( gradlewmvnw )なので、環境にインストールする物はWrapperのセットアップが主な使い方。触るリポジトリが多くて、それぞれビルドツールのバージョンがまちまちなんで、Wrapperなかったらやってられないです。

テストでのデータベース単位の捉えかた

データベース(に限らずあらゆる永続化リソース)を使用するテストをいかにして行うかはいつだって悩みの種です。この悩みは「どうやったらデータベースを使用するテストを行えるかわからない」ではなく「なんとかやってるけど、不満のようなものがある」というものになるかと思います。

やりかたはたくさんあるのですが、その優劣は条件なしに比較する意味がないくらい、条件に依存します。どんな選択肢も「この条件なら最適」と言えてしまうだけに、広いコンテキストで「こうするのがベスト」とも言いづらいのです。

前提

  • xUnit Test Patterns を下敷きにします。
  • ユニットテストでの話です。他でもある程度通じます。
  • 具象イメージはSpringBootを使用するWebアプリケーションです。そこまでべったりな内容ではありませんが、背景にあるとご理解ください。他でもそれなりに通じます。

データベースを使用するテストで達成したいこと

テストでデータベースを使いつつ達成したいことはいくつもあります。例を挙げます。

  • データベースも含めて期待する動作を担保できること。
  • テストが独立していること。
  • テストが高速で実行できること。
  • テストが繰り返し実行可能であること。
  • テストが他のテストと競合しないこと。
  • CIで素直に実行できること。

(読み飛ばし可) Independent Testが実現できれば繰り返し実行可能や競合しないは達成できるのですが、完全に独立性させるためにはTransient Fresh Fixtureが必要になってきます。しかしながらテストごとにすべてのFixtureのセットアップが必要になるため、実行速度を損ないます。回避としてShared Fixtureが持ち出されたりしますが、これにより独立性を損ねます。Shared Fixtureを使用したまま独立性を確保するためにDatabase Sandboxを適用しますが、制約が生じて気にすることは増えますし、完全ではありません。

……と言ったことに決着をつけるために、選択肢を把握し、テストしたい対象を見定める必要があると思うんです。もちろん現代のコンピュータースペックを持ってすれば多少効率の悪い方法を選んでも、ある程度はどうにかなります。 どうにかならなくなってから呼ばれるんですよね……

大まかな三分類

細かく分けるときりがないので、今回書きたい内容によって3種類に分類して、それぞれについて書いていきます。

本稿はxUnit Test Patternsを読んでいない方向けとして、以降はなるべくパターン名は控えめで書きます。パターンっぽいところはリンクしておくので、気になる方はそちらをどうぞ。

テストに閉じたデータベース

f:id:irof:20210921000534p:plain

ここでのテストはテストメソッドのイメージです。テストインスタンスがクラス単位ならテストクラス単位でもいいんですが、とにかくテストの実行単位ごとに完全に独立したデータベースを使用します。 図はシンプルですが、テストケース数が100ならデータベース数も100になるイメージです。

すべての情報がテストに閉じている、理想の形です。実現できるならこれでいきたい。

荒唐無稽なことを言っているように感じるかもしれませんが、たとえばH2 Database Engineをインメモリでテストごとに名前を変えれば実現できます。

メリットは何よりも完全な独立性。他のテストの影響でデータがおかしくなってたり、同じデータにアクセスする問題は起こりません。データベース起因のテストの不安定さはほとんど起こりません。並列実行も余裕です。100テストケースを100並列で実行できます。リソースが許せば。

デメリットは大量にリソースを使うことと、準備のコストがそれなりに重いこと。 SpringBootTestだとSpringの起動時間も必要ですし、データベース作成からとなるとかなりのコストになります。 コンテナを使用する場合、セットアップ済みのイメージを使用すれば多少時間は稼げるとはいえ、早くても5秒程度は見なきゃかなと思います(なんか今やったら1秒で起動したけど、多分機嫌がいいんだろう)。5秒かかるものを順番に実行すると、100ケースで500秒かかってしまいます。並列のオプションがあるとはいえ、安易に手を出さずに済ませたいし、1マシンで100コンテナも起動できる気がしない。

H2を使うことに関しては このツイートのスレッド とかで書いてます。今回は書きません。 そのうち別に書くかも。書かないかも。

環境に閉じたデータベース

f:id:irof:20210921002700p:plain

ローカルにデータベースをインストールしたり、その環境専用にコンテナやVMを立ち上げるイメージです。開発環境構築とかで開発マシンにデータベースをインストールしているならこれなので、馴染みあるパターンかと思います。

ここで「環境」と言っているのは、先に挙げたように開発者のマシンとかそういうのになるんだけど、実際は「テストの実行単位」くらいで捉えるのがいいと思います。mvn test とかIDEのテストボタンのクリックとか、そういう単位。「環境」を実行単位とするのは、昨今のコンテナベースのCIだと実行単位ごとにデータベースコンテナを立ち上げられる、あれのイメージです。

メリットはデータベース自体やスキーマ、マスタデータなどがセットアップ済みもしくは単発となるため、その分の速度が見込めること。データベースの動作がイメージしやすいこと。本番とある程度同じデータベースが使用できることなどが挙げられます。

デメリットは複数のテストでデータベースを共有していることによるもの。かなり注意していても他のテストの影響でテストが落ちたりしがちです。共有するFixtureがすべて不変ならいいのですが、そうとも限りません。また、テストでセットアップするデータも永続化されるため、後片付けが必要になります。 もしかしたら「開発者がデータベースをセットアップする必要がある」がデメリットになるかもしれません。

「ちゃんと後片付けすれば問題ない」と言うのは全てのコードが制御下にあり、順調に動いている時のみ言えることです。アプリケーションの機能が豊富になってくると「テストで参照していないテーブルへの更新」などがあり、面倒が見切れないこともしばしばありますし、後片付け時の接続断やVMクラッシュなどで必ずいつか失敗して不正なデータが残り、後のテストを阻害します。想定しておく必要はあります。 トランザクションを使用してある程度は担保できますが、テスト対象がトランザクション制御をしていたら適用は難しくなりますし、テスト後のデータを確認したい(と思うのはテストに対するスメルなんですが、思うの自体は仕方ない)こともあり、コミットしたくなったりもする。

このパターンでは、テストの実行順が変わったり並列実行すると不具合がでることもしばしばあります。気をつけていても起こると思いますし、原因を見つけても「事後処理の漏れ」と言う非生産的な対応となるため、あまり嬉しくありません。

開いたデータベース

f:id:irof:20210921003614p:plain

環境に一つどころか全体で一つのデータベースを用意し、それを共有するパターンです。 実行単位でデータベースを起動できない場合、CIではこの形になったりします。 ローカルマシンが貧弱だったころは開発環境でもよく見ました。いまはあまり見ません。見ないとは言わない。

メリットは何よりデータベースのセットアップコストが低いこと。準備は誰かがすればよく、テスト実施側は動いてるサーバーに接続するだけなので、起動すら不要です。 インメモリデータベースや開発環境にインストールする代替データベースではなく、本番相当のスペックや設定のデータベースが使用できうることもメリットに挙げられるかもしれません。(「できうる」です。実際は費用面などからダウングレード版を使うことになるでしょう。)

環境ごとにスキーマを用意するなどすれば環境ごとと同程度の独立性は得られますが、一部のメリットを損ねるのと、接続する可能性のある環境の数だけスキーマが必要になります。また、複数スキーマを使ったデータベース設計を行っていると適用は難しくなります。これは CREATE DATABASE を行えるようなデータベース製品なら緩和できる可能性はありますが、できないものもあるのでいつも使えるわけではありません。

デメリットは「環境に閉じたデータベース」のほとんどが強化されて乗っかってくるのに加え、他の環境から不意に触られるリスクを抱えることになります。 また、共有するデータを変更する場合、多方面に調整が必要になったりするかもしれません。

前段のように環境ごとにデータベースを用意する場合、同じテストを同時に実行することはそんなにありません。ですがこの形だと複数人が同じテストを実行する、CIのコミットステージが同時に動作する、などで全く同じテーブルの同じデータを更新することもまま出てきます。トランザクションを使用していると予期せぬロックでテスト実行時間が妙に伸びたり、使用しているデータベーステスト補助ライブラリなどによってはデッドロックが生じることもなくはありません。「CIが落ちた原因が誰かが同じタイミングでテストを実行したから」とか、わかってもつらみしかありませ。実行した人が責められたりとか……意味わからない。 こう言うことに煩わされたくないので、私はこのパターンの採用にはかなり抵抗しています。でも仕方ない時もある。

また、ユニットテストにおいては通信が必要なところも無視できません。通信不調の影響を受けますし、通信時間も要します。同じネットワーク内の通信であることが多いのでそこまで通信時間はかかりませんが、ユニットテスト文脈では0.1秒かかると10,000ケースのテストで1,000秒プラスでかかります。こんなことを理由にユニットテストのケース数を抑える努力はしたくないものです。そしてこう言うテストに限って並列耐性がなかったりします。

昨今の焦点はCI

開発環境は「環境に閉じたデータベース」でどうにかなってきました。CIでどう考えていくかが現代の焦点になるかと思います。 CIも多くがコンテナが前提になってきているかと思います。10年前は「CIサーバー環境に依存したビルド」なんてものがちらほらありましたが、ほとんどがコンテナによって制御できるようになってきて、とても良い。

さて、CIでテストするにあたり「テストに閉じたデータベース」であればそもそも外因に左右されないので気にする必要はありません。 気にする必要がないのでこれは置いておきましょう。実現できる文脈がまだ限られる話でもありますし。

と言うことで、先に「環境に閉じたデータベース」で書いたように、実行単位ごとに独立したデータベースが欲しくなります。 そしてCIサービスはこれに応えていて、CircleCIではセカンダリコンテナでデータベースを使うとか、GitHub Actionsでサービスコンテナを使うとかが使えるかと思います。CIとしては持っていて欲しい機能になってきていますが、なくてもDocker in Dockerなどで対応できることもあります。そこまでしてでも「環境に閉じたデータベース」を用意する価値はあります。

とはいえ、データベースコンテナを使用するとコンテナ数が単位ごとに一つ追加になります。CIサービスでは使用できる枠に制限がありますし、自前であってもマシンリソースが厳しくなってきます。いつだって取れる選択肢ではないかもしれません。テストに対してデータベースは補助的なものなのに、それのせいでテストの実施が心理的/経済的に妨げられるのはあまり嬉しくないです。

f:id:irof:20210921100323p:plain
ジョブ(CIごとに呼び方はまちまち)ごとの閉じたデータベース

「テストに閉じたデータベース」が厳しくても、環境(ジョブ)の実行ごとに閉じて欲しい。それが厳しいならせめてパイプラインの実行ごとに閉じたデータベースが欲しいところです。

「環境に閉じたデータベース」が選択できず、やむを得なく「開いたデータベース」を使用することもあるでしょう。 データベースサーバーやデータベースアプリケーションは共有しても、テストごとに独立したデータベースやスキーマを作って擬似的に「環境に閉じたデータベース」が作成できることもあります。可能ならばこれを選ぶのが良いでしょう。

やむを得ず共有したデータベースをそのまま使用することもあります。CIにおいてこれはすごく厄介で、同時実行を制限しなければならないかもしれません。CIサーバーでは同時実行数制限などを行えたりしますが、どの単位で制限するかで、CIからのフィードバックの速度が格段に変わります。 もし一連のテストに1時間かかり、データベースを占有しなければ不安定になるとしたら、5個トリガーすれば結果が見れるのは翌日です。こんなのでは「継続的」と言うのは厳しくなってきます。

f:id:irof:20210921100738p:plain
複数のパイプラインから使われる「開いたデータベース」

パイプラインの実行が他のパイプラインで妨げられるのは嬉しくありません。同じパイプラインであればトリガーを順次実行でも「仕方ないなー」と思えるかもしれませんが、他のパイプラインもまたがっては厳しいです。シンプルな単一アプリケーションではあまり考えづらいかもしれませんが、複数のアプリケーションが連携するシステムにおいて、アプリケーションがデータベース共有で連携されるパターンだとあり得る話です。

CIでは「全く同じテストが同時に実行される」はザラにあります。 この際に問題にならないように設計するには、データベースを可能な限りテスト、悪くても環境に閉じるように作るよう、努力をする必要があります。 どうにもならず同期する前に、同じデータベースを使いつつ閉じたデータベースを実現する足掻きもする価値はあります。

xUTPのターン

ここはパターンを使います(我慢できなかった)

データベースを使用するテストでは、Database Sandbox をいかに実現するかが勝負かなと思っています。 意図したデータで意図したテストをする、そのために意図しないデータが紛れ込まない、紛れ込ませない技術が重要。

xUTPの時代背景上、開発者などが最小単位になっていますが、先に挙げたように「テストケースごとに独立」が可能なら実現したいことです。 これがデメリットなしに実現できるのが理想のスタートライン(ここで語っているのはテストの準備段階であり、あくまでスタートラインなんです。やりたいことはテストなんです。)、他の全てはいかにこれに近づけるかの妥協です。

コンテナ時代の到来により、Dedicated Database Sandboxが実現しやすくなりました。CIでも使っていきたいですが、CIサーバーのリソースによっては制限を加える必要が出てきます。

DB Schema per TestRunnerは「環境を跨いでデータベースを共有」せざるを得ない時に有用です。もしかしたらDedicated Database Sandboxの場合もテストの並列実行を考慮するとありかもしれません。

Database Partitioning SchemeGenerated Valueで実現すれば、データベースを共有していても後片付けが不要になったりもします。これは多くのアプリケーションではそんな難しいことでもなく、アプリケーションがたいてい持っている「一意キーを発行する機能」を使うだけで済むものです。アプリケーションとデータベースの設計にもよりますが。データベースに大量のテスト後データが残ったりもしますが、そもそもアプリケーションはデータが溜まっていくものでしょう。何かしら対処する機能はあるはずですし、クリーニングもそこまで難しいものでもないはずです。

Fresh Fixture、できればTransientを第一において、少しずつ崩していく。Shared FixtureにするにしてもImmutable Shared Fixtureを見定めて適用し、mutableには安易に手を出さない。そしてFixtureのSetUpは適切なFixture Setup Patternを選ぶ。SetupしたならTearDownも必要で、対応する適切なものを選ぶ必要がある。そしてTearDownは失敗するものとして備えておく(データベースに限った話ではないが)。

先に書いた「テストに閉じたデータベース」はTransient Fresh Fixtureなんですが、「データベースの準備も含めてテストが行う」は一見非効率に見えて利点はものすごくたくさんあります。最大の利点はIndependent Testが採用するだけで達成できること。そしてオンメモリになるので実行速度はディスクやネットワークアクセスと比べるまでもありません。TearDownも不要であり、仕組みがシンプルになります。「データベースを起動しわすれて動かない」とかも起こり得ません。「実行すれば実行できる」と言う単純なことがバックエンドサービス(12factor)に依存するテストでは難しくなるのですが、それに煩わされない取り回しの良さは強力です。読めば必要なものがわかるのでTests as Documentationにも貢献できます。いつもセットアップするからと言ってImplicit Setupで隠しちゃうと見えなくなるので、併用には注意が必要ですが。

他にもデータベースのテストではPrebuilt FixureStandard Fixture、Shared FixtureのLazy Setupなどもよく出てきます。 データベーステストを補助するライブラリを使用していると知らず知らずのうちに使っていたりします。そういう目で見ると「このパターンの実装(卵と鶏の関係だったりする)だから、こういう使い方」みたいなのが見えてきて面白かったりもします。

xUnit Test Patternsのほとんどは2000年代の初めであり、私が ファッションで 持っている本も2007年に印刷されたもの。 20年も前ですが、パターンとしてはまだまだ学ぶ価値があるものなんじゃないかなと思ってます。 この辺りの語彙で会話できるととても話が早いですし。

まとめ

全部xUnit Test Patternsに書いてた。

補足: 2021-09-23

最初に「比較する意味がない」と書いてる通り、どれがいいとかではないです。と言うか自動テストあるプロダクトなら、たぶん全部あります。意識の度合いはまちまちでしょうが、使い分けてるはずです。

そもそも「テスト」の分類すら曖昧です。単体試験がどうとかユニットテストがどうとか、結合って何と何の結合だとか。一般的に受け入れられるラベリングはありますが、それが全ての現場に当てはまることはありません。脱線が過ぎた。ともかく、「メソッドがテスト対象となるユニットテスト」を切り出しは基本の整理に役立ちますが、現場に適合させる際は応用が必要になります。なのでxUnit Test Patternsを下敷きにしているものの、本稿のタイトルは「テストでの」と広く取りました。

本稿は、これくらいは意識して識別するとだいぶやりやすくなるんじゃないかな?を狙ったものです。現場の状況にもよりますが、実際はもっと細分化して取捨選択しますし、他のTestFixtureとの絡みもあります。それらはもっと特定技術要素に寄ったものになり、理解のための前提知識が特化したものになっていきます。技術以外の要因で切り捨てるものもあります。その辺りは現場でやっていきたいところです。(遠回しな営業活動)

あと、一度「これで」と決めて進めていても、途中で切り替えるとかもザラにあります。切り替える時、に「開いたデータベースをこの環境に閉じるようにしよう」みたいに意識してパスを通しておくと、それに伴う作業内容やリスクがノウハウとして溜まります。次以降スムーズにいくようになります。これがパターンの力であり、プログラミングに通じるところがある。と思ってたり。