テスト
Swift
ソフトウェアテスト

『はじめて学ぶソフトウェアのテスト技法』を読み解く - 「同値クラステスト」(Swiftのサンプルコード付き)

はじめに

『はじめて学ぶソフトウェアのテスト技法』を読んでいます。
このQiitaは本の内容をもとに、自分の思考整理と、理解促進のために書いています。

技術書に出てくる概念的な説明は、とにかく理解が難しいです。
読み進めているとだんだんとページの上を視線がすべるだけになっていって理解できない、なんてことはよくあります。
なので理解を深めるために、噛み砕き、自分の言葉でアウトプットし直していこうと思いました。そんなQiitaです。

同値クラステスト

同値クラステストはテスト設計のテクニックです。
パターンの決まった限られた範囲に分類されるテスト対象を、範囲内の全てのデータをテストするのではなく、有効ないくつかのデータのみをテストすることで、テストケースを減らすテクニックです。

例えば、人材管理システムの、応募者の年齢にもとづいて雇用するかどうかを判定するモジュールを例にとります。仕様は以下です。

  • 0歳〜16歳 : 雇用しない
  • 17歳〜18歳: パートタイムのみで雇用
  • 19歳〜56歳: 正社員として雇用
  • 57歳〜99歳: 雇用しない

これの判定ロジックをコードに落としこむと次のようなコードになります。

if applicantAge >= 0  && applicantAge <= 16 { hireStatus = "雇用せず" }
if applicantAge >= 17 && applicantAge <= 18 { hireStatus = "パート" }
if applicantAge >= 19 && applicantAge <= 56 { hireStatus = "正社員" }
if applicantAge >= 57 && applicantAge <= 99 { hireStatus = "雇用せず" }

このような決まった範囲の集合を同値クラスと呼び、テストの観点からは同じ範囲内にあるデータを、同じデータとみなします。
だからこのロジックをテストしようとした時、 0, 1, 2, ..., 97, 98, 99 すべてのデータをテストする必要はなくて、それぞれ4区分の中にあるデータを1つずつテストすることで動作を担保できます。もし
このシステムの場合は、テストケースの数でみると、100個からたったの4個に減らせます。

無効同値クラス

本には書いてありませんが、無効なデータにも同値クラスが存在します。それを無効同値クラスといいます。今回の例では以下の2つの無効同値クラスが存在します。

  • 0歳未満
  • 100歳以上

コードで書いてみる

ためしにSwiftで書いてみるとこんな感じです。
4パターンの同値クラスと2パターンの無効同値クラスのデータのみをテストしていますが、各範囲でそのデータを返すことが保証できているので必要十分です。

class HumanResourceManager {
    var hireStatus: String? = nil

    func examineHireStatus(applicantAge: Int) {
        if applicantAge <= -1 { hireStatus = nil }
        if applicantAge >= 0  && applicantAge <= 16 { hireStatus = "雇用せず" }
        if applicantAge >= 17 && applicantAge <= 18 { hireStatus = "パート" }
        if applicantAge >= 19 && applicantAge <= 56 { hireStatus = "正社員" }
        if applicantAge >= 57 && applicantAge <= 99 { hireStatus = "雇用せず" }
        if applicantAge >= 100 { hireStatus = nil }
    }
}
import XCTest
@testable import HumanResource

class HumanResourceTests: XCTestCase {
    func testExamineHireStatus() {
        let humanResourceManager = HumanResourceManager()

        // 初期状態の確認
        XCTAssertNil(humanResourceManager.hireStatus)

        // 0未満
        humanResourceManager.examineHireStatus(applicantAge: -5)
        XCTAssertNil(humanResourceManager.hireStatus)

        // 0〜16
        humanResourceManager.examineHireStatus(applicantAge: 4)
        XCTAssertEqual("雇用せず", humanResourceManager.hireStatus)

        // 17〜18
        humanResourceManager.examineHireStatus(applicantAge: 17)
        XCTAssertEqual("パート", humanResourceManager.hireStatus)

        // 19〜56
        humanResourceManager.examineHireStatus(applicantAge: 35)
        XCTAssertEqual("正社員", humanResourceManager.hireStatus)

        // 57〜99
        humanResourceManager.examineHireStatus(applicantAge: 88)
        XCTAssertEqual("雇用せず", humanResourceManager.hireStatus)

        // 100以上
        humanResourceManager.examineHireStatus(applicantAge: 120)
        XCTAssertNil(humanResourceManager.hireStatus)
    }
}

発展: 雇用の仕組みを型で解決する

ここからは発展系です。上記のコードはあまりSwiftらしいとはいえないサンプルコードなので、Swiftらしく型で解決させてみようと思います。

このモジュールを紐解くと以下の二つのことを行なっていることがわかります。

  • 雇用するか、しないか
  • 雇用する場合はどんな雇用形態をとるか

雇用するか、しないかという限られたパターンにはenumが使えます。

enum EmploymentStatus {
    case employ
    case notEmploy
}

このenumを使って、まず雇用するかしないかを判定するようにさっきのモジュールを直すと、以下のように書けます。

enum EmploymentStatus {
    case employ
    case notEmploy

    init?(applicantAge: Int) {
        switch applicantAge {
        case 0...16:
            self = .notEmploy
        case 17...18:
            self = .employ
        case 19...56:
            self = .employ
        case 57...99:
            self = .notEmploy
        default:
            // 0歳未満と100歳以上の無効同値クラス
            return nil
        }
    }
}
import XCTest
@testable import HumanResource

class HumanResourceTests: XCTestCase {
    func testExamineEmploymentStatus() {
        // 0未満
        XCTAssertNil(EmploymentStatus(applicantAge: -3))

        // 0〜16
        XCTAssertEqual(.notEmploy, EmploymentStatus(applicantAge: 4))

        // 17〜18
        XCTAssertEqual(.employ, EmploymentStatus(applicantAge: 17))

        // 19〜56
        XCTAssertEqual(.employ, EmploymentStatus(applicantAge: 35))

        // 57〜99
        XCTAssertEqual(.notEmploy, EmploymentStatus(applicantAge: 88))

        // 100以上
        XCTAssertNil(EmploymentStatus(applicantAge: 120))
    }
}

これで雇用するか、しないかというパターンを型で表現できました。
String型で表現してしまうと、思わぬ文字列がどこかで入ってきてしまって、バグの原因になりがちなので、できるだけ型でデータを縛っていくとテストしやすく、より良い設計になります。

また、0歳未満と100歳無効同値クラスはnilで表現することで、異常値として扱えます。
Swift(静的型付け言語)の場合、StringやDoubleなどの想定外の入力データが入り込むことをコンパイル時に制限できるため、テストケースを減らせるという利点があります。
(動的型付け言語の場合、想定外の入力データだった場合に例外を返すかというテストケースが必要になります)

同じように雇用形態もまた、enumが使えます。

enum EmploymentPattern {
    case partTime // パート
    case fullTime // 正社員
}

そしてこの EmploymentPatternEmploymentStatus.employ のAssociated Valueとして定義すると、雇用する場合はどんな雇用形態をとるかを型で表現できます。

enum EmploymentStatus {
    case employ(EmploymentPattern)
    case notEmploy

    init?(applicantAge: Int) {
        switch applicantAge {
        case 0...16:
            self = .notEmploy
        case 17...18:
            self = .employ(.partTime)
        case 19...56:
            self = .employ(.fullTime)
        case 57...99:
            self = .notEmploy
        default:
            // 0歳未満と100歳以上の無効同値クラス
            return nil
        }
    }
}

ただしこれを XCTAssertEqual で比較するには、 .employ のAssociated Value同士が正しいことを判定するロジックが必要になるので、 Equatable protocolに準拠させます。

enum EmploymentStatus: Equatable {
    case employ(EmploymentPattern)
    case notEmploy

    init(applicantAge: Int) {
        switch applicantAge {
        case 0...16:
            self = .notEmploy
        case 17...18:
            self = .employ(.partTime)
        case 19...56:
            self = .employ(.fullTime)
        case 57...99:
            self = .notEmploy
        default:
            // 0歳未満と100歳以上の無効同値クラス
            return nil
        }
    }

    static func ==(lhs: EmploymentStatus, rhs: EmploymentStatus) -> Bool {
        switch (lhs, rhs) {
        case (.employ(let lhsValue), .employ(let rhsValue)) where lhsValue == rhsValue:
            return true
        case (.notEmploy, .notEmploy):
            return true
        default:
            return false
        }
    }
}

そしてテストはこうやって比較できる。

import XCTest
@testable import HumanResource

class HumanResourceTests: XCTestCase {
    func testExamineEmploymentStatus() {
        // 0未満
        XCTAssertNil(EmploymentStatus(applicantAge: -3))

        // 0〜16
        XCTAssertEqual(.notEmploy, EmploymentStatus(applicantAge: 4))

        // 17〜18
        XCTAssertEqual(.employ(.partTime), EmploymentStatus(applicantAge: 17))

        // 19〜56
        XCTAssertEqual(.employ(.fullTime), EmploymentStatus(applicantAge: 35))

        // 57〜99
        XCTAssertEqual(.notEmploy, EmploymentStatus(applicantAge: 88))

        // 100以上
        XCTAssertNil(EmploymentStatus(applicantAge: 120))
    }
}

まとめ

同値クラスはとてもよく使うパターンで、このテクニックを使うことで、必要なテストケースを減らせます。また、どんなテストケースが必要になるかという観察眼を養う第一歩になる気がするので、人にテストの具体的な方法を説明するときは同値クラステストから始めるとよいかなと思いました。

同値クラスの考え方をもとにして、隣り合った同値クラスの境界をテストする境界値テストについても、また今度まとめます。

お遊びの発展系として、型で制約を加えることでバグを抑えるテクニックについても紹介しました。
同値クラスの考えととても親和性が高いので、参考にしていただければ幸いです。