ソフトウェアは日々改良され、進化し続けるものですが、その過程で自動テストが非常に重要であり、特に継続的インテグレーション (CI) と継続的デリバリー (CD) を行うためには欠かせないものです。
開発者はソフトウェアの異なる面を評価するために、異なる種類のテスト (例えば、単体テストや統合テストなど) を実装します。一般的に、単体テストはソフトウェアのビジネス ロジック (業務のロジック) が正しく動作するかどうかを確認するために行われれます。また、テストの対象となるシステムの部分に基づいて、外部の依存関係 (例えば、データベースや他のサービスなど) は、モック (模擬のもの) やスタブ (簡易版) に置き換えられることが多くあります。
しかし、実際の機能は多くの外部サービスとの連携に依存していることが多く、単体テストだけでは、ソフトウェアの全体的な信頼性を確保するのは難しいです。そのため、統合テストは、実際の外部サービスなどの依存関係を使って、システム全体が正しく動作するかを確認するために行われます。
一般的に統合テストは複雑なプロセスであり、次のものが含まれます。
- データベース、メッセージ ブローカーなどの必要な依存サービスのインストールと構築。
- Web サーバーまたはアプリケーション サーバーの設定。
- 作成したプログラム (jar、war、ネイティブ実行ファイルなど) をサーバーにビルドして展開。
- 統合テストの実行。
Testcontainers を使えば、単体テストのように手軽で簡単に行えるだけでなく、実際の依存サービスを使用する統合テストの信頼性も得られます。
実際の依存関係を使用したテストが重要な理由
テストでは、開発中に迅速にフィードバックを得て、アプリケーションの動作が正しいかどうかを確認します。
モックやインメモリ サービスを使用するテストは、システムが正常に動いていると誤解させ、フィードバックを得るまでの時間を遅らせる可能性があります。実際の依存関係を使用したテストは、実際のコードを実行することで、より高い信頼を与えます。
テストには H2 のようなインメモリ データベースを使い、本番環境では Postgres や SQL Server を使用する一般的なシナリオを考えてみましょう。テストと本番で異なるデータベースを使うことが良くない理由は、いくつかあります。
1. 互換性の問題
ある程度複雑なアプリケーションは、インメモリ データベースでは対応していないことがあり、特定のデータベース機能を利用している場合が多くあります。たとえば、ページネーション (ページ分割) を行う一般的な方法は、LIMIT と OFFSET を使用することです。
SELECT id, name FROM employee ORDER BY name LIMIT 25 OFFSET 50
テストには H2 データベースを使用し、本番環境では MS SQL Server を使用することを想像してみてください。H2 でテストを行うと、テストが成功し、コードが問題なく動作しているという誤解を招く可能性があります。しかし、本番環境では、MS SQL Server は LIMIT…OFFSET 構文をサポートしていないため、コードが動作しなくなることがあります。
2. インメモリ データベースは、本番環境データベースのすべての機能をサポートしているわけではない
アプリケーションはデータベース ベンダー特有の高度な機能を使用していることがありますが、これらはインメモリ データベースでは完全にサポートされていない可能性があります。例としては、XML/JSON 変換関数、 WINDOW 関数、 共通テーブル式 (CTE) などがあります。 このような場合、インメモリ データベースを使用してテストすることは不可能です。
これらは、独自のコードでサービスをモック化する際に、さらに大きな問題に発展することがよくあります。モックを使うと、特定のシナリオのテストに役立つ場合もありますが、その互換性を検証する作業がテストの設定を複雑にします。
モックを使用したテストでは、本番環境でシステムが正常に動作するかを確実に検証することができません。また、コードの互換性やサードパーティの統合によって引き起こされる問題をテストスイートが検出できるかについても信頼できません。
そのため、できるだけ実際の依存関係を使ってテストを行い、モックは必要な場合のみ使用することを強くお勧めします。
Testcontainers を使用した実際の依存関係でのテスト
Testcontainers は、一時的に生成される Docker コンテナーを使って、実際の依存サービスを用いたテストを書くことができるライブラリです。これは、必要な依存サービスを Docker コンテナーとして起動するための API を提供しています。この方法で、モックの代わりに実際のサービスを使用したテストを書くことができます。したがって、単体テスト、API テスト、エンドツーエンド テストのいずれの場合でも、同じプログラミング モデルで実際の依存関係を用いたテストを行うことができます。
Testcontainers ライブラリは、以下のプログラミング言語で使用可能で、多くのフレームワークやテスト ライブラリと簡単に統合できます。
- Java
- Go
- Node.js
- .NET
- Python
- Rust
ケース スタディ
Testcontainers を使用してアプリケーションのさまざまな部分をどのようにテストできるか、そしてそれらがどのように「実際の依存関係を持つ単体テスト」のように見えるかを見てみましょう。
ここでは、一般的な API サービスを実装した SpringBoot アプリケーションのサンプル コードを使用しています。この API サービスはウェブ アプリを通じて利用し、データの保存には Postgres を使用します。しかし、Testcontainers はさまざまなプログラミング言語向けの API を提供しているため、どの言語でも同様の環境を構築できます。
これらの例を見て、Testcontainers で何ができるかを知るための参考にしてください。また、Java を使っている場合は、これまでに書いたテストに似たものが見つかったり、今後のテスト作成の参考になったりするかもしれません。
データリポジトリのテスト
次のような 1 つのカスタム メソッドを持つ Spring Data JPA リポジトリがあるとしましょう。
public interface TodoRepository extends PagingAndSortingRepository<Todo, String> {
@Query("select t from Todo t where t.completed is false")
Iterable<Todo> getPendingTodos();
}
前述の内容を踏まえて、テストにはインメモリ データベース使用し、本番環境では異なる種類のデータベースを使用することは、問題を引き起こす可能性があるためお勧めできません。本番用データベース タイプがサポートする機能やクエリ構文は、インメモリ データベースでサポートされていないかもしれません。
たとえば、次のクエリ (データ移行スクリプトにある場合があります) は、Postgresql では正常に機能しますが、H2 の場合は機能しなくなります。
INSERT INTO todos (id, title)
VALUES ('1', 'Learn Modern Integration Testing with Testcontainers')
ON CONFLICT do nothing;
ですから、常に本番環境で使用されているのと同じタイプのデータベースで、テストすることを推奨します。
TodoRepository の単体テストを書くために、SpringBoot のスライス テスト アノテーション @DataJpaTest を使用することができます。これを実現するために、Testcontainers を使用して Postgres コンテナーをプロビジョニングします。
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class TodoRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14-alpine");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
TodoRepository repository;
@BeforeEach
void setUp() {
repository.deleteAll();
repository.save(new Todo(null, "Todo Item 1", true, 1));
repository.save(new Todo(null, "Todo Item 2", false, 2));
repository.save(new Todo(null, "Todo Item 3", false, 3));
}
@Test
void shouldGetPendingTodos() {
assertThat(repository.getPendingTodos()).hasSize(2);
}
}
Postgres データベースの依存関係は、Testcontainers JUnit5 Extension を使用してプロビジョニングされ、テストは実際の Postgres データベースと通信します。 コンテナー ライフサイクル管理に関する詳細については、「Testcontainers と JUnit の統合」を参照してください。
インメモリ データベースを使用する代わりに、運用環境で使用されているのと同じ種類のデータベースでテストすることで、データベースの互換性の問題が完全に回避され、テストの信頼性が高まります。
データベースのテストに関しては、Testcontainers が特別な JDBC URL サポートを提供しているため、SQL データベースとの作業がより簡単になります。
REST API エンドポイントのテスト
テスト コンテナーを介してプロビジョニングされたデータベースなど、必要な依存関係とともにアプリケーションを起動することで、API エンドポイントをテストすることができます。REST API エンドポイントをテストするためのプログラミング モデルは、リポジトリの単体テストと同じです。
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
public class TodoControllerTests {
@LocalServerPort
private Integer port;
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14-alpine");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
TodoRepository todoRepository;
@BeforeEach
void setUp() {
todoRepository.deleteAll();
RestAssured.baseURI = "http://localhost:" + port;
}
@Test
void shouldGetAllTodos() {
List<Todo> todos = List.of(
new Todo(null, "Todo Item 1", false, 1),
new Todo(null, "Todo Item 2", false, 2)
);
todoRepository.saveAll(todos);
given()
.contentType(ContentType.JSON)
.when()
.get("/todos")
.then()
.statusCode(200)
.body(".", hasSize(2));
}
}
@SpringBootTest アノテーションを使用してアプリケーションを起動し、RestAssured を使用して API 呼び出しを行い、レスポンスを検証しました。モックを使用せず、実際の API をテストすることで、テストに対する信頼性が向上し、開発者が API コントラクトを壊すことなく任意の内部コードのリファクタリングを行うことができます。
Selenium と Testcontainers を使用したエンドツーエンドのテスト
Selenium は、エンドツーエンド テストを実行するための人気のあるブラウザー自動化ツールです。Testcontainers は、Docker コンテナー内で Selenium ベースのテストを簡素化する Selenium モジュールを提供しています。
@Testcontainers
public class SeleniumE2ETests {
@Container
static BrowserWebDriverContainer<?> chrome = new BrowserWebDriverContainer<>().withCapabilities(new ChromeOptions());
static RemoteWebDriver driver;
@BeforeAll
static void beforeAll() {
driver = new RemoteWebDriver(chrome.getSeleniumAddress(), new ChromeOptions());
}
@AfterAll
static void afterAll() {
driver.quit();
}
@Test
void testViewHomePage() {
String baseUrl = "https://myapp.com";
driver.get(baseUrl);
assertThat(driver.getTitle()).isEqualTo("App Title");
}
}
Testcontainers が提供する WebDriver を使用して、同じプログラミング モデルで Selenium テストを実行することができます。Testcontainers は、複雑な設定手順を必要とせずに、テスト実行を簡単に録画することができます。
参照用に Testcontainers Java SpringBoot QuickStart プロジェクトをご覧いただけます。
まとめ
このブログ記事では、データ アクセス層、API テスト、エンドツーエンド テストなど、開発者がアプリケーションで使用するさまざまなタイプのテストを調べました。また、Testcontainers ライブラリを使用することで、本番環境で使用する実際のデータベースのバージョンなど、実際の依存関係を持つテストのセットアップを簡素化できる方法も発見しました。
Testcontainers は、Java、Go、.NET、Python など、人気のあるプログラミング言語で利用できます。さらに、実際の依存関係を持つテストを、使い慣れた単体テストに変換するための慣用的なアプローチも提供しています。
Testcontainers ベースのテストは、個々のテストを IDE、テストクラス、またはコマンドラインから実行するかどうかに関係なく、CI パイプラインとローカルで同じように実行できます。これにより、問題の再現性だけでなく、開発者エクスペリエンスも向上します。
最後に、Testcontainers は、モックを使用せずに実際の依存関係を持つテストを書くことを可能にし、テスト スイートに対する信頼性を高めることができます。実践的なアプローチがお好きな方は、ぜひこの記事で紹介したすべてのテストタイプを最初から実行できる、Testcontainers Java SpringBoot QuickStart を確認してください。
Testcontainers の開発会社である AtomicJar が Docker の一部に
2023 年 12 月、Docker 社が Testcontainers の開発会社である AtomicJar を買収し、現在、Atomic Jar は Docker の一部として Testcontainers を提供しています。Docker は、Docker Desktop や Docker Scout などにより、すでにソフトウェア開発の「インナーループ」のステップであるビルドや検証、実行、デバッグ、共有の加速をサポートしていますが、Testcontainers により、「テスト」の信頼性向上と加速のサポートを追加しました。これにより、Docker を使用する開発者は、より品質の高いソフトウェアをより速く提供できるようになります。
エクセルソフトは Docker の Preferred Reseller として、Docker Business を販売しています。2022 年 1 月 31 日以降、中・大規模組織による Docker Desktop の利用には有料サブスクリプションが必要となっています。詳細は、弊社 Web サイトをご確認ください。
*本記事は、Docker 社が提供している以下の記事から抜粋・転載したものです。
Testcontainers: 実際の依存関係を使用したテスト