Share
Embed
フレームワークを10年以上開発する中で培ってきた単体テストのプラクティス / JJUG CCC 2024 Spring
JJUG CCC 2024 Spring の発表資料です。
Kiyoshi Iwasaki
June 22, 2024
Transcript
60 slides
-
Ⓒ2024 Dai Nippon Printing Co., Ltd. All Rights Reserved. 2024年6月16日
大日本印刷株式会社 情報イノベーション事業部 岩崎 清 フレームワークを10年以上開発する中で培ってきた 単体テストのプラクティス -
自己紹介 ◼ 名前 : 岩崎 清 ◼ 所属 : 大日本印刷株式会社
情報イノベーション事業部 ICTセンター ICTDX本部 ◼ 経歴 ◦ 2000年から Java 利用開始 ◦ 2003年から Webアプリケーション開発に従事 ◦ 2013年から社内向け Webアプリケーションフレームワークの開発を開始 ◦ 現在はフレームワークの開発・保守、実案件でのアーキテクトに従事 -
経緯 ◼ JUnit 自体は20年以上前から利用している ◦ 当時は単体テスト ≠ JUnit が大多数 ◦
今でこそ JUnit は当たり前だが、昔は余分なコストと見られることも ◦ 開発したフレームワークを普及させていく中でも、単体テストがあるのでコストが上が ると言われることも ◼ そのような中で開発したフレームワーク、単体テストを普及させたい ◼ そのために ◦ テスト基盤の整備 ◦ ルール、サンプルの整備 などを行ってきた -
前提条件 ◼ 以下のような条件でも効率的にテストを行い、品質も担保したい ◦ 開発者 • メインの開発者はグループ会社の開発者 • プロジェクトでは、協力会社を含めて人が集められることも多い •
開発者のスキルレベルもバラバラ ◦ 品質 • Javaなので比較的お硬いシステムが多いが、決済系からコンテンツ系のシステムまで幅 広く、求められる品質も幅広い • 高い品質を求められるケースにも対応できる必要がある ◦ 開発後の保守 • 開発メンバーが保守するとは限らない -
基本的な考え方 ◼ 単体テストを容易に記述できるように ◦ 面倒だと人は手を抜く ◼ ルールはなるべくシンプルに ◦ ルールが多いと学習コストが上がる ◦
コピー&ペーストでコードは増殖する。パターン(ルール)が多いとコピー元を間違え る ◼ コードレビューを効率的に ◦ 条件の抜けはある程度コードカバレッジで確認できる ◦ アサートの抜けは気づきづらい -
一般論:偽陽性と偽陰性 ◼ 偽陽性(false positive) ◦ 成功すべきテストが失敗する = 誤検知 ◼ 偽陰性(false
negative) ◦ 失敗すべきテストが成功する = 検知漏れ 実際の振る舞い 正しい 間違い(バグ) テスト 結果 成功 真陰性 (正しい推断) 偽陰性 失敗 偽陽性 真陽性 (正しい推断) 引用 : Vladimir Khorikov.「単体テストの考え方/使い方」.マイナビ出版.2022年,416p -
一般論:良い単体テストの4つの柱 ◼ 退行(regression)に対する保護 ◦ 偽陰性(=検知漏れ)が発生しない ◼ リファクタリングへの耐性 ◦ 偽陽性(=誤検知)が発生しない ◼
迅速なフィードバック ◦ テストの実行時間 ◼ 保守のしやすさ ◦ テストの書きやすさ ◦ テストの読みやすさ この3つはトレードオフ 引用 : Vladimir Khorikov.「単体テストの考え方/使い方」.マイナビ出版.2022年,416p -
一般論:偽陽性と偽陰性 実際の振る舞い 正しい 間違い(バグ) テスト 結果 成功 真陰性 (正しい推断) 偽陰性(検知漏れ)
失敗 偽陽性(誤検知) 真陽性 (正しい推断) 偽陰性(検知漏れ)は「退行に対する保護」を阻害する 偽陽性(誤検知)は「リファクタリングへの耐性」を阻害する -
理想 ◼ 「リファクタリングへの耐性」「保守のしやすさ」を最大限に備えたものにする ◼ 「退行に対する保護」「迅速なフィードバック」の間でバランスをとる リファクタリングへの耐性 (誤検知が発生しない) 最大限 にする 退行に対する保護
(検知漏れが発生しない) 保守のしやすさ 迅速なフィードバック バランスを調整する 最大限 にする 引用 : Vladimir Khorikov.「単体テストの考え方/使い方」.マイナビ出版.2022年,416p -
実際の優先順位 ◼ 「退行に対する保護」を最優先 ◦ 多少過剰なアサートがあっても、漏れがないようにする 3 1 保守のしやすさ 4 迅速なフィードバック
2 リファクタリングへの耐性 (誤検知が発生しない) 退行に対する保護 (検知漏れが発生しない) -
テスト対象のシステム構成とテスト観点 ◼ 一般的な MVC 構造の Webアプリケーション ◼ レイヤーごと、コンポーネントごとにテスト観点を決めている Controller Logic
Entity Database Template Object Storage KVS 【Controller層のテスト観点】 ・………… ・………… 【Logic層のテスト観点】 ・………… ・………… 【Entity層のテスト観点】 ・………… ・………… -
テスト観点 ◼ 単体テストだけでなく、テストプロセス全体を通してテスト観点を整理している ◦ テスト観点の抜け漏れを防ぐ ◦ 工程間での重複を減らす 要件定義 詳細設計・実装 基本設計
単体テスト システムテスト 結合テスト テスト観点 ・…………… ・…………… ・…………… ・…………… ・…………… ・…………… ・…………… ・…………… ・…………… ・…………… システムテストで担保するテスト観点 結合テストで担保するテスト観点 単体テストで担保するテスト観点 今回の発表の範囲。 以降、下位レイヤーから具体的なテスト方法等について説明する -
エンティティの単体テスト ◼ 観点 : テーブルのカラムの値が、エンティティのフィールドに読み込まれること @Entity public class AdAdminEntity {
@Id @Column(name = "ADMIN_UID") private long adminUid; @Column(name = "ADMIN_ID") private String adminId; @Column(name = "ADMIN_NAME") private String adminName; admin_uid admin_id admin_name …… 1001 admin1 管理者1 …… 1002 admin2 管理者2 …… …… …… -
エンティティの単体テスト – フィールドとカラムのマッピング ◼ assertEquals を使ったテストコード例 @Test public void selectSuccess01()
throws Exception { // 実行 AdAdminEntity entity = getSession().get(AdAdminEntity.class, 1_001L); // 検証 assertEquals(1001L, entity.getAdminUid()); assertEquals("admin1", entity.getAdminId()); assertEquals("管理者1", entity.getAdminName()); assertEquals("[email protected]", entity.getMailAddress()); assertEquals(2, entity.getLastLoginFailureCount()); assertEquals(AdAdminState.REGISTRATION, entity.getAdAdminState()); // …… } -
エンティティの単体テスト – フィールドとカラムのマッピング ◼ 課題 ◦ フィールド(カラム)が 50 個あったら 50
個アサートを書く必要がある ◦ 抜けがあっても気づきづらい ⇒ レビューも手間が掛かる ◦ カラム、フィールドが足されてもテストは失敗しない(検知漏れ?) ◦ assertEquals の失敗はひとつずつなので、直してまた実行を繰り返す必要がある • JUnit5 の assertAll を利用すればまとめてアサート可能だが、どれが失敗しているか分 かりづらい ◼ オブジェクトを文字列化し、まとめて比較する ◦ 故石井 勝氏のアイデアを発展させたテストツールを作成 -
「まさーるのページ - JUnit実践講座」の紹介 ◼ 「JUnit実践講座 - オブジェクトの文字列表現を活用しよう」 ※1 ◦ 著者
: 故 石井 勝氏 ※2 • 2000年代前半に「まさーるのページ」 でオブジェクト指向関連の有益な記事を多数公開 • 「まさーるのページ」は「オブラブ(旧 : オブジェクト倶楽部)」で今も公開されている ※3 • Quick JUnit の作者 ◦ 検証対象のオブジェクトを文字列配列に変換し、 文字列配列で用意した期待値と比較する ◦ リフレクションで文字列化するライブラリも公開していた ◦ まとめて検証でき、失敗時の差分も分かりやすく表示できる (右図はホームページからの転載。Emacsで差分表示しているが、 Eclipse, IDEA 等でもGUIで分かりやすく差分表示できる) ※1 引用 : オブラブ.「JUnit 実践講座 - オブジェクトの文字列表現を活用しよう」.https://objectclub.jp/community/memorial/homepage3.nifty.com/masarl/article/junit/to-string.html ※2 引用 : 石井勝氏は、2005年のJR福知山線脱線事故で逝去された。 ※3 引用 : オブラブ.「まさーるのページ」.https://objectclub.jp/community/memorial/homepage3.nifty.com/masarl/ -
テスト用ライブラリ - Traverser ◼ オブジェクトを文字列化するツール // デフォルト設定の Traverser を取得 Traverser
traverser = Traverser.createDefault(); // 文字列化対象のオブジェクト Name name = new Name("大日本", "太郎"); // "プロパティ名=値" の String の List に変換 List<String> actual = traverser.traverse(name).getStringList(); // 確認用にコンソール出力 actual.forEach(e -> System.out.println(e)); // 期待値とのアサート List<String> expected = List.of( "firstName=太郎", "lastName=大日本" ); assertEquals(expected, actual); firstName=太郎 lastName=大日本 実行例 対象 実行結果 -
テスト用ライブラリ - Traverser ◼ 機能 ◦ リフレクションで getter またはフィールドを文字列化していく •
参照型は再帰的に文字列化し、プロパティ名を “.” で連結する • getter のみ、フィールドのみ、getter 優先、フィールド優先を指定可能 TraverserConfig config = TraverserConfig.newInstance(); // フィールドのみ対象 config.setPriority(Priority.FIELD_ONLY); Traverser traverser = Traverser.create(config); // 文字列化対象のオブジェクト Person person = new Person(name, address, …); // "プロパティ名=値" の String の List に変換 List<String> actual = traverser.traverse(person).getStringList(); address.address1=東京都 address.address2=新宿区市谷加賀町 address.address3=1-1-1 address.zipCode=162-8001 birthday=2000/01/01 name.firstName=太郎 name.lastName=大日本 実行例 対象 実行結果 getAge() はgetterなので対象にならない -
テスト用ライブラリ - Traverser ◼ 機能 ◦ 終端となるクラスおよびその際の文字列化形式を設定可能 • 何も指定しないと、String やラッパー型もプリミティブ型のフィールドまでたどる
• String やラッパー型だけでなく、DateTimeや自分で定義した型も指定可能 • その際の文字列形式(日時のフォーマット等)も指定可能 TraverserConfig config = TraverserConfig.newInstance(); // 終端クラスの Formatter をクリア config.setTraverserFormatters(new TraverserFormatter<?>[0]); Traverser traverser = Traverser.create(config); // 文字列化対象のオブジェクト IntPair obj = new IntPair(10, 20); // "プロパティ名=値" の String の List に変換 List<String> actual = traverser.traverse(obj).getStringList(); value1=10 value2.value=20 Formatter をクリアした場合の実行例 実行結果 Integer クラスの int 型のフィールド value が文字列化される。 対象 -
テスト用ライブラリ - Traverser TraverserConfig config = TraverserConfig.newInstance(); config.addTraverserFormatter(new TraverserFormatter<Name>() {
@Override public String format(Name obj) { return String.format("Name[lastName=%s, firstName=%s]", obj.getLastName(), obj.getFirstName()); } @Override public Class<Name> getTargetType() { return Name.class; } }); Traverser traverser = Traverser.create(config); // 文字列化対象のオブジェクト Name name = new Name("大日本", "太郎"); Address address = new Address("162-8001", "東京都", …); Person person = new Person(name, address, …); // "プロパティ名=値" の String の List に変換 List<String> actual = traverser.traverse(person).getStringList(); // 確認用にコンソール出力 actual.forEach(e -> System.out.println(e)); address.address1=東京都 address.address2=新宿区市谷加賀町 address.address3=1-1-1 address.zipCode=162-8001 birthday=2000/01/01 name=Name[lastName=大日本, firstName= 太郎] 独自の Formatter を指定した実行例 実行結果 Nameクラスは指定した Formatter で 文字列化される。 Nameクラスに対する Formatter を追加 -
テスト用ライブラリ - Traverser ◼ 機能 ◦ 配列、Collection、Map はサイズとインデックス(Mapはキー)付きで文字列化 Map<String, Name>
map = new HashMap<>(); map.put("太郎", new Name("大日本", "太郎")); map.put(“花子”, new Name(“大日本”, "花子")); // "プロパティ名=値" の String の List に変換して表示 traverser.traverse(map).getStringList().stream() .forEach(e -> System.out.println(e)); List<Address> list = new ArrayList<>(); list.add(new Address("162-8001", "東京都", …)); list.add(new Address("231-0023", "神奈川県", …)); // "プロパティ名=値" の String の List に変換して表示 traverser.traverse(list).getStringList().stream() .forEach(e -> System.out.println(e)); Map, List の実行例 size=2 {太郎}.firstName=太郎 {太郎}.lastName=大日本 {花子}.firstName=花子 {花子}.lastName=大日本 実行結果 size=2 [0].address1=東京都 [0].address2=新宿区市谷加賀町 [0].address3=1-1-1 [0].zipCode=162-8001 [1].address1=神奈川県 [1].address2=横浜市中区 [1].address3=山下町23番地 [1].zipCode=231-0023 -
テスト用ライブラリ - Traverser ◼ 機能 ◦ 再帰的にたどる階層の最大値を指定可能 ◦ 循環参照を検知して安全に文字列化 //
文字列化対象のオブジェクト TestObj obj1 = …; // … TraverserConfig config = …; // 深さ 3 に設定 config.setMaxDepth(3); Traverser traverser = Traverser.create(config); traverser.traverse(obj1).getStringList().…; // 深さのデフォルトは5 traverser = Traverser.createDefault(); traverser.traverse(obj1).getStringList().…; 最大階層指定と循環参照の実行例 test1 test2 test3 test4 name=test1 obj.name=test2 obj.obj.name=test3 obj.obj.obj=TestObj[name=test4] 実行結果 name=test1 obj.name=test2 obj.obj.name=test3 obj.obj.obj.name=test4 obj.obj.obj.obj=[->obj] 対象 循環参照が検知されると、同じ オブジェクトのパスが表示される 最大階層を超えた場合、 toString() が呼ばれる -
テスト用ライブラリ - Traverser ◼ 機能 ◦ 特定のプロパティを文字列化対象から除外可能 ◦ 除外の除外を指定可能 Traverser
traverser = Traverser.create(config); AdAdminEntity entity = getSession().get(AdAdminEntity.class, 1001L); // "プロパティ名=値" の String の List に変換 List<String> actual = traverser.traverse(entity, new String[] { "adAdminGroupEntitySet[]" // 除外指定 }, new String[] { "adAdminGroupEntitySet[].adminGroupUid", // 除外の除外指定 "adAdminGroupEntitySet[].adminGroupId" }).getStringList(); // 確認用にコンソール出力 actual.forEach(e -> System.out.println(e)); 除外指定と除外の除外指定の実行例 実行結果 adAdminGroupEntitySet.size=2 adAdminGroupEntitySet[0].adminGroupId=admin_group1 adAdminGroupEntitySet[0].adminGroupUid=1001 adAdminGroupEntitySet[1].adminGroupId=admin_group2 adAdminGroupEntitySet[1].adminGroupUid=1002 adminId=admin1 adminName=管理者1 …… 対象 -
テスト用ライブラリ - ObjectAssert ◼ 機能 ◦ Traverserで文字列化したオブジェクトと期待値(文字列配列)を比較する ◦ 並び順が異なっていても失敗しない ◦
失敗した場合、IDE で直感的に差分を確認しやすい // アサート対象のオブジェクト Name name = new Name("大日本", "太郎"); Address address = new Address("162-8001", "東京都", …); Person person = new Person(name, address, …); ObjectAssert.assertPropertyEquals( traverser, "エラーメッセージ", new String[] { "name.lastName=大日本", "name.firstName=太郎", "address.zipCode=162-8001", "address.address1=東京都", "address.address2=新宿区市谷加賀町", "address.address3=1-1-1", "birthday=2000/01/01", }, person, new String[0], new String[0], true); ObjectAssert による比較の実行例 ダブルクリックで差分を ビジュアルに表示可能 -
テスト用ライブラリ - ObjectAssert ◼ 機能 ◦ 期待値には、正規表現、幅を持った日付・日時などを指定可能 List<Object> actual =
new ArrayList<>(); // 正規表現用 actual.add("abcd1234"); // 日付 actual.add(LocalDate.now()); actual.add(LocalDate.of(2024, 5, 20)); // 日時 actual.add(LocalDateTime.now()); actual.add( LocalDateTime.of(2024,5,25,12,34)); String[] expecteds1 = { "[0]:regex=a.+4", // 正規表現 "[1]:day=now#-1d", // 幅を持った日付比較 "[2]:day=2024/05/25#-5d", "[3]:date=now#-1s", // 幅を持った日時比較 "[4]:date=2024/05/24 12:33:50#+1d10s", "size=5", }; ObjectAssert.assertPropertyEquals( traverser, "", expecteds1, actual, // … ); -
エンティティの単体テスト – フィールドとカラムのマッピング ◼ Traverser と ObjectAssert を使ったアサート // //
実行 // AdAdminEntity entity = getSession() .get(AdAdminEntity.class, 1_001L); // // 検証 // assertPropertyEqualsMethodOnly(new String[] { "adAdminGroupEntitySet.size=2", "adAdminGroupEntitySet[0].adminGroupId=admin_group1", "adAdminGroupEntitySet[0].adminGroupUid=1001", "adAdminGroupEntitySet[1].adminGroupId=admin_group2", "adAdminGroupEntitySet[1].adminGroupUid=1002", "adminId=admin1", "adminName=管理者1", "adminRole=DNP_MASTER", // …… // …… "passwordExpirationDate=2017/01/20 00:00:00.000", "passwordModifiedDate=2017/01/01 00:00:00.000", "passwordSalt=WGwRH5xZKjJ1Lrf2Z6w1", "phoneNumber=0330001234", "previousLoginDate=2017/01/01 00:30:00.000", "registeredDate=2017/01/01 00:00:00.000", "removedDate=2017/01/01 00:00:00.000", "useStopped=true", "useStoppedDate=2017/01/01 04:00:00.000", }, entity, new String[] { "adAdminGroupEntitySet[]" }, new String[] { "adAdminGroupEntitySet[].adminGroupUid", "adAdminGroupEntitySet[].adminGroupId" }); -
エンティティの単体テスト – フィールドとカラムのマッピング ◼ 課題(再掲) ◦ フィールド(カラム)が 50 個あったら 50
個アサートを書く必要がある ◦ 抜けがあっても気づきづらい ⇒ レビューも手間が掛かる ◦ カラム、フィールドが足されてもテストは失敗しない(検知漏れ?) ◦ assertEquals の失敗はひとつずつなので、直してまた実行を繰り返す必要がある ◦ これらは解決できたが、こちらはやはり面倒では? 解決できます ⇒ デモ -
エンティティの単体テスト – フィールドとカラムのマッピング ◼ コピー&ペーストでテストの意味はあるのか? ◦ やりきれないと人は手を抜く ◦ であれば、確認ミスのリスクがあっても、現実的にテスト可能な方が良い ◦
単体テストを作れば、リグレッションテストが可能になる ◼ それでもフィールドが50個あったら大変では? IDEの比較結果からコピー&ペーストできます! ただし、省力化はできるが、結果を人の目で確認する必要はある -
テストライブラリ – Traverser と ObjectAssert ◼ Traverser と ObjectAssert による文字列比較は、単体テストの基盤を支える
ツールになっている ◦ 単体テストを同じルール(同じアサートの範囲)で容易に記述可能 • 退行に対する保護にもつながる ◦ テスト失敗時の比較も、直感的に分かりやすい ◦ 文字列化を工夫することでさらに便利に利用できる • コントローラー層のアサート(後述)や、結合テスト自動化でも利用している -
ロジックの説明 ◼ ロジックは Command Oriented Interface (※1)がベース ◦ ただし、authorize(認証)、validate(検証)、execute(実行)のメソッドを持つ ◦
メリット、デメリットがあるが詳細は省略 ※1 引用 : martinFowler.com.「Command Oriented Interface」. https://martinfowler.com/bliki/CommandOrientedInterface.html UpdateAdAdmin updateAdAdmin = …; updateAdAdmin.setAdminUid(startForm.getAdminUid()); updateAdAdmin.setAdminName(inputForm.getAdminName()); updateAdAdmin.setAdminType(inputForm.getAdminType()); updateAdAdmin.setAdminRole(inputForm.getAdminRole()); updateAdAdmin.setPassword(inputForm.getPassword()); // …… updateAdAdmin.execute(); AdAdmin admin = updateAdAdmin.getAdAdmin(); public interface UpdateAdAdmin { void setAdminUid(String adminUid); void setAdminName(String adminName); // …… void authorize(); void validate(); void execute(); // …… AdAdmin getAdAdmin(); boolean isMailAddressChanged(); } コントローラーからの呼び出し例 Command Oriented Interface の例 ロジックの引数 ロジックの戻り値 ロジックの実行 -
ロジックのテスト観点 認可処理 入力画面表示 完了 画面 確認 画面 入力 画面 public
class XxxLogicImpl implements XxxLogic { // …… public void authorize() { // 認可 // …… } public void validate() { authorize(); // 入力値検証 // …… } void execute() { validate(); // 実行 // …… } // …… } 入力値検証 確認画面表示 ロジック実行 完了画面表示 Controller ◼ authorize で正しく認可処理が行わ れていること ◦ 例 : 更新権限のないオブジェクトに 対する更新ロジックの呼び出しでエ ラーとなること ◼ validate で入力値エラーとなること ◦ ロジックでの独自の入力値チェック は境界値テストまで行う ◦ エンティティ層のチェックメソッドで検 証しているものは、境界値テストまで は行わないが、チェックメソッドの呼 び出しを確認するためにエラーは発 生させる -
public class XxxLogicImpl implements XxxLogic { // …… public void
authorize() { // 認可 // …… } public void validate() { authorize(); // 入力値検証 // …… } void execute() { validate(); // 実行 // …… } // …… } ロジックのテスト観点 ◼ execute で実行しているビジネスロ ジックが正しいこと ◦ 検証は、ロジックの入力値に対する 戻り値とデータベースの値の検証 ◼ execute から validate、 validate から authorize が 呼び出されていること 認可処理 入力画面表示 完了 画面 確認 画面 入力 画面 入力値検証 確認画面表示 ロジック実行 完了画面表示 Controller -
ロジックの単体テスト ◼ なぜ validate で境界値テストを省略できるのか? ◼ 「チェックメソッドで境界値テストを行う」というルールが決まっているから省略可 ⇒ どのレイヤーで何を担保するかのルール(テスト観点)が重要 public
void validate() { // …… errors.addAll(AdAdminEntity.checkAdminName(adminName)); errors.addAll(AdAdminEntity.checkAdminType(adminType)); errors.addAll(AdAdminEntity.checkAdminRole(adminRole)); // …… public static … checkAdminName(String value) { // …… } public static … checkAdminType(String value) { // …… } エンティティ層のチェックメソッド ロジックの validate メソッド これらのメソッドはエンティティ層のテストで 境界値テストまで行うルールになっている。 validate メソッドのテストは、エンティティ層の チェックメソッドの呼び出し確認まで行うルール になっている。 これらのルールは両方そろって成り立つ -
ロジックの単体テスト ◼ データベースの中身まで検証する必要はある? ◦ エンティティのマッピングを確認しているのであれば、エンティティを確認すれば十分 では? ◼ トランザクションがコミットされていることを確認するにはDBを見る必要がある ◦ 複数トランザクションを扱うロジックで、途中のトランザクションまで確認したいケース
もある ◦ DBのアサートも ObjectAssert と同じようなツール(DbAssert)を提供しているので、 コピー&ペーストで期待値を用意できるため、最小限の手間で実装可能 -
ロジックの単体テスト ◼ データベースのアサートの範囲は対象のレコードの全カラム ◼ アサートの範囲が広すぎるのでは? ◼ 極論を言うとDB全体のアサートが必要 ◦ 同じレコードの、他のカラムを更新していない保証はない ◦
同じテーブルの、他のレコードを更新していない保証もない ◦ 他テーブルのレコードを更新していない保証もない ◼ 現実的に登録・更新対象のレコードの全カラムをアサート対象としている -
ブラックボックステストとホワイトボックステスト ◼ 単体テストはブラックボックステスト?ホワイトボックステスト? ◼ TDD等の考え方ではあくまでブラックボックステスト ◦ 対象のコンポーネント単位でのブラックボックステスト ◼ 実際はホワイトボックステストになっている ◦
validate の境界値テストの例では、validate がチェックメソッドを呼んでいるのはコードレビューで担保し ている ◦ レビュー時にはカバレッジを確認してテストの漏れがないか確認している ここで心に留めておいてほしいのは、テストを分析する際は、ホワイト・ ボックス・テストの手法も用いることができる、ということです。 例えば、コード網羅率を計測するツールを用いて、どの経路がまだ検 証されていないのかを見つけ出し、そのあとに、あたかもそのことを まったく知らなかったかのように、その見つけた未検証の経路を経由 するテスト・ケースを作成するのです。 ※カバレッジに関してはブラックボックステストと考えることができる 引用 : Vladimir Khorikov.「単体テストの考え方/使い方」.マイナビ出版.2022年,416p ホワイトボックステストを書きたくなるのは、テストの問題 ではなく、設計の問題だ。コードがきちんと動いているか どうかを変数を使って確かめたくなるときは、設計を改善 する機会であると私は考えている。 ※ホワイトボックステストになるのは設計の問題? 引用 : KentBeck.「テスト駆動開発」.オーム社.2017年,344p -
単体テストにおけるデータベースの扱い ◼ データは毎回初期化 ◦ DbUnit でテストクラスまたはテストメソッドごとに初期化する ◦ シーケンスも毎回リセットする ◼ トランザクションも空振りさせずにコミットする
◼ メリット ◦ シーケンスで採番する主キーもアサートできる ◦ 分割トランザクションになるケースであっても、トランザクションも含めたロジックの検証を確実 に行うことができる ◼ デメリット ◦ テストの独立性は損なわれる(同時に複数テストを実行できない) ◦ データベースの初期化の時間がかかる -
単体テストにおけるデータベースの扱い ◼ 暗号化・復号化を自動化 ◦ DbUnit の import, export 時に、自動で暗号化・復号化を行うため、テストデータを平文で管 理できる
admin_uid admin_id admin_name …… 1001 admin1 K41h4HjGplSSAbuo GsX6TA== …… 1002 admin2 a/mzOP9enRtEg+Sz axtgfQ== …… …… …… <dataset> …… <AD_ADMIN_TBL ADMIN_UID="1001" ADMIN_ID="admin1" ADMIN_NAME="管理者1" …/> <AD_ADMIN_TBL ADMIN_UID="1002" ADMIN_ID="admin2" ADMIN_NAME="管理者2" …/> 平文に復号化して DbUnit 用の XML を export するツールを提供 XxxTestInit01.xml テスト用の初期化データ 編集 @Test public void executeSuccess01() throws Exception { // // 事前処理 // insert("XxxTestInit01.xml"); // 暗号化してDB投入 // …… 独自の DbUnit のフィルターを用意している (比較的実装は簡単) -
単体テストにおけるデータベースの扱い ◼ 暗号化・復号化を自動化 ◦ DbAssert でも DBから取得した値を復号化してアサートできるので、期待値を平文にできる admin_uid admin_id admin_name
…… 1001 admin1 K41h4HjGplSSAbuo GsX6TA== …… 1002 admin2 a/mzOP9enRtEg+Sz axtgfQ== …… …… …… getSession().beginTransaction(); AdAdminEntity entity = new AdAdminEntity(…); getSession().persist(entity); getSession().getTransaction().commit(); // 検証 assertDataEquals( new String[] { "[0].ADMIN_UID=1002", "[0].ADMIN_ID=admin2", "[0].ADMIN_NAME=管理者2", // …… }, "AD_ADMIN_TBL", "SELECT * FROM AD_ADMIN_TBL WHERE ADMIN_UID = " + entity.getAdminUid()); DBの値を復号化してアサートできる Import, export と同様に、独自の DbUnit のフィルターを利用している -
コントローラー層のテスト ◼ コントローラー層の構造 Controller Logic Template Form Holder Spring Web
MVC FreeMarker ① リクエスト ② Formを作成しリクエスト パラメータを設定 ③ 実行 ④ 参照 ⑤ ロジック実行 ⑥ Holder作成。 リクエストスコープに格納 ⑦ View返却 ⑧ forward ⑧ HTMLレンダリング ⑨ 参照 ⑩ レスポンス コントローラー層の主なテストスコープ -
コントローラー層のテスト ◼ 登録・更新系のフロー input Controller confirm execute complete 完了 画面
確認 画面 入力 画面 トランザクショントークンによる保護 CSRF対策トークンによる保護 前 画面 forward redirect / GET POST POST GET forward forward forward (エラー) forward (エラー) -
コントローラー層のテスト ◼ コントローラーのテストは実際に MVC フレームワークを動かして実行する ◦ 昔は Controller を POJO
としてテストを行っていた • 単体テストが通っても、ブラウザでの動作確認でエラーになることが多かった ◦ 若干結合テスト寄りになっているが、実際に MVC フレームワークで動かすことで • ServletFilter、Interceptor も確認可能 • テンプレートエンジンの正常終了まで確認可能 (デザインは実際にブラウザで確認する必要がある) -
コントローラー層のテスト ◼ ロジックにテストダブル(モック、スタブ)は使わない ◦ ロジックの変更時にテストダブルも漏れなく直さないといけない • テストダブルを利用していると、直さなくてもテストは失敗しない • 実物を利用していれば、単体テストが失敗する ⇒少なくとも検知漏れ(偽陰性)は防げる
• テストが失敗する箇所は、誤検知(偽陽性)で期待値修正だけで済むケースも多いが、少 なくとも影響がないか確認した方が良い箇所という考え方 ◦ ロジックの実装が間に合わなくて開発が進まないことはないのか? • 基本的に機能単位で開発者に割り当てるため、同一の開発者がロジック→コントローラー の順で実装するので問題にならない (COI は1ロジックごとにファイルが分かれるため、機能単位の開発と相性が良い) -
コントローラー層のテスト ◼ テストコード例(アサートの前まで) // ログイン loginAdmin("admin_master"); // セッション UpdateAdminStartForm startForm
= new UpdateAdminStartForm(); startForm.setAdminUid("1008"); setAttributeToSession(UpdateAdminStartForm.ID, startForm); UpdateAdminInputForm inputForm = new UpdateAdminInputForm(); inputForm.setAdminName("管理者名"); inputForm.setAdminType(CoAdminType.DNP_PROJECT.getId()); inputForm.setAdminRole(CoAdminRole.DNP_PROJECT.getId()); inputForm.setPassword("123abc123abc!!"); inputForm.setPasswordConfirm("123abc123abc!!"); inputForm.setMailAddress("[email protected]"); inputForm.setMailAddressConfirm("[email protected]"); // …… setAttributeToSession(UpdateAdminInputForm.ID, inputForm); // リクエストパラメータ addPostParameters(new String[][] { {"adminName", "DNP"}, {"adminType", "DNP_PROJECT"}, {"adminRole", "DNP_PROJECT"}, {"password", "!123abc123abc!"}, {"passwordConfirm", "!123abc123abc!"}, {"mailAddress", "[email protected]"}, {"mailAddressConfirm", "[email protected]"}, {"adminGroupUid", "1001"}, // …… }); // ワークフロートークン createWorkflowTokenAndSetToSessionAndParam( UpdateAdminAction.WORKFLOW_NAME); // 実行 doPost("/admin/UpdateAdminConfirm.action"); -
コントローラー層のテスト ◼ テスト観点 ◦ Form の入力値をロジックに正しく渡せていること ◦ ロジックの authorize, validate,
execute を正しく呼び出していること ◦ 以下の値が正しいこと • HTTPステータス • forward するテンプレートのパス • redirect の URL • リクエストスコープのオブジェクト Holder エラー情報 • HTTPセッションのオブジェクト Form, State CSRF対策トークン トランザクショントークン これらは input, confirm 等のメソッド、 正常系かエラー系かにより、 何を検証すべきかが変わる 過不足なく実装するのも難しいが、 レビューも手間がかかる -
コントローラー層のテスト ◼ リクエスト、セッションなど、まとめて確認する仕組みを提供 ◦ アサートが必要な値をすべて保持するクラスを用意し、Traverser, ObjectAssert で まとめてアサート ◼ デメリット
◦ 余分なアサートが行われる(誤検知(偽陽性)が起きやすい) ◼ メリット ◦ アサートの漏れがない(検知漏れ(偽陰性)が起きにくい) ◦ テストコードの記述が容易 ◦ レビューも容易 -
コントローラー層のテスト // 検証 String[] expected = { "101_Status=200", "102_ViewName=/admin/update_admin_confirm", "103_View=[null]",
"104_RedirectUrl=", "105_ResponseCookies.size=0", "201_Msg_Infos.size=0", "202_Msg_Warnings.size=0", "203_Msg_Errors.size=0", "301_Session=exists", "302_SessionAttr.size=2", "302_SessionAttr{...web.backend.admin.UpdateAdminInputForm}.adminGroupUid.size=2", "302_SessionAttr{...web.backend.admin.UpdateAdminInputForm}.adminGroupUid[0]=1001", // …… "401_RequestAttr.size=1", "401_RequestAttr{holder}.adAdminGroupHolderList.size=4", // …… "501_Workflow.1_FirstTime=false", "501_Workflow.2_Phase=CONTINUE", "501_Workflow.3_TokenName=updateAdmin", // …… }; assertHttpResultEquals(expected, new String[]{}, new String[]{}); -
まとめ ◼ 単体テストに対する考え方 ◦ ① 多少誤検知(偽陽性)が発生したとしても、検知漏れ(偽陰性)の発生を抑える ◦ ② 単体テストの記述、レビューを効率的に行えるようにする 3
1 保守のしやすさ 4 迅速なフィードバック 2 リファクタリングへの耐性 (誤検知が発生しない) 退行に対する保護 (検知漏れが発生しない) -
まとめ ◼ ① 検知漏れ(偽陰性)の発生を抑えるために ◦ 誤検知(偽陽性)は増えるが、アサートの範囲を広めにしている • エンティティ、テーブル等、直接更新対象でないフィールド、カラムも検証する • コントローラーのテストでは、漏れをなくすために一律同じアサートを行っている
◦ テストダブル(モック、スタブ)の利用は最小限にし、なるべく実際の動作に近い状態 でテストする • スタブは、外接先等の単体テストでは利用できないもののみ利用する • 各レイヤー(コントローラー層から見たロジック等)はもちろん、データベース等も実際にア クセスしてテストしている • コントローラー層も実際に MVC フレームワークを動作させてテストしている -
まとめ ◼ ② 単体テストの記述、レビューを効率化するために ◦ レイヤーごとのテスト観点を定義し、重複したテストは行わない • ロジック層ではエンティティ層で定義されたチェックメソッドの境界値テストは行わない • コントローラー層ではロジックの正しさ(機能仕様)まではテストしない
◦ テスト用ライブラリ、テスト基盤を整備する • テスト自体の記述を容易にする • レビューも効率的に行えるようにする -
アンケートのお願い ◼ あなたの開発組織での単体テストはどのように行っていますか? (1~4 は重複回答可) ◦ 1. TDDやってるぜ。 ( ̄▽ ̄)ドヤッ ◦
2. 単体テストの観点やルールを決めている。 ◦ 3. C0=90%, C1=80% 等のカバレッジの目標を決めている。 ◦ 4. カバレッジを見て、テスト不可以外のライン、分岐を網羅している。 ◦ 5. 特にルールはなく、開発者任せ。 ◦ 6. そもそもプログラムによる単体テストをやってない。((((;゚Д゚))))