テストケースの作り方|仕様から正常系・境界値・異常系を出す例

広告 設計・テスト・レビュー

テストケースの作り方|仕様・境界値・期待結果をJUnitと対応させる実例

「問い合わせの対応開始をテストしておいて」と言われ、担当者が一度操作できることだけを確認した。レビューで「投稿者が操作したら? 完了済みだったら? 拒否した後の状態は?」と聞かれ、ケースを足すたびに終わりが見えなくなる。そんなときは、テストコードを書く前に仕様の条件と、その条件で守る結果を対応させます。

この記事でわかること

  • 件名1〜100文字という仕様から、正常系・異常系・境界値を選ぶ理由。
  • 権限と状態を変えるケースで、入力・事前条件・操作・期待結果を揃える方法。
  • 仕様の1行をJUnitのassertionへ移し、失敗から元の条件へ戻る方法。

題材は架空の問い合わせ管理です。件名を受け付ける判定と、問い合わせの対応を開始する操作を、小さなJavaクラスにしています。画面やSpringを起動せず、ケースの選び方に集中できます。

仕様・ケース表・テスト対象・JUnit全文をまとめて取得

Java 17、Maven、JUnit 5.12.2を使用します。ZIPにはdocs/spec.md、docs/cases.csv、対象クラス2個、テストクラス3個、pom.xml、READMEを収録。この記事とParameterizedTestの記事は、同じ固定版のプロジェクトを使います。

「必須・100文字まで」を、許可と拒否が決まる仕様にする

最初の要求が「件名は必須で100文字まで。担当者が対応開始できる」だけなら、まだ期待値を書けません。空白だけは入力済みか、絵文字は何文字か、完了済みでも開始できるかが決まっていないからです。「たぶんこうだろう」をテストの正解にすると、実装者と確認者の推測が一致しただけで通過してしまいます。

ここでは、次のように仕様を具体化しました。業務システム全般の正解ではなく、配布コードが実装する練習用の約束です。仕様IDも配布ファイルと揃えています。

仕様ID確定した約束期待結果として観察するもの
TITLE-01〜03null・空文字・空白だけを拒否。前後空白を削除せず、Unicodeコードポイントで1〜100を許可する。TitleRule.acceptsのtrue/false
START-01現在の担当者が、受付済みの問い合わせで対応開始できる。投稿者であることだけでは許可しない。Inquiry.startのtrueと、状態が対応中になること
START-02担当者不一致・未設定、または受付済み以外なら拒否する。状態・投稿者・担当者を変えない。falseと、操作前の各値が残ること

「入力が間違っていたらエラー」という表現では、戻り値、例外、画面メッセージのどれを確かめるかがわかりません。今回はbooleanとオブジェクトの状態を契約にしました。HTTPステータスやDBの更新件数は、このサンプルで返したり保存したりしていません。実際のAPIや画面を試すケースでは、その境界で別の期待結果を決めます。

期待値の出所はspec.md、ケース一覧はcases.csvです。CSVの期待値を実装の実行結果から作ることはしていません。仕様から手で決めた値と、実際に返った値をJUnitで比較します。

null・空文字・空白だけを、同じ「未入力」にまとめない

入力の全パターンを書き出す代わりに、同じ扱いになるはずのまとまりを見つけ、代表を選びます。これが同値分割です。たとえば100文字以内の普通の件名は許可するまとまりですが、拒否する入力にも種類があります。

nullは参照先がなく、空文字は文字列が存在して長さ0、空白だけの文字列は長さがあっても内容がありません。isEmpty()だけの実装なら半角空白を見逃し、nullの確認前にメソッドを呼ぶ実装なら例外になります。結果が同じfalseでも、落としやすい条件が違うため、T01・T02・T08・T09として分けます。

正常系も「aが通った」だけでは不十分です。普通の入力を一つ選ぶ目的と、上限・下限の比較演算子を確かめる目的は別です。T03の1文字を正常な代表として使いつつ、次の境界の観点も追加します。分類名を付けることより、このケースがなければどんな間違いを見逃すかを言えることを重視してください。

0・1・2と99・100・101で、境界を1つずらした実装を見つける

許可する長さが1〜100なら、下限と上限、そのすぐ外側・内側を選びます。今回は0・1・2、99・100・101の6値です。境界と両隣を選ぶ3値境界値分析に対応します。0文字は空文字のT02と同じ実行なので、目的が二つあっても別件として水増ししません。

ケースID入力期待結果選ぶ理由
T01null拒否(false)値なし
T02空文字拒否(false)空文字/下限の外
T03a許可(true)下限ちょうど
T04aa許可(true)下限の内側
T05a×99許可(true)上限の内側
T06a×100許可(true)上限ちょうど
T07a×101拒否(false)上限の外
T08半角空白1文字拒否(false)空白だけ
T09全角空白1文字拒否(false)空白だけ
T10😀×100許可(true)UTF-16の長さとの差
T11😀×101拒否(false)同じ数え方で上限外
T12a×99+半角空白許可(true)空白を削除せず100
T13a×100+半角空白拒否(false)空白を削除せず101

T06の100文字はtrue、T07の101文字はfalseです。この組み合わせなら、実装が< 100になって上限ちょうどを拒否する間違いと、<= 101になって上限外を許す間違いを区別できます。99文字だけを通しても、上限100という約束は確認できません。

文字数の単位も入力データに反映します。JavaのString.length()はUTF-16のコード単位数です。😀100個はコードポイントで100、length()では200になるので、T10は単純な半角文字だけでは見つからない数え間違いを検出します。コードポイントは見た目の一文字と常に一致せず、結合文字や複合絵文字を一文字と数える要件には、そのまま流用できません。

T12とT13は、前後空白を削除しない条件の確認です。a99個+空白は100で許可、100個+空白は101で拒否します。「空白だけは拒否する」からといって、入力の前後を常にtrimしてよいとは限りません。trimする実装に変わるとT13の期待結果と食い違います。

この表を実際に判定する対象は次の全文です。各テストはこのTitleRuleを呼びます。

package info.syachiku.testing;

public final class TitleRule {
    private TitleRule() {}

    public static boolean accepts(String title) {
        if (title == null || title.isBlank()) return false;
        int length = title.codePointCount(0, title.length());
        return length >= 1 && length <= 100;
    }
}

担当者と現在の状態を、片方ずつ変えて比較する

対応開始の条件は「誰が」と「どの状態で」の二つです。基準を、投稿者10・担当者20・受付済みの問い合わせに対し、利用者20が操作する成功ケースA01に置きます。比較するケースごとに新しいInquiryを作り、前のテストの状態を引き継がせません。

A02〜A04では受付済みを固定し、操作する人、または担当者の有無だけを変えます。A05〜A07では担当者20による操作を固定し、開始前の状態だけを変えます。最初から両方を変えると、どちらの制約で拒否されたかを説明しにくくなるためです。

ID担当/操作する人操作前戻り値/操作後
A01担当20/利用者20受付済みtrue/対応中
A02担当20/利用者10受付済みfalse/受付済み
A03担当20/利用者30受付済みfalse/受付済み
A04担当未設定/利用者20受付済みfalse/受付済み
A05担当20/利用者20下書きfalse/下書き
A06担当20/利用者20対応中false/対応中
A07担当20/利用者20完了false/完了

投稿者10を試すA02は、単に「担当者以外を適当に1人」より意図が明確です。問い合わせを作った本人にも操作権限がある、と誤解した実装を見つけたいケースです。未設定のA04も、担当者がいないなら誰でも始めてよいという仕様にはしません。

この7件は、権限と状態の条件を分けて確認する基本セットです。4状態×3人の全組み合わせや、同時更新まで網羅した表ではありません。実装に「投稿者だけ下書きから開始できる」など条件同士の組み合わせがあれば、許可・拒否の組み合わせを表にして追加します。代表ケースでよい根拠を失ったまま、件数だけを少なくしないでください。

対応開始のテスト対象 Inquiry.javaの全文を開く
package info.syachiku.testing;

public final class Inquiry {
    public enum Status { DRAFT, RECEIVED, IN_PROGRESS, COMPLETED }

    private final long creatorId;
    private final Long assigneeId;
    private Status status;

    public Inquiry(long creatorId, Long assigneeId, Status status) {
        this.creatorId = creatorId;
        this.assigneeId = assigneeId;
        this.status = java.util.Objects.requireNonNull(status);
    }

    public boolean start(long actorId) {
        if (assigneeId == null || assigneeId.longValue() != actorId
                || status != Status.RECEIVED) return false;
        status = Status.IN_PROGRESS;
        return true;
    }

    public Status status() { return status; }
    public long creatorId() { return creatorId; }
    public Long assigneeId() { return assigneeId; }
}

startは、条件を満たさなければ状態代入の前にfalseを返します。担当者が一致し、かつ受付済みのときだけIN_PROGRESSへ変えます。投稿者IDは保持しますが、投稿者という属性だけで許可する分岐はありません。

表の期待結果を、戻り値と「変わらない状態」のassertionへ移す

ケース表が具体的なら、テストの準備・操作・確認にそのまま対応します。A02なら「投稿者10、担当者20、受付済み」を用意し、start(10)を一度呼び、falseと受付済みのままであることを確認します。

    @Test void A02_start() {
        Inquiry inquiry = new Inquiry(10, 20L, RECEIVED);
        assertEquals(false, inquiry.start(10), "A02: acceptance");
        assertEquals(RECEIVED, inquiry.status(), "A02: status");
        assertEquals(10L, inquiry.creatorId(), "A02: creator unchanged");
        assertEquals(Long.valueOf(20), inquiry.assigneeId(), "A02: assignee unchanged");
    }

最初のassertionは受付可否、次は操作後の状態です。さらに投稿者と担当者が書き換わらないことも確認しています。falseさえ返れば合格にすると、拒否する前に状態を更新してしまう実装を見逃します。エラー表示だけが正しく、データが壊れたままになる事故を防ぐには、何が変わるかだけでなく何が残るかも期待結果へ書きます。

今回の観察対象はメモリ上のInquiryです。DBへつなぐ場合に同じことを保証するには、拒否後にDBを読み直す、必要な保存処理が呼ばれていないことを確かめるなど、実際の保存境界での検証が必要です。このテストの通過を、DBやHTTPの安全性まで確認した結果に広げません。

件名の上限も、表のT06・T07をそのまま二つのテストへ移せます。100と101、trueとfalseの組み合わせを仕様から固定し、現在の実装が返す値を期待値に使わないことが要点です。

    @Test void T06_title() { assertEquals(true, TitleRule.accepts("a".repeat(100)), "T06"); }
    @Test void T07_title() { assertEquals(false, TitleRule.accepts("a".repeat(101)), "T07"); }
20ケース分のCaseDesignTest.javaの全文を開く
package info.syachiku.testing;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import static info.syachiku.testing.Inquiry.Status.*;

class CaseDesignTest {
    @Test void T01_title() { assertEquals(false, TitleRule.accepts(null), "T01"); }
    @Test void T02_title() { assertEquals(false, TitleRule.accepts(""), "T02"); }
    @Test void T03_title() { assertEquals(true, TitleRule.accepts("a"), "T03"); }
    @Test void T04_title() { assertEquals(true, TitleRule.accepts("aa"), "T04"); }
    @Test void T05_title() { assertEquals(true, TitleRule.accepts("a".repeat(99)), "T05"); }
    @Test void T06_title() { assertEquals(true, TitleRule.accepts("a".repeat(100)), "T06"); }
    @Test void T07_title() { assertEquals(false, TitleRule.accepts("a".repeat(101)), "T07"); }
    @Test void T08_title() { assertEquals(false, TitleRule.accepts(" "), "T08"); }
    @Test void T09_title() { assertEquals(false, TitleRule.accepts("\u3000"), "T09"); }
    @Test void T10_title() { assertEquals(true, TitleRule.accepts("😀".repeat(100)), "T10"); }
    @Test void T11_title() { assertEquals(false, TitleRule.accepts("😀".repeat(101)), "T11"); }
    @Test void T12_title() { assertEquals(true, TitleRule.accepts("a".repeat(99) + " "), "T12"); }
    @Test void T13_title() { assertEquals(false, TitleRule.accepts("a".repeat(100) + " "), "T13"); }
    @Test void A01_start() {
        Inquiry inquiry = new Inquiry(10, 20L, RECEIVED);
        assertEquals(true, inquiry.start(20), "A01: acceptance");
        assertEquals(IN_PROGRESS, inquiry.status(), "A01: status");
        assertEquals(10L, inquiry.creatorId(), "A01: creator unchanged");
        assertEquals(Long.valueOf(20), inquiry.assigneeId(), "A01: assignee unchanged");
    }
    @Test void A02_start() {
        Inquiry inquiry = new Inquiry(10, 20L, RECEIVED);
        assertEquals(false, inquiry.start(10), "A02: acceptance");
        assertEquals(RECEIVED, inquiry.status(), "A02: status");
        assertEquals(10L, inquiry.creatorId(), "A02: creator unchanged");
        assertEquals(Long.valueOf(20), inquiry.assigneeId(), "A02: assignee unchanged");
    }
    @Test void A03_start() {
        Inquiry inquiry = new Inquiry(10, 20L, RECEIVED);
        assertEquals(false, inquiry.start(30), "A03: acceptance");
        assertEquals(RECEIVED, inquiry.status(), "A03: status");
        assertEquals(10L, inquiry.creatorId(), "A03: creator unchanged");
        assertEquals(Long.valueOf(20), inquiry.assigneeId(), "A03: assignee unchanged");
    }
    @Test void A04_start() {
        Inquiry inquiry = new Inquiry(10, null, RECEIVED);
        assertEquals(false, inquiry.start(20), "A04: acceptance");
        assertEquals(RECEIVED, inquiry.status(), "A04: status");
        assertEquals(10L, inquiry.creatorId(), "A04: creator unchanged");
        assertEquals(null, inquiry.assigneeId(), "A04: assignee unchanged");
    }
    @Test void A05_start() {
        Inquiry inquiry = new Inquiry(10, 20L, DRAFT);
        assertEquals(false, inquiry.start(20), "A05: acceptance");
        assertEquals(DRAFT, inquiry.status(), "A05: status");
        assertEquals(10L, inquiry.creatorId(), "A05: creator unchanged");
        assertEquals(Long.valueOf(20), inquiry.assigneeId(), "A05: assignee unchanged");
    }
    @Test void A06_start() {
        Inquiry inquiry = new Inquiry(10, 20L, IN_PROGRESS);
        assertEquals(false, inquiry.start(20), "A06: acceptance");
        assertEquals(IN_PROGRESS, inquiry.status(), "A06: status");
        assertEquals(10L, inquiry.creatorId(), "A06: creator unchanged");
        assertEquals(Long.valueOf(20), inquiry.assigneeId(), "A06: assignee unchanged");
    }
    @Test void A07_start() {
        Inquiry inquiry = new Inquiry(10, 20L, COMPLETED);
        assertEquals(false, inquiry.start(20), "A07: acceptance");
        assertEquals(COMPLETED, inquiry.status(), "A07: status");
        assertEquals(10L, inquiry.creatorId(), "A07: creator unchanged");
        assertEquals(Long.valueOf(20), inquiry.assigneeId(), "A07: assignee unchanged");
    }
}

同じ確認を入力違いで何度も書く部分は、ケースの意味を決めた後でParameterizedTestへまとめられます。自動化のために必要なケースを削る順番にはしません。次の記事は、この表のT02〜T07を同じIDで実行する例です。

関連記事JUnitのParameterizedTest・CsvSourceの使い方|境界値と空文字を正しくテストする 選んだケースを繰り返し実行したい人へ。同じT02〜T07をCsvSourceへ移し、空文字や空白の渡し間違いも確認します。 記事を読む

20ケースを実行し、意図的な誤実装で検出できることも確かめる

ZIPを展開し、pom.xmlのあるtesting-casebookへ移動します。JUnitの導入設定も同梱しているので、別のプロジェクトから依存を推測して追加する必要はありません。

mvn -B -Dtest=CaseDesignTest test

件名13件と対応開始7件の計20件が、失敗0・エラー0・スキップ0で通過します。通常のmvn -B testでは、別記事のパラメータ化14回とCSV変換確認7回も含め、計41回です。同じ件名仕様を普通のテストとパラメータ化で再確認しており、41種類の独立した業務仕様があるわけではありません。

正しい実装で通るだけでなく、別のコピーで上限の<= 100を< 100へ変えました。T06・T10・T12が失敗し、「100コードポイントを受け付ける」という条件の破壊を検出できました。さらに担当者一致の確認を外すと、A02・A03が失敗します。配布する版は正しい条件に戻しています。

失敗した場合は、テスト名のIDからケース表の入力と根拠へ戻ります。たとえばA02なら、投稿者も許可する仕様へ正式に変えたのか、担当者の判定が壊れたのかを調べます。実際の結果に合わせてfalseをtrueへ直すだけでは、どちらを変更したのか不明なままです。

確認環境はJava 17.0.8、Maven 3.9.5、JUnit 5.12.2です。依存キャッシュがある環境で実行し、GitHubから取得し直したZIPでも再実行しています。依存を初めてダウンロードする空の環境や、別のJDK・OSでの実行は未確認です。

実行設定 pom.xmlの全文を開く
<?xml version="1.0" ?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>info.syachiku.samples</groupId>
  <artifactId>testing-casebook</artifactId>
  <version>1.0.0</version>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>5.12.2</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-resources-plugin</artifactId>
        <version>3.2.0</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.8.1</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.5.6</version>
      </plugin>
    </plugins>
  </build>
</project>

期待値が決まらないケースは、実装で補わず仕様確認へ戻す

「投稿者と担当者が同じ人なら」「完了後の再開は別の操作か」「見た目の一文字で数えたいか」。新しい問いが出たら、まず既存の約束だけで答えが決まるかを確認します。今回の許可条件は現在の担当者との一致なので、投稿者と同一でも担当者なら許可されます。一方、完了後の再開という別操作は定義していません。

要確認の行をtrueやfalseで埋めず、「何を入力し、誰が、どの状態で操作すると、何が起きるべきか」を仕様を決める人へ渡します。たとえば「結合文字を2コードポイントとして数えると、画面上の見た目と残り文字数がずれます。この数え方で合意できますか」と聞けば、判断の対象が明確になります。

レビューコメント例:「上限100を保証するT06と、上限外を拒否するT07の両方を残してください。A02はfalseだけでなく、受付済みが残るassertionまで必要です。」

返信も「境界値を追加しました」だけで終わらせず、「TITLE-02に対応する100/101文字をT06/T07へ追加し、START-02の状態不変をA02で確認しました」と、仕様・ケース・コードの対応を示すと、後から変更理由を追えます。

  • 仕様の根拠、入力、事前条件、操作、期待結果を揃えたか。
  • 正常・異常という名前だけでなく、その値を選ぶ理由を言えるか。
  • 境界の内側・ちょうど・外側と、null・空文字・空白を区別したか。
  • 権限と状態を変えるとき、比較の基準を固定したか。
  • 拒否時の戻り値と、変更されないデータの両方を確認したか。
  • 未決事項、未実行の経路、組み合わせの不足を残したか。

正常な値の次に何を試すか、仕様から考える力を補うなら『ソフトウェアテストの教科書[増補改訂 第2版]』を案内します。JUnitの構文とは別に、同値分割・境界値・条件の組み合わせを整理するための1冊です。

書籍「ソフトウェアテストの教科書[増補改訂 第2版]」の表紙
書影:SBクリエイティブ
PR テストを学ぶ若手向け

ソフトウェアテストの教科書[増補改訂 第2版]

布施昌弘・江添智之・永井努・三堀雅也 著

仕様から、試す入力と期待する結果を選べるようになる。

同値分割・境界値や条件の組合せなど、テストケースを選ぶ考え方を基礎から学ぶ1冊。何を試せばよいかわからない段階で選びます。

  • 正常な値を1件試した後、追加するケースに迷う
  • 入力と期待値を選んだ理由を説明したい

当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。

関連記事JUnit 5入門|Mavenで最初のテストを実行し、失敗を直す JUnitを初めて動かす人へ。Mavenの実行から、expectedとactualを読んで誤りを直すまでを試せます。 記事を読む
関連記事改修のテストはどこまでやる?単体・結合・手動の範囲を決める例 実際の改修でDBや画面も試すべきか迷ったら。今回の単体テストの外側へ、どこまで確認を広げるかを整理します。 記事を読む

ケースの数より、仕様から期待値を説明できる表を残す

テストケースは、思いついた入力の一覧ではありません。1〜100なら0・1・2と99・100・101を選び、担当者だけなら操作する人と状態を変え、拒否なら変わらないデータも確認します。理由とケースIDを残せば、仕様が変わったときにどの期待値とテストを直すかも辿れます。

まずは自分の改修の条件を一つ選び、仕様の1行・入力・事前条件・操作・期待結果を並べてください。埋められない欄があれば、それが実装前に確認すべき問いです。

技法の定義:ISTQB Foundation Levelシラバス4.0.1の4.2節。文字数の単位:Java 17 String.codePointCount。

-設計・テスト・レビュー
-, , , ,