あなたの既存のコードベースを更新してユニットテストに対応する


コンポーネント間の結合を排除することで、テストの適用範囲と信頼性を向上させます。





概観


既存のプロジェクトにユニットテストを追加するのは難しい場合があります。テスト容易性を考慮せずに設計の選択を行うと、異なるクラスやサブシステムが結合され、個別にテストできなくなる場合があるためです。あなたのソフトウェア内の 2 つのコンポーネントが密に結合している場合、一方のコンポーネントはもう一方のコンポーネントと特定の方法で統合されている場合にのみ正しく使用できます。この結合により、テストでネットワーク接続を試行したり、ファイルシステムとやり取りしたりすることになり、テスト速度が低下し、結果が決定されにくくなります。結合を解除するとユニットテストを導入できるようになりますが、テスト適用範囲が確保されていない箇所でコードを変更する必要があり、リスクを伴います。


テストしたいコンポーネントを特定することであなたのプロジェクトのテスト適用範囲を向上させるには、検証したい動作をカバーするテストケースを記述します。リスク重視のアプローチを用いて優先順位付けを行い、ユーザーからのバグ報告が多い機能や、回帰による影響が最も大きい機能の論理をカバーします。


テスト対象のコードがあなたのプロジェクトの他の部分やフレームワークのクラスと結合されている場合は、コードへの変更を最小限にとどめ、コンポーネントの動作を変えずに分離できるようにします。結合度を低くすることで、テストのコンテキストでクラスを使用できる能力を向上させ、変更を小さく抑えることで、各変更に伴うリスクを軽減します。


以下のセクションでは、対象コードと他のコンポーネント間の結合によってテストが妨げられる状況において、結合を取り除くための変更を提案します。それぞれの解決では、テスト関数が変更されたコードとどのように連携して動作をアサート (決定) するかを示します。



具体的な型をプロトコルに置き換える


あなたのコードが特定の型に依存しており、その動作によってテストが困難になる場合は、あなたのコードで使用されるメソッドとプロパティをリストしたプロトコルを作成してください。このアプローチは、あなた独自のコードベース内のコンポーネントや、プラットフォーム SDK やSwift パッケージを含めた、制御できない他のソースの API を操作する際に使用できます。このような問題のある依存関係の例としては、ユーザーのドキュメントやデータベースを含んだ外部状態にアクセスするものや、ネットワーク接続や乱数値ジェネレーターなど、結果が確定的でないものが挙げられます。


以下は、外部依存関係によって処理される添付ファイルを表すファイルを、opaque (不透明) サービスを使用して開くアプリ内のクラスを示しています。openAttachment(file:with:) メソッドの結果は、不透明サービスが要求された型のファイルを処理できるかどうか、そしてアプリケーションがファイルを正常に開けるかどうかによって異なります。これらの変数はすべてテストの失敗を引き起こす可能性があり、「エラー」を調査する際に、あなたのコードとは無関係な一時的な問題であることが判明するなど、開発の遅延につながります。


private enum AttachmentOpeningError: Error {
    case unableToOpenAttachment
}

struct AttachmentOpener {
  func openAttachment(file location: URL, with service: OpaqueService) throws {
    if (!service.open(location)) {
      throw AttachmentOpeningError.unableToOpenAttachment
    }
  }
}

このような結合を持つコードをテストするには、問題のある依存関係とあなたのコードがどのように相互作用するかを記述するプロトコルを導入します。あなたのコード内でそのプロトコルを使用することで、クラスはプロトコル内のメソッドの存在に依存しますが、そのメソッドの具体的な実装には依存しません。状態のあるタスクや決定的でないタスクを実行しない、プロトコルの代替実装を記述し、その実装を用いて制御された動作を持つテストを記述します。


以下のリストでは、open メソッドを含むプロトコルと、そのプロトコルに準拠する opaque クラスの拡張が定義されています。


protocol URLOpener {
    func open(_ file: URL) -> Bool
}

extension OpaqueService : URLOpener {}

struct AttachmentOpener {
    func openAttachment(file location: URL, with service: URLOpener) throws {
        if (!service.open(location)) {
            throw AttachmentOpeningError.unableToOpenAttachment
        }
    }
}

class StubService: URLOpener {
    var isSuccessful = true

    func open(_ file: URL) -> Bool {
        return isSuccessful
    }
}

テストでは、ユーザーのコンピューターにインストールされているアプリに依存しない URLOpener プロトコルの別の実装を記述します。


Swift TestingXCTest

@Suite struct AttachmentOpenerTests {
    var service = StubService()
    var attachmentOpener = AttachmentOpener()
    let location = URL(fileURLWithPath: "/tmp/a_file.txt")

    @Test("throws no error when open succeeds")
    func serviceCanOpenAttachment() {
        service.isSuccessful = true
        #expect(throws: Never.self) {
            try attachmentOpener.openAttachment(file: location, with: service)
        }
    }


    @Test("throws unableToOpenAttachment when open fails")
    func throwsIfServiceCannotOpenAttachment() {
        service.isSuccessful = false
        #expect(throws: AttachmentOpeningError.unableToOpenAttachment) {
            try attachmentOpener.openAttachment(file: location, with: service)
        }
    }
}


class AttachmentOpenerTests: XCTestCase {
    var service: StubService! = nil
    var attachmentOpener: AttachmentOpener! = nil
    let location = URL(fileURLWithPath: "/tmp/a_file.txt")

    override func setUp() {
        service = StubService()
        attachmentOpener = AttachmentOpener()
    }

    override func tearDown() {
        service = nil
        attachmentOpener = nil
    }

    func testServiceCanOpenAttachment() {
        service.isSuccessful = true
        XCTAssertNoThrow(try attachmentOpener.openAttachment(file: location, with: service))
    }


    func testThrowIfServiceCannotOpenAttachment() {
        service.isSuccessful = false
        XCTAssertThrowsError(try attachmentOpener.openAttachment(file: location, with: service))
    }
}


名前付き型をメタタイプ値に置き換える


あなたのアプリ内のあるクラスが、別のクラスのインスタンスを生成して使用し、生成されたオブジェクトがテスト上の問題を引き起こす場合、そのオブジェクトが生成されたクラスのテストが困難になる可能性があります。生成されたオブジェクトの型をパラメータ化し、必須のイニシャライザを使用してインスタンスを作成してください。このようなテストが困難な状況の例として、人のアクションに応じてファイルシステムに新しいドキュメントを作成するコントローラや、Web サービスから受信した JSON を解釈し、受信したデータを表す新しい Core Data 管理対象オブジェクトを作成するメソッドなどが挙げられます。


いずれの場合も、オブジェクトはあなたがテストを行いたいコードによって作成されるため、メソッドへのパラメータとして異なるオブジェクトを渡すことはできません。オブジェクトはあなたのコードによって作成されるまで存在せず、その時点ではテスト不可能な動作を行う型になります。


以下のリストは、例えば UI アクションへの応答として Document を作成してロードする DocumentLoader クラスを示しています。このクラスが作成するドキュメントオブジェクトはファイルシステムへのデータの読み取りと書き込みを行うため、ユニットテストでその動作を制御するのは容易ではありません。


enum DocumentError : Error {
    case cannotLoadContent
    case cannotSaveContent
}

class Document {
    private var location: URL
    private var titleContent: String?
    var title : String {
        get {
            return titleContent ?? "Untitled"
        }
        set {
            titleContent = newValue
        }
    }

    required init(fileURL: URL) {
        location = fileURL
    }
    
    func load() throws {
        do {
            let myString = try String(contentsOf: location, encoding: .utf8)
        }
        catch {
            throw DocumentError.cannotLoadContent
        }
    }
    
    func save() throws {
        do {
            try titleContent?.write(to: location, atomically: true, encoding: .utf8)
        }
        catch {
            throw DocumentError.cannotSaveContent
        }
    }
}

class DocumentLoader {
    func loadDocument(at location: URL) -> Bool {
        do {
            var document = Document(fileURL: location)
            try document.load()
            // Do something with the document, for example, present it in the app's UI.
            return true
        } catch {
            return false
        }
    }
}

テストしようとするコードとそれが生成するオブジェクト間の結合を取り除くには、テスト対象クラスに、構築すべきオブジェクトの を表す変数を定義します。このような変数は メタタイプ値 と呼ばれます。デフォルト値は、クラスが既に使用している型に設定します。インスタンス生成に使用されるイニシャライザが required (必須) としてマークされていることを確認してください。このリストは、その変数を導入したドキュメントブラウザのビューコントローラデリゲートを示しています。デリゲートは、メタタイプ値で定義された型でドキュメントを作成します。


class DocumentLoader {
    var DocumentClass< = Document.self

    func loadDocument(at location: URL) -> Bool {
        do {
            var document = DocumentClass.init(fileURL: location)
            try document.load()
            // Do something with the document, for example, present it in the app's UI.
            return true
        } catch {
            return false
        }
    }
}

テストではメタタイプに異なる値を設定すると、あなたのコードが同じテストできない動作を持たないオブジェクトを構築します。テストでは、ドキュメントクラスの "サンプル" バージョンを作成します。これは、同じインターフェースを持ちながら、テストを困難にする動作を実装していないクラスです。この場合、サンプルのドキュメントクラスはファイルシステムを操作しないはずです。


class SampleDocument : Document {
    static var loadsSuccessfully : Bool = true
    static var savesSuccessfully : Bool = true
    
    override func load() throws {
        guard SampleDocument.loadsSuccessfully else {
            throw DocumentError.cannotLoadContent
        }
    }
    
    override func save() throws {
        guard SampleDocument.savesSuccessfully else {
            throw DocumentError.cannotSaveContent
        }
    }
}

あなたのテストケースの setUp() メソッドでドキュメントの型をサンプルの型に置き換えます。これにより、あなたのテスト対象のドキュメントローダーはスタブドキュメントの型のインスタンスを作成します。サンプルドキュメントはテストにおいて決定論的に動作します。


Swift TestingXCTest

@Suite final class DocumentLoaderTests {
    let loader = DocumentLoader()

    init() {
        loader.DocumentClass = SampleDocument.self
    }

    @Test @MainActor func documentLoaderReturnsFalseWhenDocumentCannotLoad() {
        SampleDocument.loadsSuccessfully = false
        #expect(loader.loadDocument(at: URL(filePath:
         "/Users/example/Documents/document.txt")) == false)
    }

    @Test @MainActor func documentLoaderReturnsTrueWhenDocumentLoads() {
        SampleDocument.loadsSuccessfully = true
        #expect(loader.loadDocument(at: URL(filePath:
         "/Users/example/Documents/document.txt")) == true)
    }
}


class DocumentLoaderTests: XCTestCase {
    var loader: DocumentLoader! = nil

    override func setUp() {
        loader = DocumentLoader()
        loader.DocumentClass = SampleDocument.self
    }

    override func tearDown() {}

    func testDocumentLoaderReturnsFalseWhenDocumentCannotLoad() {
        SampleDocument.loadsSuccessfully = false
        XCTAssertFalse(loader.loadDocument(at: URL(filePath:
         "/Users/example/Documents/document.txt")))
    }

    func testDocumentLoaderReturnsTrueWhenDocumentLoads() {
        SampleDocument.loadsSuccessfully = true
        XCTAssertTrue(loader.loadDocument(at: URL(filePath:
         "/Users/example/Documents/document.txt")))
    }
}

注意

これらのテストは共有の SampleDocument.loadsSuccessfully 状態に依存しているため、同時に実行することはできません。テストは @MainActor で注釈され、連続して実行される事を確認します。



テストできないメソッドをサブクラス化してオーバーライドする


クラスがカスタムロジックと操作や動作を組み合わせ、テストが困難になるような場合は、クラスのメソッドの一部をオーバーライドするサブクラスを導入することで、他のメソッドのテストを容易にします。アプリ固有のロジックと、テストで動作の制御を困難にする環境やフレームワークとの操作の両方を含むクラスを設計することはよくあります。よくある例としては、UIViewController サブクラスが挙げられます。これはアクションメソッドにアプリ固有のコードを持ち、ビューの読み込みや他のビューコントローラの表示も行います。


カスタムアプリのロジックにテストを導入することは、このロジックが期待通りに動作することを確認し、回帰を防ぐために望ましいことです。クラスと環境間の相互作用を制御または回避する複雑さにより、ロジックのテストは困難になります。


例えば、以下のアカウントオブジェクトは、誰かの誕生日を計算するメソッドを提供しています。このメソッドは、アカウントに記録された生年月日から今日の日付までの年数を求めることで計算を行います。


import Foundation

enum AccountError : Error {
    case cannotCalculateAge
}

class Account {
    let name: String
    let email: String
    let userId: String
    let dateOfBirth: Date
    
    init(name: String, email: String, userId: String, dateOfBirth: Date) {
        self.name = name
        self.email = email
        self.userId = userId
        self.dateOfBirth = dateOfBirth
    }
    
    var now : Date {
        get {
            return Date()
        }
    }
    
    func age() throws -> Int {
        let calendar = Calendar.current
        let birthday = calendar.startOfDay(for: dateOfBirth)
        let today = calendar.startOfDay(for: now)
        guard let years =  calendar.dateComponents([.year], from: birthday, to: today).year else {
            throw AccountError.cannotCalculateAge
        }
        return years
    }
}

このオブジェクトの動作テストは、now フィールドの値がシステムクロックから取得されるため困難です。システムクロックが正しく設定されていない場合、テストが失敗する可能性があります。また、時間の経過とともに now フィールドから返される値が変化するため、テストで期待される年齢の計算結果が古くなってしまいます。


この複雑さを克服するには、Account をサブクラス化し、複雑でテスト不可能な相互作用を生成するメソッドを、より単純なメソッドでオーバーライドして「スタブ化」します。あなたのテストでは、オーバーライドしないカスタムロジックの動作を検証するためにサブクラスを使用します。テスト対象のコードがターゲット型のインスタンスを作成する場合は、メタタイプ値を導入する必要がある場合もあります。


以下のリストでは、システムクロックに依存しないサブクラス StubAccount を導入しています。代わりに、呼び出し元によって構成される固定の日付を使用します。このサブクラスを使用したテストでは、アカウントの生年月日と現在の日付の両方に固定値を提供することで、Account オブジェクトが実行する計算が正しいことを確認します。


class StubAccount : Account {
    private var overrideNow : Date
    
    init(name: String, email: String, userId: String, dateOfBirth: Date, overrideNow: Date) {
        self.overrideNow = overrideNow
        super.init(name: name, email: email, userId: userId, dateOfBirth: dateOfBirth)
    }
    
    override var now : Date {
        overrideNow
    }
}

テストの型では、StubAccount のインスタンスを作成し、Account から継承した日付計算ロジックをテストします。StubAccount では、テストコードで現在の日付を表す日付を制御できるため、テストの動作はシステムクロックに依存しません。


Swift TestingXCTest

@Suite struct AccountTests {
    @Test func accountCalculatesAgeCorrectly() throws {
        let dateOfBirth = Calendar.current.date(from: DateComponents(
            timeZone: TimeZone(identifier: "Europe/London"),
            year: 1970,
            month: 1,
            day: 1
        ))!
        let fixedDateForToday = Calendar.current.date(from: DateComponents(
            timeZone: TimeZone(identifier: "Europe/London"),
            year: 2024,
            month: 12,
            day: 31
        ))!
        let account = StubAccount(name: "Example Account", email: "account@example.com",
         userId: "example", dateOfBirth: dateOfBirth, overrideNow: fixedDateForToday)
        #expect(try account.age() == 54)
    }
}


class AccountTests : XCTestCase {
    private var account: StubAccount! = nil

    override func setUp() {
        let dateOfBirth = Calendar.current.date(from: DateComponents(
            timeZone: TimeZone(identifier: "Europe/London"),
            year: 1970,
            month: 1,
            day: 1
        ))!
        let fixedDateForToday = Calendar.current.date(from: DateComponents(
            timeZone: TimeZone(identifier: "Europe/London"),
            year: 2024,
            month: 12,
            day: 31
        ))!
        account = StubAccount(name: "Example Account", email: "account@example.com",
         userId: "example", dateOfBirth: dateOfBirth, overrideNow: fixedDateForToday)
    }

    override func tearDown() { }

    func testAccountCalculatesAgeCorrectly() throws {
        XCTAssertEqual(try account.age(), 54)
    }
}


このパターンは、複数の責任を兼ねる既存のクラスのテストに役立つ場合がありますが、そのクラスとメソッドが final として宣言されていない場合に限ります。また、テスト可能なコードをゼロから設計する際には、このパターンに従うのは推奨されません。異なる処理を扱うコードは、異なるクラスに分割してください。例えば、


  • あなたのアプリのカスタム動作を実装するコントローラークラス。

  • ビュー階層を管理し、UI アクションに応答するビューコントローラー。

  • あなたのアプリのビューに表示するデータを準備および更新するビューモデル。

  • UI テストを追加して、ユニットテストでスタブ化したロジックをカバーするエンドツーエンドのワークフローで実際のクラスの動作を検証します。


    テストできないメソッドをサブクラス化およびオーバーライドすることは、既存のコードを再設計し、アプリのロジックとフレームワークや外部データとの統合を分離するための最初のステップです。このようにコードを分割することで、あなたのプロジェクトのどの部分がアプリの機能を実装し、どの部分がシステムの残りの部分と統合されているかを理解しやすくなります。また、新しい API を利用したり、異なるテクノロジーを採用したりするためにあなたのコードを変更した際に、ロジックのバグが発生する可能性も低くなります。



    singleton の注入


    あなたのコードで singleton オブジェクトを使用してグローバルに利用可能な状態や動作にアクセスしている場合は、singleton を置き換え可能なパラメータに変換することで、テスト時の分離性をサポートできます。singleton の使用はコードベース全体に散在する可能性があるため、テスト対象のコンポーネントで singleton が使用されている場合、その状態を把握することが困難になります。テストを異なる順序で実行すると、結果が異なる場合があります。


    注意

    NSApplication やデフォルトの FileManager など、よく使われる singleton は、外部状態に依存した動作をします。これらの singlrton を直接使用するコンポーネントは、信頼性の高いテストの複雑さをさらに増大させます。


    以下の例では、LoginHandler オブジェクトがネットワークサービスへの誰かの認証に関与しています。このオブジェクトの機能の一つとして、アプリが以前そのサービスに使用したユーザー名を、標準のユーザーデフォルトオブジェクトから取得します。


    class LoginHandler {
        
        var previousUsername: String? {
            get {
                UserDefaults.standard.string(forKey: "ExampleAccountUsername")
            }
        }
        
    }
    

    UserDefaults はファイルシステムに保存される共有状態に依存しており、アプリ内の他のコードや Mac でファイルを編集する誰かによって変更される可能性があります。singleton オブジェクトへの直接アクセスを、テスト対象コンポーネントの外部から制御できるパラメータまたはプロパティに置き換えてください。アプリ内では、singleton をコンポーネントの連携オブジェクトとして引き続き使用してください。テストでは、より制御しやすい代替オブジェクトを用意してください。


    以下のリストは、上記にリストした LoginHandler クラスにこの変更を適用した結果を示しています。ログインハンドラは、保存されているユーザー名を storage オブジェクトから取得し、これは、デフォルトでユーザーデフォルト singleton に設定されます。拡張機能により、UserDefaultsLoginStorage プロトコルに準拠させているため、テストでプロトコルの代替実装を提供できます。


    protocol LoginStorage {
        func string(forKey: String) -> String?
    }
    
    extension UserDefaults : LoginStorage { }
    
    class LoginHandler {
        private var storage: LoginStorage
        
        init(storage: LoginStorage = UserDefaults.standard) {
            self.storage = storage
        }
        
        var previousUsername: String? {
            get {
                storage.string(forKey: "ExampleAccountUsername")
            }
        }
        
    }
    

    テストケースでは、テストスイートまたはアプリの他の場所では使用されていない別のストレージオブジェクトを代用できるため、他のテストやモジュールの動作から分離されます。


    Swift TestingXCTest

    struct StubLoginStorage : LoginStorage {
        let value: String?
    
        init(value: String?) {
            self.value = value
        }
    
        func string(forKey: String) -> String? {
            value
        }
    }
    
    @Suite struct LoginHandlerTests {
        let handler: LoginHandler = LoginHandler(storage: StubLoginStorage(value: "example-username"))
    
        @Test func handlerGetsUsernameFromStorage() {
            #expect(handler.previousUsername == "example-username")
        }
    }
    


    struct StubLoginStorage : LoginStorage {
        let value: String?
    
        init(value: String?) {
            self.value = value
        }
    
        func string(forKey: String) -> String? {
            value
        }
    }
    
    class XCLoginHandlerTests : XCTestCase {
        var handler: LoginHandler! = nil
    
        override func setUp() {
            handler = LoginHandler(storage: StubLoginStorage(value: "example-username"))
        }
    
        func testHandlerGetsUsernameFromStorage() {
            XCTAssertEqual(handler.previousUsername, "example-username")
        }
    }
    


    あなたがテストで使用する代替オブジェクトを singleton の代わりに作成するには、この記事のセクション (具体的な型をプロトコルに置き換えるテストできないメソッドをサブクラス化してオーバーライドする) で説明したこれらの変更を組み合わせる必要があるかもしれません。FileManagerNSApplication のように、テストで制御するのが難しい動作を singleton が提供している場合、この変更が必要になります。





    以下も見よ


    テスト開発


    あなたの Xcode プロジェクトにテストを追加

    あなたの関数内のロジックをテストし、統合の問題をチェックし、UI ワークフローを自動化し、パフォーマンスを測定するためのコードをビルドするテストターゲットを追加します。


    あなたのテストでカバーするコードの範囲を決定する (Determining how much code your tests cover)

    コードカバレッジを使用して、十分なテストが不足している領域に新しいテスト開発を集中させます。


    テストをテスト計画に整理することでコード評価を改善します (Improving code assessment by organizing tests into test plans)

    テスト計画を作成および構成することで、ソフトウェアエンジニアリングプロセスのさまざまな段階であなたがテストから取得する情報を制御します。














    トップへ












    トップへ












    トップへ












    トップへ












    トップへ












    トップへ












    トップへ












    トップへ












    トップへ












    トップへ












    トップへ












    トップへ












    トップへ












    トップへ












    トップへ












    トップへ












    トップへ












    トップへ