定義する例外を減らしつつソフトウェアを設計する方法を"A Philosophy of Software Design"から学ぶ

会社の読書会で"A Philosophy of Software Design"を読んでいる。

自分の担当で第10章"Define Errors Out Of Existence"をしっかり読む機会があり、けっこう興味深い内容なので読書メモをブログにも書いておく。

章タイトルについて

章タイトル"Define Errors Out Of Existence"は直訳すると「エラーが存在しないものとして定義する」。例外やエラーはプログラム中で定義するものなので”define”の語を使っている様子。

こなれた感じにするには「エラーがもとから存在しないようにする」ぐらいが妥当そう。本文中では”defining away”(”define”を使いつつ消し去るニュアンスを出してる?)みたいな用例も出てくる。

この章の主張

著者のJohn先生による主張の構造は次のとおり。

  • エラーハンドリングのコードは特殊なケースについてのロジックを表している
  • 特殊なケースについてのコードはソフトウェアをより複雑にする
    • 6章の"General-Purpose Modules are Deeper"などでこの主張を支えている
  • よって、次の方法でエラーハンドリングする箇所をできるだけ減らすべきである
    • 例外的な状況をなくして通常のふるまいの一部になるようにセマンティクスを変える
    • 下位層のエラーとして隠す、もしくは上位層でまとめてエラーハンドリングする
    • そのままクラッシュさせる

各節のまとめ

10.1 Why exceptions add complexity

この本での例外 (exception) の定義は次のとおり。

I use the term exception to refer to any uncommon condition that alters the normal flow of control in a program.

プログラムにおける正常系フローを異常系フローに変えてしまう状況を例外と呼んでいる。

例外があると、無視すると別の例外が発生する可能性があるし、例外を受けて処理の中断をするとシステムの整合性を取りつつ、より上位にエラーを上げる必要があるので、コードが複雑になりやすい。また、例外処理のコードはボイラープレートが増えがちで読みにくい。

データ指向の分散システムにおける90%以上の障害がうまくエラーハンドリングできていないことによるものという研究もある。

所感

try-catchな例外処理が増えてくると読みにくいというのは誰しもが感じることがありそう。大域脱出するので処理が追いづらいというのもある。

10.2 Too many exceptions

プログラマはよかれと思って例外を定義しすぎる。

Programmers exacerbate the problems related to exception handling by defining unnecessary exceptions. Most programmers are taught that it’s important to detect and report errors;

著者のJohn先生の失敗談として、Tclunsetが紹介されている。ざっくりまとめると

  • unsetは変数を削除するためのコマンドで、存在しない変数の名前を渡すのは使う側のバグと考えてエラーを投げるようにした
  • 存在するかどうかに関わらず変数名のリストにある名前すべてをunsetに渡したいケースがあるが、存在しない変数が渡る場合に備えてエラーハンドリングを必ず書く必要があり、失敗したと感じている

例外もインタフェースの一部であり、上位層に伝播する性質もあるので、増やしすぎるとシステム全体の複雑度が上がる。

The exceptions thrown by a class are part of its interface; classes with lots of exceptions have complex interfaces, and they are shallower than classes with fewer exceptions.

…It can propagate up through several stack levels before being caught, so it affects not just the method’s caller, but potentially also higher-level callers …

例外を投げて使う側に対応を委譲するのは楽だが、システムの複雑さを軽減するためには例外をハンドリングする場所自体を減らしていく必要がある。

The best way to reduce the complexity damage caused by exception handling is to reduce the number of places where exceptions have to be handled.

所感

防御的プログラミングに対する反論的な章。手元のEffective Java第2版7章「メソッド」の「パラメータの正当性を検査する」には

もし、不正なパラメータ値がメソッドに渡されて、実行する前にメソッドがそのパラメータを検査すれば、適切な例外で持ってメソッドは速やかにきちんと失敗します

とある。こういうプラクティスが主流だった時期の反動はあるのだろうか。

10.3 Define errors out of existence

例外ハンドリングを減らすには、例外をハンドリングしなくて済むように(エラーがもとから存在しないように)APIを定義すればよい。

The best way to eliminate exception handling complexity is to define your APIs so that there are no exceptions to handle: define errors out of existence. This may seem sacrilegious, but it is very effective in practice.

先ほどのunsetの場合、未知の変数を渡したらなにもせずにreturnすればよかった。また、変数を削除するというよりは、削除した結果として変数が存在しなくなっていることを確認するというセマンティクスに変えるとよかった。すると、未知の変数名を渡してもエラーが発生しないというのが自然な挙動になる。

所感

この節は短いが、テストに出るとしたら(?)ここだと思う。APIを設計する時点で、例外を定義せずにすむようなセマンティクスにするのが大事。

10.4 Example: file deletion in Windows / 10.5 Example: Java substring method

これらの節では、Windows/Unixのファイル削除の対比や、JavaのStringクラスのsubstringメソッドを例に、エラーをなくす例を紹介している。

たとえば、Unixではファイル削除を遅延することで次の2種類のエラーをないものとしている。

  • ファイルが利用中でも削除操作がエラーを返さなくなっている
  • ファイルを削除するとき、すでにファイルをオープンしているプロセスで例外が起きなくなっている

また、Javaのsubstringは、指定したインデックスが文字列のインデックスのレンジからはみ出るとIndexOutOfBouondsExceptionが発生するので使いづらい(John先生の感想)。次のようなAPIにすればよい(John先生の提案)。

// Javaは次のコードでIndexOutOfBouondsExceptionを投げるが
// John先生は "ware" を返せるようにすればいいのではと言っている
  "software".substring(4, 8) // => "ware"
// ^^^^^^^^
// 01234567

// その他のerror-freeな例
"software".substring(-1, 3) // => "soft"
"software".substring(8, 9) // => ""
"software".substring(5, 3) // => ""

結果、APIがシンプルかつ多くの機能を持つ = 深いモジュールになる。

例外があるほうがバグをすぐに発見できるのではないかという反論がある。それによってバグを発見できると判断してJavaのsubstringは以上の機能になったかもしれないが、エラーを回避したり無視するコードを書く(もしくは書くのを忘れる)過程でソフトウェアが複雑になり、新たなバグを誘発する。

Overall, the best way to reduce bugs is to make software simpler.

所感

最近の言語だと、たとえばRustのstr::getは上記のJohn先生の提案と同じようなAPIであり、Optionを返す。また、スライスを使って部分文字列を得ることもできるが、こちらはメモリを意識しておりインデックスがはみ出るとpanicになる。

10.6 Mask exceptions

エラーをなくすのではなく隠すテクニックについて。例外的な状況に対してシステムの下位層で対処することで、上位層からは気にする必要がなくなる。

例としてはTCPのパケットロスのときの再送機構、NFSでサーバ内部にエラーを隠してアプリケーションはNFSサーバが復旧するまでハングする、といった話がある。

所感

手法自体は納得感あるものの、例として使っているNFSサーバの話の納得感があまりない…

10.7 Exception aggregation

エラーを単一の箇所でハンドリングする例外の集約について。個別の箇所でエラーハンドリングするのではなく、1つのハンドラを使って1箇所ですべてのエラーに対応する。

Webサーバで、URLに基づいてディスパッチャからハンドラにリクエストを割り当てるケースを考える。URLからパラメータを抽出するメソッド(ここではgetParameterという例)を使い、必要なパラメータがなければ例外を投げるとする。ふつうにディスパッチャから呼び出される各ハンドラでこの例外を対処するとコードが重複する。そこで、ディスパッチャに例外を集めて対処することで、エラー処理が1箇所にまとまる。

getParameterはパラメータ解析方法と人間が読みやすい形式のエラーについてだけ知っており、ディスパッチャはエラーをHTTPレスポンスにする方法だけ知っている、という責務の分担もできる。

所感

Webサーバの例があったが、このようなエラーハンドリングはWebアプリケーションフレームワークの機能で実現するのが現代という気がする。

10.8 Just crash?

例外処理の複雑さを減らすために、アプリケーションをクラッシュさせてしまうというテクニック。

例えば、Cでmallocを使うときにout of memory (OOM)はチェックしきれないし、メモリ不足は現代では起こりにくい。起こるとしたらバグの可能性が高いので、OOMをハンドリングする意義は薄く、そのままクラッシュさせるほうがよい。やるとしたらOOMのときプログラムをエラーメッセージとともに中断するckallocを定義して、それだけ使うようにするほうがまだよい。

所感

『「悪い方が良い」原則と僕の体験談』の3「プログラムの頑強さ」と近い話だと思った。とはいえ、Webサービスの会社としては、信頼できない入力が事故につながるWebアプリケーションではまた別の考え方が必要そうではあった。

10.9 Taking it too far

例外を消し去ったりモジュール内に隠したりするほうがいいといってもやり過ぎに注意。インタフェースが複雑になるが、重要な例外はちゃんとクライアント側に見せる必要がある。

全体の所感

エラーをエラーとして扱わずに済ませるような、特殊ケースをより汎用的な正常系に吸収させるセマンティクスを持つようにAPI定義するという考えかたは、これまで無意識下でやっていたこともあるものの、あらためて意識しておく価値があると思った。この章で紹介されている観点を持って各言語のライブラリのAPIがどのように定義されているかも見てみるとおもしろそう。

中途入社のソフトウェアエンジニアがWebサービス開発に参加するとき役立ったこと

この記事は一休.com Advent Calendar 2023 8日目の記事です。

2023-09-25に入社して2か月半が経ったので、既存のWebサービスの開発にソフトウェアエンジニアとして参加するにあたって役立ったことを書いておく。

Webサービスのソフトウェアエンジニアとしての転職活動で役立ったこと』の続編といえるかもしれない。

前提

  • レストラン予約のサービスの開発に参加した
    • 歴史が長い(2006〜)
  • Webアプリケーションを開発する
  • 技術スタックは転職前後で完全に変わった
    • 前: Rails, PHP, Nuxt, MySQLなど7年
    • 後: Rust, Next.js, Python, Microsoft系技術(SQL Serverなど)1

観点

知らないWebアプリケーションの開発に途中からJOINしたとき、どこから切り込むか?』というスライドでは

  • どのようなサービスか
  • どのようなアーキテクチャか
  • どのようにデータを保持するか
  • どのような開発環境か
  • どのようなコードか

という観点で、新たに参加したプロジェクトで開発を始めるために何が必要かを論じている。

加えて、次のような観点も結果的には必要だった。

  • どのように「未知の未知」を減らすか
  • どのようにチームは開発を進めているか

これらをもとに、今回は次の観点でやってみて役立ったことを挙げる。

  • どのようなサービスかを調べる
  • どのようにデータを保持するかを調べる
  • どのようなコードかを調べる
  • 「未知の未知」をできるだけ早く減らす
  • チームの開発体制に興味を持つ

どのようなサービスかを調べる

プロダクトには、解決すべき問題が属する業務ドメインがある。そこで、まずその業務についてざっくり知っておくと、自分が担当するプロダクト以外も含むサービス全体のアーキテクチャを把握するときや、仕事の会話でキーワードが出てきたときに理解を早められる。

また、インセプションデッキで得られる情報のように、ある程度高い抽象度でプロダクトが解決したいことを把握しておくと、自分のやっていることが結局なににつながるのかというのが腹落ちしやすい。

宿泊予約サービスもやっている会社なので、入社前は宿泊が絡む業界に関する本も読んだ。

航空業界との連携によって古くから基幹システムが導入されていたりとか、サイトコントローラというサービスで複数チャネルからの予約を管理する仕組みができあがっているというのがおもしろポイント。サイトコントローラは飲食店予約の業界でも存在する。また、部屋という総数が減らせない在庫を捌くためにダイナミックプライシングが発達しているというのも普通のECとちょっと違うところ。

一方、肝心の飲食店予約の業務知識については世の中にあんまり出回っていない。これについては、入社後にわからんとインターネットで発言していたらCTOに見つかり、結果として飲食店予約の取り扱いからお店で利用する台帳まで、さらに今後の事業戦略などいろいろと社内で教えていただけたので助かった。知りたい人は入社してください

どのようにデータを保持するかを調べる

システムがどのようにデータを持っているかは、これまでアプリが何度かリプレースされても同じデータ構造を保ってきたという点で重要。かつ、アプリから生成されるデータがプロダクト開発上早めに知っておくべきものたちなので、アプリからのデータの使い方とデータのライフサイクルを突き合わせて理解していく。

参加したサービスでは、店舗の方がオペレーションするための管理システムも提供している。その管理システムをテスト環境で操作しながらデータベースのドキュメントを読むことで、各画面で生成されるデータの構造とその意味についておおまかに把握していった。また、店舗の方に提供する網羅性の高いマニュアルがあるので、そのマニュアル上のガイドを読みつつ操作することで、自分で操作しているだけだと気づかない細かい仕様とそれに関連するデータベース上のデータについても理解を深められた。

この作業で各テーブルのデータについて理解を深めたうえで、アプリケーションのコードを読んでデータをどのように生成、参照、変更しているかもあわせて把握することで、データのライフサイクルの中盤から後半、つまり変更や(論理)削除についても理解を深めていった。

それなりに規模が大きいデータベースでまだまだ全体像を把握しているとは言えないものの、普段の開発に必要なデータについてはこの方法である程度目星がつけられるようになった。

どのようなコードかを調べる

前提で述べたとおり、今回の転職で技術スタックがすっかり変わっている。そんな中でもある程度早く成果を出したいので、とくに最初の段階ではコードベースで読むところを絞った。深くコードベースを理解するためのコードリーディングもまた重要だが、今回は割愛する。

まず、開発タスクを進めるにあたって変更する必要がある箇所のエントリポイントを探す。リクエストがどのように各モジュールを通ってコアのビジネスロジックにたどり着くかを読む。そのうえで、変更が必要な主要モジュールに限定して、それらは自分の言葉で説明できるぐらい理解を深めるようにした。また、同じ箇所を変更している過去のプルリクエストをgit blameで探して、最初のモチベーションを調べたりするのも理解につながりやすかった。

そのほか、コードベースの多くを書いた人が近くにいるなら、設計思想を聞くのがよい。これは次の「未知の未知」にもつながる。

「未知の未知」をできるだけ早く減らす

"A Philosophy of Software Design"の2.2節では"unknown unknowns"がソフトウェアの複雑さの中で一番厄介であり、減らす必要があるという話があった。

一方で、実際は「未知の未知」が初めのうちは数多くあるので、その解決は優先度高めでやる必要がある。

今回だと、初めて取り組んだ大きめのタスクを進めるには、5年以上前の開発の歴史を調べる必要があった。自分やチーム内で見ても進捗しないものだったので、チーム外のシステムの歴史に詳しいエンジニアの方にインタビューする会をささっと企画していろいろ伺うことで、ふつうに調べていたらわからないシステムの挙動に関する話が聞けた。結果、その後の開発方針の決定や進行が楽になった。

また、転職で技術スタックやコードベースが変わったことによって、現状はコードを書くうえで知らないことが多いと実感している。詳しい人からすると、私のプルリクエストはプロダクトの都合で必要な変更が足りない箇所や、もっと適切なメソッドやマクロを使える箇所があったりするかもしれない。そんななかでフィードバックサイクルを早めるために、一通りやりたいことをコードとして書いた時点で早めにドラフトのプルリクエストにして、作業の透明性を上げて早めにコメントをもらえるようにすることで、未知の未知を減らす機会を増やしている。

チームの開発体制に興味を持つ

エンジニアとして転職して開発に参加すると、どうしても技術的な課題解決で頭の中がいっぱいになる。その一方で、チームとしての開発体制がどうなっているかを見ておくと、結果として自分のタスクもスムーズに流せるようになるかもしれない。

まず、あらためて会議の話をちゃんと(!)聞いて、自分がわかっていないことやチーム内外の関心事をいかに把握するかが大事な気がしている。最近は出社したときのミーティングで(議事メモを取ったり何かを確認する必要がないときは)ラップトップを閉じて、能動的に聞く状況に追い込んだりしている。また、抑制的な傾向があるので、素朴な質問をちゃんとすることを最近は意識している。

そのうえで、チームの状況に合わせて、開発プロジェクトを進めるうえで手伝えるところは手伝うようにしている。たとえば、カンバンの運用、ストーリーの切り出し、計画づくりのための規模の相対見積りの導入、プロジェクト管理ツール運用のサポートなどを、アジャイルプラクティスの文脈を補足しつつやっている。開発ペースを予測可能にしてタスクのフロー効率を上げることで、結果として自分含め開発者の満足度を上げたい気持ちでやっている。また、フロー効率が向上すれば毎スプリントのインクリメントが生まれる確率が上がるので、ステークホルダーも嬉しいはず。

このあたりの領域でそれなりに動けるのは、アジャイル事業部がある会社の出身者や関係者が同僚に多くいた過去が影響しているのでラッキーだったと思う。

所感

オンボーディングにおけるアンチパターンの記事が話題だったので、最近オンボードした人間の視点での工夫を書いてみた。実際、オンボーディングの準備をする側も大変な現場が多いと思うので、双方とも楽になる方法を考えていきたい。他にいい方法があれば教えてください。

RustでTestcontainers入門: テストコードから依存サービスを起動してテスト環境を作成する

この記事はRust Advent Calendar 2023 シリーズ1の4日目の記事です。

あるソフトウェアをテストするとき、そのソフトウェアがデータベースやメッセージブローカのような外部のサービスに依存する場合に、その依存をどのように扱うかという問題がつきまとう。この問題へのよくある解決策としては

  • 依存サービスを差し替えられるような設計にして、テスト実行時はモックに差し替える
  • Docker Composeなどであらかじめ依存サービスをテスト用のコンテナとして立ち上げておき、テスト実行時はそれらのサービスが動くコンテナにアクセスする

などが挙げられる。

今回はさらに別の方法として、テスト実行時にテストコードの中から依存サービスをコンテナとして立ち上げるためのフレームワークであるTestcontainersを紹介する。また、RustでTestcontainersを使うときの実例を紹介する。

Testcontainersとは

Testcontainersは、Dockerを利用して外部のサービスをテストコードからプログラマブルに作成するためのフレームワークである。

testcontainers.com

Technology Radarでもadoptedな技術である。

もともと、テスト用のコンテナを用意するときに次のような課題があった。

  • 各テストケースから共用のコンテナを使うと、アクセスが競合してテスト結果が不安定になりうる
  • コンテナの起動時に使用可能になるまで待機する必要がある
  • インメモリのサービス(e.g. MySQLではなくSQLiteを使う)で代替するのはdev/prod parityの観点で問題がある

これらの課題をTestcontainersで解決できる。Testcontainersには主に次の利点がある。

  • コンテナを作成、破棄するAPIを提供するので、テスト実行時にコンテナを使い捨てできる。テスト実行時の環境がつねに期待通りになる
  • コンテナが使用可能になるまでの待機方法をプログラマブルに設定できるので、確実にテスト実行可能なコンテナが得られる
  • テスト用コンテナのポートを自動でホストにマッピングしてくれるのでポート番号被りを気にしなくてよい

Docker Composeでも同じようなことができそうだが、コンテナ起動の待機やポート番号採番の仕組みを作り込まずとも、Testcontainersが提供するAPIを使ってプログラマブルな仕組みで解決できるというのが優位なポイント。この点についてはドキュメントにも記載がある。

https://testcontainers.com/getting-started/#differences-with-docker-and-docker-compose

RustでTestcontainersを使うためのtestcontainers-rs

Testcontainersは各言語に移植されている。Rustならtestcontainers-rsがある。crateの名前はtestcontainers。

github.com

Rustのtestcontainersには次の特長がある。

  • Imageトレイトを構造体が実装すると、その構造体をイメージを表すモジュールとして扱える
    • この記事では詳しく扱わないが、Image::ready_conditionsを実装することでプログラマブルにコンテナの利用可能条件を記述できる
  • コンテナを表す構造体がdropされるときに、Dockerで動いているコンテナが停止する。つまり明示的な後始末は不要

testcontainers-rsを使う

まずtestcontainers-rsの基本的な使い方を説明する。

コンテナを起動する

以下、testcontainers-rsを使うときはDockerランタイムが動いていることを前提とする。

まずコンテナを起動してみる。コンテナとして起動したい主要なミドルウェアのモジュールは、testcontainers-rs-modules-communityというリポジトリでメンテナンスされている。このリポジトリが提供するcrateの名前はtestcontainers-modules。

github.com

今回は私が先日testcontainers-modulesに追加したSQL Serverのモジュールを利用して、SQL Serverのコンテナを起動してみる1

Cargo.tomlにTestcontainers関連のcrateを依存として追加する。testcontainers-modulesが提供する各モジュールはfeatureとして明示的に依存に追加する必要がある。

[dev-dependencies]
testcontainers = "0.15.0"
testcontainers-modules = { version = "0.2.0", features = ["mssql_server"] }

コンテナを起動するだけのコードは次のとおり。

// examples/mssql_server.rs
use testcontainers::clients;
use testcontainers_modules::mssql_server;

fn main() {
    // Docker CLIへのインタフェースを作成する
    let docker = clients::Cli::default();

    // デフォルト設定でSQL Serverのコンテナを起動する
    let mssql_server = docker.run(mssql_server::MssqlServer::default());

    // 起動したコンテナの情報を表示する
    println!("Container ID: {}", mssql_server.id());

    let mssql_default_port = 1433;
    println!("Host port: {}", mssql_server.get_host_port_ipv4(mssql_default_port));
}

実行結果の一例は次のとおり。

$ cargo r --examples mssql_server
    Finished dev [unoptimized + debuginfo] target(s) in 0.09s
     Running `target/debug/examples/mssql_server`
Container ID: 6fcfae72e2ad14bdd85de6a2416d0fc15d1fd35e5663f64876519738a28850b9
Host port: 32830

testcontainers-modulesが提供するモジュールはDefaultトレイトを実装している。このおかげで、MssqlServer::default()を呼べば、普通に使える状態に設定されたイメージが構造体として得られる。例えばDBならユーザのパスワードなどがあらかじめ設定される。また、これらのモジュールはImageトレイトを実装した構造体でもあるので、clients::Cli::runにイメージを渡すとコンテナを起動できる。

コンテナを起動するとContainerのインスタンスが得られる。上のコードではコンテナIDをContainer::idで取得している。また、Container::get_host_port_ipv4を使うと、localhostにマッピングされたポート番号を取得できる。Testcontainersの特長にも書いたように、このポート番号は自動で採番される。実際にコンテナに接続したいときは、このメソッドを通じて取得した番号を利用することになる。

その後、上記のコードだとmain関数の終わりでmssql_serverの所有権が失われる。ContainerDropトレイトを実装しており、デフォルトではdropするときにコンテナを停止する。よって、main関数の終わりのタイミングでプログラムはコンテナを停止する。

イメージの設定を変更する

イメージのモジュールには主要な設定を変えるためのメソッドが存在することが多い。たとえば、MssqlServer::with_sa_passwordを使ってSQL ServerのSAユーザのパスワードを次のように変更できる。

// SAユーザ(管理者ユーザ)のパスワードを変える
let image = mssql_server::MssqlServer::default().with_sa_password("difficult!(passWORD)");
let mssql_server = docker.run(image)

また、そのようなメソッドが用意されていない場合でも、RunnableImageで既存のイメージを包むことで、使うイメージのタグや環境変数を変更できる。特殊な設定を入れたいときはこちらを使うのが便利。

// 少し古い2019年のイメージを使う
RunnableImage::from(MssqlServer::default()).with_tag("2019-CU23-ubuntu-20.04");

// SQL Serverのエディションを選択する環境変数を変更する
RunnableImage::from(MssqlServer::default()).with_env_var(("MSSQL_PID", "Evaluation"));

インテグレーションテストを書いてみる

Testcontainersを使ってDBにアクセスするモジュールに対するテストを書いてみよう。この節のサンプルコードは https://github.com/kymmt90/testcontainers-example で公開している。

インテグレーションテストとしてtestsディレクトリ配下にテストを書く。ディレクトリ構造は次のとおり2

src
└── lib.rs
tests
├── common
│   └── mod.rs
└── integration_test.rs

最終的に次のような形式のテストを書けるようにしていく。

#[tokio::test]
async fn test() -> anyhow::Result<()> {
    // commonに置いたセットアップ関数を使い、Testcontainersを通じてコンテナを起動する
    let container = common::setup_mssql_container().await?;

    // ホストにマッピングされたポート番号を使って、コンテナで動くSQL Serverに接続する
    let mut client = get_mssql_client(container.get_host_port_ipv4(common::MSSQL_PORT)).await?;

    // DBに接続してなにかするロジックを実行する

    // 期待するふるまいかどうか検証する

    Ok(())
}

テスト対象のモジュール

例なのでモジュールの仕様は最小限にする。まず、モジュールからアクセスするDBのスキーマは次のものとする。

create table products (
    id int identity(1,1) primary key,
    name varchar(255) not null,
    price decimal(10,2) not null
);

src/lib.rsは次のとおり。DBから取得したproductsのレコードのデシリアライズと、独自フォーマットの文字列表現作成のロジックだけを持つProductを定義する。このモジュールはtestcontainers_sampleから提供されるものとする。

use std::fmt;

use anyhow::{anyhow, Result};
use tiberius::numeric::Numeric;

#[derive(Debug, PartialEq)]
pub struct Product {
    pub id: i32,
    pub name: String,
    pub price: i128,
}

impl fmt::Display for Product {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}: {} - {}円", self.id, self.name, self.price)
    }
}

impl Product {
    pub fn deserialize(row: &tiberius::Row) -> Result<Self> {
        let id: i32 = row.get("id").ok_or(anyhow!("id not found"))?;

        let name = row
            .get::<&str, _>("name")
            .ok_or(anyhow!("name not found"))?
            .to_string();

        let price = row
            .get::<Numeric, _>("price")
            .ok_or(anyhow!("price not found"))?
            .int_part();

        Ok(Self { id, name, price })
    }
}

テストセットアップ用のヘルパー関数

tests/common/mod.rsはTestcontainersのセットアップなど、テスト全体で使うヘルパー関数を置く。mod.rsの一部は次のようになる。

use std::sync::OnceLock;
use testcontainers::{clients, Container};
use testcontainers_modules::mssql_server::MssqlServer;

pub static MSSQL_PORT: u16 = 1433;

static DOCKER: OnceLock<clients::Cli> = OnceLock::new();

pub type MssqlClient = tiberius::Client<tokio_util::compat::Compat<tokio::net::TcpStream>>;

pub async fn setup_mssql_container<'d>() -> anyhow::Result<Container<'d, MssqlServer>> {
    let docker = DOCKER.get_or_init(clients::Cli::default);
    let container = docker.run(MssqlServer::default());

    create_database(&container).await?;
    load_schema(&container).await?;
    load_master_data(&container).await?;

    Ok(container)
}

pub async fn connect_to_mssql_container(config: tiberius::Config) -> anyhow::Result<MssqlClient> {
    use tokio_util::compat::TokioAsyncWriteCompatExt as _;

    let tcp = tokio::net::TcpStream::connect(config.get_addr()).await?;
    tcp.set_nodelay(true)?;

    Ok(tiberius::Client::connect(config, tcp.compat_write()).await?)
}

// ...

setup_mssql_containerは、SQL Serverのコンテナを起動し、そのコンテナ上でテスト用にデータベースをセットアップするヘルパー関数である。

記事内では省略するが、データベースの作成(create_database)、スキーマのロード(load_schema)、テスト用のマスタデータのロード(load_master_data)を実行する。この例では、testデータベースにproductsテーブルを作り、load_master_dataでproductsテーブルにマスタデータとしてレコード10件をロードしているとする。

最終的に、setup_mssql_containerはテスト用のセットアップが完了したコンテナを返す。

connect_to_mssql_containerは、テストのセットアップやテスト自身からSQL Serverに接続するためのクライアントを取得するヘルパー関数である。

Testcontainersを使ったテスト

用意したセットアップ用のヘルパー関数を使うと、次のようにテストを書ける。ここでは例として2つのテストケースを書く。

mod common;

use testcontainers_sample::Product;

#[tokio::test]
async fn count_total_records() -> anyhow::Result<()> {
    let container = common::setup_mssql_container().await?;
    let mut client = get_mssql_client(container.get_host_port_ipv4(common::MSSQL_PORT)).await?;

    let r = client
        .simple_query("SELECT count(*) AS count FROM products")
        .await?
        .into_row()
        .await?
        .unwrap();

    assert_eq!(r.get("count"), Some(10));

    Ok(())
}

#[tokio::test]
async fn query_product() -> anyhow::Result<()> {
    let container = common::setup_mssql_container().await?;
    let mut client = get_mssql_client(container.get_host_port_ipv4(common::MSSQL_PORT)).await?;

    let id = 1;
    let r = client
        .query("SELECT * FROM products WHERE id = @P1", &[&id])
        .await?
        .into_row()
        .await?
        .unwrap();

    let product = Product::deserialize(&r)?;
    assert_eq!(product.to_string(), "1: product 1 - 1000円");

    Ok(())
}

async fn get_mssql_client(port: u16) -> anyhow::Result<common::MssqlClient> {
    let mut config = common::mssql_config();
    config.database("test");
    config.port(port);

    common::connect_to_mssql_container(config).await
}

1つ目のテストcount_total_recordsでは、productsテーブルにマスタデータがちゃんと10件ロードできているかをテストしている。また、2つ目のテストquery_productでは、productsテーブルからロードしたID 1のレコードをデシリアライズ、文字列化して期待どおり動くかをテストしている。

それぞれのテストでsetup_mssql_containerしているので、Testcontainersは各テストケースに対して1つコンテナを起動する。また、Cargoがテストを並列に実行するので、結果的に2つのコンテナが同時に起動する。ポート番号はそれぞれTestcontainersが採番するので、その番号を使えば問題ない。

テスト実行結果は次のとおり。DBコンテナを起動してテストを最後まで実行できている。

$ cargo t --test integration_test
    Finished test [unoptimized + debuginfo] target(s) in 0.13s
     Running tests/integration_test.rs (target/debug/deps/integration_test-6e5507a7a028eb02)

running 2 tests
test count_total_records ... ok
test query_product ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 7.29s

テスト実行中は次のようにコンテナが2つ起動できている。

$ docker ps
CONTAINER ID   IMAGE                                                   COMMAND                  CREATED         STATUS                  PORTS                     NAMES
082a3a3492f8   mcr.microsoft.com/mssql/server:2022-CU10-ubuntu-22.04   "/opt/mssql/bin/perm…"   2 seconds ago   Up Less than a second   0.0.0.0:32824->1433/tcp   musing_mcclintock
f9ef4fa9349f   mcr.microsoft.com/mssql/server:2022-CU10-ubuntu-22.04   "/opt/mssql/bin/perm…"   2 seconds ago   Up Less than a second   0.0.0.0:32823->1433/tcp   kind_neumann

これらのコンテナはそれぞれのテストの関数が終了するときに、containerがdropされることで停止する。

これで、テスト実行時に起動し、テスト終了とともに停止するコンテナを利用したテストを書くことができた。

補足

Java版での入門ドキュメントなどを見ると、beforeAllのようなフックでテスト実行前にコンテナを起動して複数のテストから使うようにも見える。しかし、実際はそのような意図ではないようだ3。入門ドキュメントの例はテストクラス内にテストが1つなので、そういう書きかたなのかもしれない。

RustではCargoが勝手にテストを並列実行してくれるとはいえ、テストごとにコンテナを起動するとテストスイート全体としては速度が遅くなる。Testcontainersを使うテストはテスト規模でいうところの中テスト(単一マシンで実行されるが、複数プロセスを使うテスト)であり、単一プロセスで実行する小テストより検証できることは増えるが、非決定的なテストの増加や速度低下につながる4。テストスイート全体におけるTestcontainersを使うテストの割合が多くなるなら、そもそもできるだけ単一プロセスでテストを完結できるような設計にできないかを考える必要がありそうだ。


  1. SQL Serverを使うシステムのテストで利用したかったという経緯があり、SQL Serverのモジュールがなかったのでコントリビュートした
  2. https://doc.rust-lang.org/book/ch11-03-test-organization.html も参照のこと
  3. testcontainers-rsのメンテナの人も、テストごとにコンテナは独立させるべきと言っている https://github.com/testcontainers/testcontainers-rs/issues/354
  4. cf. https://gihyo.jp/dev/serial/01/savanna-letter/0003