モックと実際のElasticsearchを使用してJavaコードをテストする
モックとテストコンテナを使用してElasticsearchの自動テストを作成する方法を学びます
この記事では、Elasticsearch を外部システム依存関係として使用してソフトウェアをテストする 2 つの方法を紹介し、説明します。モックを使用したテストと統合テストについて説明し、それらの実際の違いを示し、各スタイルでどこに向かうべきかについてヒントを提供します。
システムの信頼性を測る優れたテスト
優れたテストとは、IT システムの作成および保守のプロセスに関わるすべての人の信頼を高めるテストです。テストは、クールであったり、高速であったり、コード カバレッジを人為的に増やしたりするためのものではありません。テストは次のことを確認する上で重要な役割を果たします。
私たちが提供したいものは、本番環境で機能するようになります。
システムは要件と契約を満たしています。
将来的には後退は起こりません。
開発者 (および関係する他のチーム メンバー) は、自分たちが作成したものが機能すると確信しています。
もちろん、これはテストがクールで高速でなかったり、コード カバレッジを増加できなかったりすることを意味するものではありません。テスト スイートをより速く実行できればできるほど、良い結果が得られます。テスト スイートの全体的な期間を短縮することを目指す場合、自動テストによって得られる信頼性、保守性、および自信を犠牲にすべきではありません。
優れた自動テストにより、さまざまなチーム メンバーの自信が向上します。
開発者: 開発者は、自分が行っている作業が機能することを確認できます (作業中のコードが自分のマシンから送信される前でも)。
品質保証チーム: 手動でテストする量が少なくなります。
システム オペレーターと SRE: システムの導入と保守が容易になるため、安心できます。
最後になりましたが、システムのアーキテクチャについてです。私たちは、システムが整理され、保守が容易で、アーキテクチャがクリーンで目的を果たしている場合を好みます。しかし、時には「この方がテストしやすい」という言い訳のために、あまりにも多くのものを犠牲にしているアーキテクチャを目にすることがあります。テストが非常に容易であることは何も悪いことではありません。システムが、その存在を正当化するニーズに応えるのではなく、テストができるように主に書かれている場合にのみ、尻尾が犬を振るという状況が発生します。
2種類のテスト: モックと依存関係
テストを確認する方法、および分類する方法は数多くあります。この投稿では、テストを分割する際の 1 つの側面、つまりモック (またはスタブ、フェイクなど) の使用と実際の依存関係の使用のみに焦点を当てます。私たちの場合、依存関係は Elasticsearch です。
モックを使用したテストは、外部依存関係を開始する必要がなく、すべてがメモリ内でのみ行われるため、非常に高速です。自動テストにおけるモックとは、実際の依存関係を使用せずにプログラムの一部をテストするために、実際のオブジェクトの代わりに偽のオブジェクトを使用することです。これが、それらが必要な理由であり、高速検出ネットテストでそれらが優れている理由です。入力の検証。たとえば、リクエスト内の負の数値が許可されていないことを確認するためだけに、データベースを起動して呼び出す必要はありません。
ただし、モックを導入すると、次のようないくつかの影響が生じます。
すべてをいつでも簡単にモックできるわけではないので、モックはシステムのアーキテクチャに影響を与えます (時には大きな影響があり、時にはそれほど大きくない影響もあります)。
モック上で実行されるテストは高速かもしれませんが、模倣するシステムを詳細に反映するモックは通常無料では提供されないため、そのようなテストの開発にはかなり時間がかかります。システムの仕組みを知っている人が、適切な方法でモックを作成する必要があります。この知識は、実際の経験やドキュメントの学習などから得られます。
モックは維持される必要があります。システムが外部の依存関係に依存していて、この依存関係をアップグレードする必要がある場合、依存関係を模倣するモックも、破壊的変更、ドキュメント化された変更、ドキュメント化されていない変更(これもシステムに影響を与える可能性があります)をすべて反映して更新されるようにする必要があります。これは、依存関係をアップグレードしたいが、(モックのみを使用した) テスト スイートではテストされたすべてのケースが確実に機能するという確信が得られない場合に特に困難になります。
モックではなくシステムの開発とテストに努力が向けられるようにするには、規律が必要です。
これらの理由から、多くの人は正反対の方向に進むことを主張しています。つまり、モック (またはスタブなど) を決して使用せず、実際の依存関係のみに依存するということです。このアプローチは、デモや、システムが小さく、膨大なカバレッジを生成するテスト ケースが少数しかない場合に非常にうまく機能します。このようなテストには、統合テスト (大まかに言うと、システムの一部を実際の依存関係に対してチェックする) やエンドツーエンド テスト (実際の依存関係をすべて同時に使用し、システムの動作をすべてのエンドでチェックしながら、システムが使用可能かつ成功であると定義するユーザー ワークフローを再生する) などがあります。このアプローチを使用することの明らかな利点は、依存関係に関する想定と、それらを作業中のシステムにどのように統合するかについても (多くの場合は意図せずに) 検証できることです。
ただし、テストで実際の依存関係のみを使用する場合は、次の点を考慮する必要があります。
一部のテスト シナリオでは、実際の依存関係は必要ありません (例: リクエストの静的不変条件を検証する場合)。
このようなテストは通常、フィードバックを待つのに時間がかかりすぎるため、開発者のマシンでスイート全体で実行されることはありません。
CI マシンではより多くのリソースが必要となり、時間とリソースを無駄にしないように調整するのにさらに時間がかかる可能性があります。
テストデータを使用して依存関係を初期化するのは簡単ではないかもしれません。
実際の依存関係を持つテストは、大規模なリファクタリング、移行、または依存関係のアップグレードの前にコードを封鎖するのに最適です。
これらは不透明なテストである可能性が高く、つまり、テスト対象のシステムの内部については詳細に説明されず、結果に注意が払われます。
スイートスポット:両方のテストを使用する
システムを 1 種類のテストだけでテストするのではなく、適切な場合には両方の種類のテストを活用し、両方のテストの使用率を向上させることができます。
モックベースのテストの方がはるかに高速なので、最初にモックベースのテストを実行し、すべてが成功した場合にのみ、その後でより遅い依存関係テストを実行します。
外部依存関係が実際には必要のないシナリオでは、モックを選択します。モックに時間がかかりすぎる場合は、そのためだけにコードを大幅に変更し、外部依存関係に依存します。
意味がある限り、両方のアプローチを使用してコードをテストしても問題はありません。
SystemUnderTestの例
次のセクションでは、ここにある例を使用します。これは Java 21 で記述された小さなデモ アプリケーションで、ビルド ツールとして Maven を使用し、Elasticsearch クライアントに依存し、Elasticsearch の最新の追加機能であるES|QL (Elastic の新しい手続き型クエリ言語) を使用しています。Java がプログラミング言語でない場合でも、これから説明する概念を理解し、それを自分のスタックに適用すれば問題ありません。実際のコード例を使用すると、特定の事柄を説明しやすくなります。
BookSearcherは、データの検索と分析(この場合は書籍)の処理に役立ちます(以前の投稿の 1 つで示したように)。
唯一の依存関係として、バージョン
8.15.xの Elasticsearch が必要です (isCompatibleWithBackend()を参照)。たとえば、コードに前方互換性があるかどうか不明であり、後方互換性がないことは確実であるためです。運用中の Elasticsearch を新しいバージョンにアップグレードする前に、まずテストでそれをアップグレードして、テスト対象システムの動作が同じままであることを確認します。これを使用して、特定の年に出版された書籍の数を検索できます (
numberOfBooksPublishedInYear参照)。また、データセットを分析して、特定の 2 年間に最も多く出版された 20 人の著者を見つける必要がある場合にも、これを使用できます (
mostPublishedAuthorsInYears参照)。
public class BookSearcher {
private final ElasticsearchClient esClient;
public BookSearcher(ElasticsearchClient esClient) {
this.esClient = esClient;
if (!isCompatibleWithBackend()) {
throw new UnsupportedOperationException("This is not compatible with backend");
}
}
private boolean isCompatibleWithBackend() {
try (ResultSet rs = esClient.esql().query(ResultSetEsqlAdapter.INSTANCE, """
show info
| keep version
| dissect version "%{major}.%{minor}.%{patch}"
| keep major, minor
| limit 1""")) {
if (!rs.next()) {
throw new RuntimeException("No version found");
}
return rs.getInt(1) == 8 && rs.getInt(2) == 15;
} catch (SQLException | IOException e) {
throw new RuntimeException(e);
}
}
public int numberOfBooksPublishedInYear(int year) {
try (ResultSet rs = esClient.esql().query(ResultSetEsqlAdapter.INSTANCE, """
from books
| where year == ?
| stats published = count(*) by year
| limit 1000""", year)) {
if (rs.next()) {
return rs.getInt("published");
}
} catch (SQLException | IOException e) {
throw new RuntimeException(e);
}
return 0;
}
public List<MostPublished> mostPublishedAuthorsInYears(int minYear, int maxYear) {
assert minYear <= maxYear;
String query = """
from books
| where year >= ? and year <= ?
| stats first_published = min(year), last_published = max(year), times = count (*) by author
| eval years_published = last_published - first_published
| sort years_published desc
| drop years_published
| limit 20
""";
try {
Iterable<MostPublished> published = esClient.esql().query(
ObjectsEsqlAdapter.of(MostPublished.class),
query,
minYear,
maxYear);
List<MostPublished> mostPublishedAuthors = new ArrayList<>();
for (MostPublished mostPublished : published) {
mostPublishedAuthors.add(mostPublished);
}
return mostPublishedAuthors;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public record MostPublished(
String author,
@JsonProperty("first_published") int firstPublished,
@JsonProperty("last_published") int lastPublished,
int times
) {
public MostPublished {
assert author != null;
assert firstPublished <= lastPublished;
assert times > 0;
}
}
}まずはモックでテストする
テストで使用するモックを作成するには、Java エコシステムで非常に人気のあるモック ライブラリであるMockito を使用します。
各テストの前にモックをリセットするには、次のように開始します。
public class BookSearcherMockingTest {
ResultSet mockResultSet;
ElasticsearchClient esClient;
ElasticsearchEsqlClient esql;
@BeforeEach
void setUpMocks() {
mockResultSet = mock(ResultSet.class);
esClient = mock(ElasticsearchClient.class);
esql = mock(ElasticsearchEsqlClient.class);
}
}前に述べたように、モックを使用してすべてを簡単にテストできるわけではありません。しかし、できること(そしておそらくそうすべきこと)もあります。現時点では Elasticsearch のサポートされているバージョンは8.15.xであることを確認してみましょう (将来的には、システムが将来のバージョンと互換性があることが確認されたら、範囲を拡張する可能性があります)。
@Test
void canCreateSearcherWithES_8_15() throws SQLException, IOException{
// when
when(esClient.esql()).thenReturn(esql);
when(esql.query(eq(ResultSetEsqlAdapter.INSTANCE), anyString())).thenReturn(mockResultSet);
when(mockResultSet.next()).thenReturn(true).thenReturn(false);
when(mockResultSet.getInt(1)).thenReturn(8);
when(mockResultSet.getInt(2)).thenReturn(15);
// then
Assertions.assertDoesNotThrow(() -> new BookSearcher(esClient));
}同様に (単に異なるマイナー バージョンを返すことによって)、 BookSearcherが8.16.xとまだ動作しないことを確認できます。これは、互換性があるかどうかわからないためです。
@Test
void cannotCreateSearcherWithoutES_8_15() throws SQLException, IOException {
// when
when(esClient.esql()).thenReturn(esql);
when(esql.query(eq(ResultSetEsqlAdapter.INSTANCE), anyString())).thenReturn(mockResultSet);
when(mockResultSet.next()).thenReturn(true).thenReturn(false);
when(mockResultSet.getInt(1)).thenReturn(8);
when(mockResultSet.getInt(2)).thenReturn(16);
// then
Assertions.assertThrows(UnsupportedOperationException.class, () -> new BookSearcher(esClient));
}ここで、実際の Elasticsearch に対してテストするときに同様のことを実現する方法を見てみましょう。このため、 Testcontainers の Elasticsearch モジュールを使用します。このモジュールの要件は 1 つだけで、Docker コンテナーを実行するため、Docker にアクセスする必要があります。ある角度から見ると、Testcontainers は単に Docker コンテナを操作する方法に過ぎませんが、Docker Desktop (または同様のもの)、CLI、またはスクリプトで操作する代わりに、使い慣れたプログラミング言語でニーズを表現できます。これにより、イメージの取得、コンテナの起動、テスト後のガベージコレクション、ファイルのコピー、コマンドの実行、ログの調査などがテスト コードから直接実行できるようになります。
スタブは次のようになります。
@Testcontainers
public class BookSearcherIntTest {
static final String ELASTICSEARCH_IMAGE = "docker.elastic.co/elasticsearch/elasticsearch:8.15.0";
static final JacksonJsonpMapper JSONP_MAPPER = new JacksonJsonpMapper();
RestClientTransport transport;
ElasticsearchClient client;
@Container
ElasticsearchContainer elasticsearch = new ElasticsearchContainer(ELASTICSEARCH_IMAGE);
@BeforeEach
void setupClient() {
transport = // setup transport here
client = new ElasticsearchClient(transport);
}
@AfterEach
void closeClient() throws IOException {
if (transport != null) {
transport.close();
}
}
}この例では、 Testcontainers の JUnit 統合を@Testcontainersおよび@Containerと利用しています。つまり、テストの前に Elasticsearch を起動したり、テスト後に停止したりする必要はありません。必要なのは、各テストの前にクライアントを作成し、各テストの後にクライアントを閉じることだけです (大規模なテスト スイートに影響を及ぼす可能性のあるリソース リークを回避するため)。
非静的フィールドに@Container注釈を付けるということは、テストごとに新しいコンテナが開始されることを意味します。そのため、古いデータやコンテナの状態のリセットについて心配する必要がありません。ただし、多くのテストではこのアプローチがうまく機能しない可能性があるため、次の投稿のいずれかで代替案と比較する予定です。
注記:
docker.elastic.co(Elastic の公式 Docker イメージ リポジトリ) を利用することで、Docker hub の制限を超えてしまうことを回避できます。最大限の互換性を確保するために、テスト環境と本番環境で同じバージョンの依存関係を使用することをお勧めします。また、バージョンを正確に選択することをお勧めします。このため、Elasticsearch イメージにはlatestタグがありません。
テストでElasticsearchに接続する
Elasticsearch Java クライアントは、セキュリティと SSL/TLS が有効になっている場合でも、テスト コンテナーで実行されている Elasticsearch に接続できます (バージョン 8.x ではこれがデフォルトであるため、コンテナー宣言でセキュリティに関連するものを指定する必要はありませんでした)。実稼働環境で使用している Elasticsearch でも TLS と何らかのセキュリティが有効になっていると仮定すると、統合テストの設定を実稼働環境のシナリオにできるだけ近づけ、テストでそれらを無効にしないことが推奨されます。
コンテナがフィールドまたは変数elasticsearchに割り当てられていると仮定して、接続に必要なデータを取得する方法:
elasticsearch.getHost()コンテナが実行されているホストが提供されます (ほとんどの場合、おそらく"localhost"になりますが、設定によっては別の名前になる場合があるため、これをハードコードしないでください。そのため、ホストは常に動的に取得する必要があります)。elasticsearch.getMappedPort(9200)コンテナ内で実行されている Elasticsearch に接続するために使用する必要があるホスト ポートを指定します (コンテナを起動するたびに外部ポートが異なるため、これも動的な呼び出しである必要があります)。上書きされない限り、デフォルトのユーザー名とパスワードはそれぞれ
"elastic"と"changeme"です。コンテナのセットアップ中に SSL/TLS 証明書が指定されておらず、セキュリティで保護された接続が無効になっていない場合 (バージョン 8.x からのデフォルトの動作)、自己署名証明書が生成されます。それを信頼する(例:cURLと同様に、証明書は
elasticsearch.caCertAsBytes()(Optional<byte[]>を返します) を使用して取得できます。または、別の便利な方法として、createSslContextFromCa()を使用してSSLContextを取得することもできます。
全体的な結果は次のようになります。
BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
credentialsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("elastic", "changeme"));
// Create a low level rest client
RestClient restClient = RestClient.builder(new HttpHost(elasticsearch.getHost(), elasticsearch.getMappedPort(9200), "https"))
.setHttpClientConfigCallback(httpClientBuilder ->
httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider)
.setSSLContext(elasticsearch.createSslContextFromCa())
)
.build();
// The RestClientTransport is mainly for serialization/deserialization
RestClientTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper());
// The official Java API Client for Elasticsearch
ElasticsearchClient client = new ElasticsearchClient(transport);ElasticsearchClientのインスタンスを作成する別の例は、デモ プロジェクトにあります。
注記:
実稼働環境でクライアントを作成する場合は、ドキュメントを参照してください。
最初の統合テスト
Elasticsearch バージョン 8.15.x を使用してBookSearcherを作成できることを確認する最初のテストは次のようになります。
@Test
void canCreateClientWithContainerRunning_8_15() {
Assertions.assertDoesNotThrow(() -> new BookSearcher(client));
}ご覧のとおり、他に何も設定する必要はありません。Elasticsearch によって返されるバージョンをモックする必要はありません。必要なのは、Testcontainers によって開始された Elasticsearch の実際のインスタンスに接続されたクライアントをBookSearcherに提供することだけです。
統合テストは内部をあまり考慮しない
ちょっとした実験をしてみましょう。列インデックスを使用して結果セットからデータを抽出するのをやめ、列名に頼らなければならないと仮定します。そのため、メソッドisCompatibleWithBackendでは、
return rs.getInt(1) == 8 && rs.getInt(2) == 15;私たちは次のことをする予定です:
return rs.getInt("major") == 8 && rs.getInt("minor") == 15;両方のテストを再実行すると、実際の Elasticsearch との統合テストが問題なく合格していることがわかります。ただし、 rs.getInt(int)ではなくrs.getInt(String)ような呼び出しをモックしたため、モックを使用したテストは機能しなくなりました。これらを合格させるには、テスト スイート内の他のユース ケースに応じて、代わりにモックを作成するか、両方をモックする必要があります。
統合テストはハエを殺す大砲になり得る
統合テストは、外部依存関係が必要ない場合でも、システムの動作を検証できます。ただし、このように使用すると、通常、実行時間とリソースが無駄になります。メソッドmostPublishedAuthorsInYears(int minYear, int maxYear)を見てみましょう。最初の 2 行は次のとおりです。
assert minYear <= maxYear;
String query = // here goes the query最初のステートメントは、Elasticsearch (またはその他の外部依存関係) にまったく依存しない条件をチェックしています。したがって、 minYearがmaxYearより大きい場合に例外がスローされることを確認するためだけにコンテナーを起動する必要はありません。
これを確認するには、高速でリソースを大量に消費しない単純なモック テストで十分です。モックを設定したら、次の操作を実行するだけです。
BookSearcher systemUnderTest = new BookSearcher(esClient);
Assertions.assertThrows(
AssertionError.class,
() -> systemUnderTest.mostPublishedAuthorsInYears(2012, 2000)
);このテスト ケースでは、モックする代わりに依存関係を開始するのは無駄です。この依存関係に対して意味のある呼び出しを行う機会がないためです。
ただし、 String query = ...で始まる動作、つまりクエリが正しく記述され、期待どおりの結果が得られることを検証するには、クライアント ライブラリが適切なリクエストと応答を送信でき、構文の変更がないため、統合テストを使用する方がはるかに簡単です。例:
@BeforeEach
void setupDataInContainer() {
// here we initialise data in the Elasticsearch running in a container
}
@Test
void shouldGiveMostPublishedAuthorsInGivenYears() {
var systemUnderTest = new BookSearcher(client);
var list = systemUnderTest.mostPublishedAuthorsInYears(1800, 2010);
Assertions.assertEquals("Beatrix Potter", list.get(12).author(), "Beatrix Potter was 13th most published author between 1800 and 2010");
}こうすることで、Elasticsearch (このバージョンまたは移行先の将来のバージョン) にデータをフィードするときに、クエリによって期待どおりの結果が正確に返されることが保証されます。つまり、データ形式は変更されず、クエリは引き続き有効であり、すべてのミドルウェア (クライアント、ドライバー、セキュリティなど) は引き続き機能します。モックを最新の状態に保つ必要はありません。例えば、互換性を確保するために必要な変更は、8.15はこれを変更します:
static final String ELASTICSEARCH_IMAGE = "docker.elastic.co/elasticsearch/elasticsearch:8.15.0";例えば、ES|QL の代わりに古き良き QueryDSL を使用してください。クエリから受け取る結果は (言語に関係なく) 同じになるはずです。
必要に応じて両方のアプローチを使用する
メソッドmostPublishedAuthorsInYearsのケースは、1 つのメソッドを両方のメソッドを使用してテストできることを示しています。そしておそらくそうなるべきなのかもしれない。
モックのみを使用するということは、モックを維持する必要があり、システムをアップグレードするときに自信がまったくないことを意味します。
統合テストのみを使用すると、まったく必要ないのに、かなりのリソースを無駄にすることになります。
まとめましょう
Elasticsearch ではモックテストと統合テストの両方を使用できます。
fast-detection-net としてモック テストを使用し、正常に合格した場合にのみ、依存関係を使用してテストを開始します (例:
./mvnw test '-Dtest=!TestInt*' && ./mvnw test '-Dtest=TestInt*'またはFailsafeおよびSurefireプラグインを使用)。外部依存関係との統合があまり重要でない(またはスキップできる)システムの動作(「コード行」)をテストする場合は、モックを使用します。
統合テストを使用して、外部システムに関する想定と外部システムとの統合を検証します。
上記の点に従って、意味がある場合は、両方のアプローチを使用してテストすることを恐れないでください。
バージョン (この場合は8.15.x ) について厳しすぎるというのは、言い過ぎではないでしょうか。バージョン タグだけを使用することもできますが、この投稿ではバージョン間で変更される可能性のある他のすべての機能を表すものとして機能していることに注意してください。
シリーズの次回の記事では、テスト データ セットを使用して、テスト コンテナーで実行される Elasticsearch を初期化する方法について説明します。このブログに基づいて何かを構築した場合、または質問がある場合は、ディスカッション フォーラムやコミュニティ Slack チャネルでお知らせください。

