Использование ресурсов в модульных тестах с Swift Package Manager

Я пытаюсь использовать файл ресурсов в модульных тестах и ​​получить к нему доступ Bundle.path, но он возвращает ноль.

Этот вызов в MyProjectTests.swift возвращает ноль:

Bundle(for: type(of: self)).path(forResource: "TestAudio", ofType: "m4a")

Вот моя иерархия проектов. Я также пытался переехать TestAudio.m4a к Resources папка:

├── Package.swift
├── Sources
│   └── MyProject
│       ├── ...
└── Tests
    └── MyProjectTests
        ├── MyProjectTests.swift
        └── TestAudio.m4a

Вот мое описание пакета:

// swift-tools-version:4.0

import PackageDescription

let package = Package(
    name: "MyProject",
    products: [
        .library(
            name: "MyProject",
            targets: ["MyProject"])
    ],
    targets: [
        .target(
            name: "MyProject",
            dependencies: []
        ),
        .testTarget(
            name: "MyProjectTests",
            dependencies: ["MyProject"]
        ),
    ]
)

Я использую Swift 4 и API-интерфейс описания Swift Package Manager версии 4.

10 ответов

Решение

В настоящее время диспетчер пакетов Swift (SPM) не обрабатывает ресурсы, вот проблема, открытая в системе отслеживания ошибок SPM https://bugs.swift.org/browse/SR-2866.

До тех пор, пока в SPM не будет реализована поддержка ресурсов, я буду копировать ресурсы в места, где ожидаемый результат будет находиться во время выполнения. Вы можете узнать эти места, напечатав Bundle.resourcePath имущество. Я бы сделал это копирование автоматически, используя, например, Makefile. Таким образом, Makefile становится "сборщиком" поверх SPM.

Я написал пример, чтобы продемонстрировать, как этот подход работает в MacOS и Linux - https://github.com/vadimeisenbergibm/SwiftResourceHandlingExample.

Пользователь будет запускать команды make: make build а также make test вместо swift build а также swift test, Make скопирует ресурсы в ожидаемые места (разные в MacOS и Linux, во время работы и во время тестов).

Swift 5.3

Swift 5.3 включает предложение по развитию SE-0271 ресурсов диспетчера пакетов с " Статусом: реализовано (Swift 5.3) ".:-)

Ресурсы не всегда предназначены для использования клиентами пакета; одно использование ресурсов может включать в себя тестовые инструменты, которые нужны только модульным тестам. Такие ресурсы не будут включены в клиенты пакета вместе с кодом библиотеки, а будут использоваться только при выполнении тестов пакета.

  • Добавить новый resources параметр в target а также testTarget API, позволяющие явно объявлять файлы ресурсов.

SwiftPM использует соглашения файловой системы для определения набора исходных файлов, принадлежащих каждому целевому объекту в пакете: в частности, исходные файлы целевого объекта - это те файлы, которые расположены под назначенным для него "целевым каталогом". По умолчанию это каталог с тем же именем, что и цель, и он находится в "Источниках" (для обычной цели) или "Тестах" (для тестовой цели), но это расположение можно настроить в манифесте пакета.

// Get path to DefaultSettings.plist file.
let path = Bundle.module.path(forResource: "DefaultSettings", ofType: "plist")

// Load an image that can be in an asset archive in a bundle.
let image = UIImage(named: "MyIcon", in: Bundle.module, compatibleWith: UITraitCollection(userInterfaceStyle: .dark))

// Find a vertex function in a compiled Metal shader library.
let shader = try mtlDevice.makeDefaultLibrary(bundle: Bundle.module).makeFunction(name: "vertexShader")

// Load a texture.
let texture = MTKTextureLoader(device: mtlDevice).newTexture(name: "Grass", scaleFactor: 1.0, bundle: Bundle.module, options: options)

пример

// swift-tools-version:5.3
import PackageDescription

  targets: [
    .target(
      name: "Example",
      dependencies: [],
      resources: [
        // Apply platform-specific rules.
        // For example, images might be optimized per specific platform rule.
        // If path is a directory, the rule is applied recursively.
        // By default, a file will be copied if no rule applies.
        // Process file in Sources/Example/Resources/*
        .process("Resources"),
      ]),
    .testTarget(
      name: "ExampleTests",
      dependencies: [Example],
      resources: [
        // Copy Tests/ExampleTests/Resources directories as-is. 
        // Use to retain directory structure.
        // Will be at top level in bundle.
        .copy("Resources"),
      ]),

Текущий номер

Xcode

Bundle.moduleгенерируется SwiftPM (см. Build / BuildPlan.swift SwiftTargetBuildDescription generateResourceAccessor()) и, следовательно, не присутствует в Foundation.Bundle при сборке с помощью Xcode.

Сравнимый подход в Xcode - добавить вручную Resources ссылочная папка в проект Xcode, добавьте этап сборки Xcode copy поставить Resource в некоторые *.bundle каталог и добавьте #ifdef Xcode директива компилятора для сборки Xcode для работы с ресурсами.

#if Xcode 
extension Foundation.Bundle {
    
    /// Returns resource bundle as a `Bundle`.
    /// Requires Xcode copy phase to locate files into `ExecutableName.bundle`;
    /// or `ExecutableNameTests.bundle` for test resources
    static var module: Bundle = {
        var thisModuleName = "CLIQuickstartLib"
        var url = Bundle.main.bundleURL
        
        for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") {
            url = bundle.bundleURL.deletingLastPathComponent()
            thisModuleName = thisModuleName.appending("Tests")
        }
        
        url = url.appendingPathComponent("\(thisModuleName).bundle")
        
        guard let bundle = Bundle(url: url) else {
            fatalError("Foundation.Bundle.module could not load resource bundle: \(url.path)")
        }
        
        return bundle
    }()
    
    /// Directory containing resource bundle
    static var moduleDir: URL = {
        var url = Bundle.main.bundleURL
        for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") {
            // remove 'ExecutableNameTests.xctest' path component
            url = bundle.bundleURL.deletingLastPathComponent()
        }
        return url
    }()
    
}
#endif

SwiftPM (5,1) не поддерживает ресурсы изначально еще, однако...

Когда запущены модульные тесты, можно ожидать, что репозиторий будет доступен, поэтому просто загрузите ресурс чем-то, полученным из #file. Это работает со всеми существующими версиями SwiftPM.

let thisSourceFile = URL(fileURLWithPath: #file)
let thisDirectory = thisSourceFile.deletingLastPathComponent()
let resourceURL = thisDirectory.appendingPathComponent("TestAudio.m4a")

В случаях, отличных от тестов, когда репозиторий не будет во время выполнения, ресурсы все равно могут быть включены, хотя и за счет двоичного размера. Любой произвольный файл может быть встроен в исходный код Swift, представив его как данные base 64 в строковом литерале. Workspace - это инструмент с открытым исходным кодом, который может автоматизировать этот процесс:$ workspace refresh resources. (Отказ от ответственности: я являюсь его автором.)

Bundle.module начал работать у меня после настройки правильной файловой структуры и зависимостей.

Файловая структура для тестовой цели:

Настройка зависимостей в Package.swift:

      targets: [
    // Targets are the basic building blocks of a package. A target can define a module or a test suite.
    // Targets can depend on other targets in this package, and on products in packages this package depends on.
    .target(
        name: "Parser",
        dependencies: []),
    .testTarget(
        name: "ParserTests",
        dependencies: ["Parser"],
        resources: [
            .copy("Resources/test.txt")
        ]
    ),
]

Использование в проекте:

      private var testData: Data {
    let url = Bundle.module.url(forResource: "test", withExtension: "txt")!
    let data = try! Data(contentsOf: url)
    return data
}

Swift Package Manager (SPM) 4.2

В Swift Package Manager PackageDescription 4.2 представлена ​​поддержка локальных зависимостей.

Локальные зависимости - это пакеты на диске, к которым можно обращаться напрямую, используя их пути. Локальные зависимости разрешены только в корневом пакете, и они переопределяют все зависимости с тем же именем в графе пакета.

Примечание: я ожидаю, но еще не проверял, что что-то вроде следующего должно быть возможно с SPM 4.2:

// swift-tools-version:4.2
import PackageDescription

let package = Package(
    name: "MyPackageTestResources",
    dependencies: [
        .package(path: "../test-resources"),
    ],
    targets: [
        // ...
        .testTarget(
            name: "MyPackageTests",
            dependencies: ["MyPackage", "MyPackageTestResources"]
        ),
    ]
)

Swift Package Manager (SPM) 4.1 и более ранних версий

Можно использовать ресурсы в модульных тестах с помощью Swift Package Manager для MacOS и Linux с некоторыми дополнительными настройками и пользовательскими сценариями. Вот описание одного из возможных подходов:

Диспетчер пакетов Swift пока не предоставляет механизм обработки ресурсов. Ниже приведен практичный подход к использованию тестовых ресурсов. TestResources/ в пакете; и, также предусматривает последовательное TestScratch/ каталог для создания тестовых файлов, если это необходимо.

Настроить:

  • Добавить каталог ресурсов тестирования TestResources/ в PackageName/ каталог.
  • Для использования XCode, добавьте ресурсы теста в проект "Фазы сборки" для цели пакета теста.

    • Редактор проектов> TARGETS > CxSQLiteFrameworkTests > Этапы сборки> Копировать файлы: ресурсы назначения, + добавить файлы
  • Для использования в командной строке установите псевдонимы Bash, которые включают swift-copy-testresources.swift

  • Поместите исполняемую версию swift-copy-testresources.swift по соответствующему пути, который включает в себя $ PATH.
    • Ubuntu: nano ~/bin/ swift-copy-testresources.swift

Баш псевдонимы

MacOS: nano .bash_profile

alias swiftbuild='swift-copy-testresources.swift $PWD; swift build -Xswiftc "-target" -Xswiftc "x86_64-apple-macosx10.13";'
alias swifttest='swift-copy-testresources.swift $PWD; swift test -Xswiftc "-target" -Xswiftc "x86_64-apple-macosx10.13";'
alias swiftxcode='swift package generate-xcodeproj --xcconfig-overrides Package.xcconfig; echo "REMINDER: set Xcode build system."'

Ubuntu: nano ~/.profile , Приложите к концу. Измените / opt / swift / current на то, где Swift установлен для данной системы.

#############
### SWIFT ###
#############
if [ -d "/opt/swift/current/usr/bin" ] ; then
    PATH="/opt/swift/current/usr/bin:$PATH"
fi

alias swiftbuild='swift-copy-testresources.swift $PWD; swift build;'
alias swifttest='swift-copy-testresources.swift $PWD; swift test;'

Скрипт: swift-copy-testresources.sh chmod +x

#!/usr/bin/swift

// FILE: swift-copy-testresources.sh
// verify swift path with "which -a swift"
// macOS: /usr/bin/swift 
// Ubuntu: /opt/swift/current/usr/bin/swift 
import Foundation

func copyTestResources() {
    let argv = ProcessInfo.processInfo.arguments
    // for i in 0..<argv.count {
    //     print("argv[\(i)] = \(argv[i])")
    // }
    let pwd = argv[argv.count-1]
    print("Executing swift-copy-testresources")
    print("  PWD=\(pwd)")

    let fm = FileManager.default

    let pwdUrl = URL(fileURLWithPath: pwd, isDirectory: true)
    let srcUrl = pwdUrl
        .appendingPathComponent("TestResources", isDirectory: true)
    let buildUrl = pwdUrl
        .appendingPathComponent(".build", isDirectory: true)
    let dstUrl = buildUrl
        .appendingPathComponent("Contents", isDirectory: true)
        .appendingPathComponent("Resources", isDirectory: true)

    do {
        let contents = try fm.contentsOfDirectory(at: srcUrl, includingPropertiesForKeys: [])
        do { try fm.removeItem(at: dstUrl) } catch { }
        try fm.createDirectory(at: dstUrl, withIntermediateDirectories: true)
        for fromUrl in contents {
            try fm.copyItem(
                at: fromUrl, 
                to: dstUrl.appendingPathComponent(fromUrl.lastPathComponent)
            )
        }
    } catch {
        print("  SKIP TestResources not copied. ")
        return
    }

    print("  SUCCESS TestResources copy completed.\n  FROM \(srcUrl)\n  TO \(dstUrl)")
}

copyTestResources()

Test Utility Code

//////////////// // MARK: - Linux //////////////// #if os (Linux)

// /PATH_TO_PACKAGE/PackageName/.build/TestResources
func getTestResourcesUrl() -> URL? {
    guard let packagePath = ProcessInfo.processInfo.environment["PWD"]
        else { return nil }
    let packageUrl = URL(fileURLWithPath: packagePath)
    let testResourcesUrl = packageUrl
        .appendingPathComponent(".build", isDirectory: true)
        .appendingPathComponent("TestResources", isDirectory: true)
    return testResourcesUrl
} 

// /PATH_TO_PACKAGE/PackageName/.build/TestScratch
func getTestScratchUrl() -> URL? {
    guard let packagePath = ProcessInfo.processInfo.environment["PWD"]
        else { return nil }
    let packageUrl = URL(fileURLWithPath: packagePath)
    let testScratchUrl = packageUrl
        .appendingPathComponent(".build")
        .appendingPathComponent("TestScratch")
    return testScratchUrl
}

// /PATH_TO_PACKAGE/PackageName/.build/TestScratch
func resetTestScratch() throws {
    if let testScratchUrl = getTestScratchUrl() {
        let fm = FileManager.default
        do {_ = try fm.removeItem(at: testScratchUrl)} catch {}
        _ = try fm.createDirectory(at: testScratchUrl, withIntermediateDirectories: true)
    }
}

///////////////////
// MARK: - macOS
///////////////////
#elseif os(macOS)

func isXcodeTestEnvironment() -> Bool {
    let arg0 = ProcessInfo.processInfo.arguments[0]
    // Use arg0.hasSuffix("/usr/bin/xctest") for command line environment
    return arg0.hasSuffix("/Xcode/Agents/xctest")
}

// /PATH_TO/PackageName/TestResources
func getTestResourcesUrl() -> URL? {
    let testBundle = Bundle(for: CxSQLiteFrameworkTests.self)
    let testBundleUrl = testBundle.bundleURL

    if isXcodeTestEnvironment() { // test via Xcode 
        let testResourcesUrl = testBundleUrl
            .appendingPathComponent("Contents", isDirectory: true)
            .appendingPathComponent("Resources", isDirectory: true)
        return testResourcesUrl            
    }
    else { // test via command line
        guard let packagePath = ProcessInfo.processInfo.environment["PWD"]
            else { return nil }
        let packageUrl = URL(fileURLWithPath: packagePath)
        let testResourcesUrl = packageUrl
            .appendingPathComponent(".build", isDirectory: true)
            .appendingPathComponent("TestResources", isDirectory: true)
        return testResourcesUrl
    }
} 

func getTestScratchUrl() -> URL? {
    let testBundle = Bundle(for: CxSQLiteFrameworkTests.self)
    let testBundleUrl = testBundle.bundleURL
    if isXcodeTestEnvironment() {
        return testBundleUrl
            .deletingLastPathComponent()
            .appendingPathComponent("TestScratch")
    }
    else {
        return testBundleUrl
            .deletingLastPathComponent()
            .deletingLastPathComponent()
            .deletingLastPathComponent()
            .appendingPathComponent("TestScratch")
    }
}

func resetTestScratch() throws {
    if let testScratchUrl = getTestScratchUrl() {
        let fm = FileManager.default
        do {_ = try fm.removeItem(at: testScratchUrl)} catch {}
        _ = try fm.createDirectory(at: testScratchUrl, withIntermediateDirectories: true)
    }
}

#endif

Расположение файлов:

Linux

В течение swift build а также swift test переменная среды процесса PWD обеспечивает путь к корню пакета …/PackageName, PackageName/TestResources/ файлы копируются в $PWD/.buid/TestResources, TestScratch/ каталог, если он используется во время выполнения теста, создается в $PWD/.buid/TestScratch,

.build/
├── debug -> x86_64-unknown-linux/debug
...
├── TestResources
│   └── SomeTestResource.sql      <-- (copied from TestResources/)
├── TestScratch
│   └── SomeTestProduct.sqlitedb  <-- (created by running tests)
└── x86_64-unknown-linux
    └── debug
        ├── PackageName.build/
        │   └── ...
        ├── PackageNamePackageTests.build
        │   └── ...
        ├── PackageNamePackageTests.swiftdoc
        ├── PackageNamePackageTests.swiftmodule
        ├── PackageNamePackageTests.xctest  <-- executable, not Bundle
        ├── PackageName.swiftdoc
        ├── PackageName.swiftmodule
        ├── PackageNameTests.build
        │   └── ...
        ├── PackageNameTests.swiftdoc
        ├── PackageNameTests.swiftmodule
        └── ModuleCache ...

MacOS CLI

.build/
|-- TestResources/
|   `-- SomeTestResource.sql      <-- (copied from TestResources/)
|-- TestScratch/
|   `-- SomeTestProduct.sqlitedb  <-- (created by running tests)
...
|-- debug -> x86_64-apple-macosx10.10/debug
`-- x86_64-apple-macosx10.10
    `-- debug
        |-- PackageName.build/
        |-- PackageName.swiftdoc
        |-- PackageName.swiftmodule
        |-- PackageNamePackageTests.xctest
        |   `-- Contents
        |       `-- MacOS
        |           |-- PackageNamePackageTests
        |           `-- PackageNamePackageTests.dSYM
        ...
        `-- libPackageName.a

macOS Xcode

PackageName/TestResources/ файлы копируются в тестовый пакет Contents/Resources папка как часть этапов сборки. Если используется во время испытаний, TestScratch/ находится рядом с *xctest расслоение.

Build/Products/Debug/
|-- PackageNameTests.xctest/
|   `-- Contents/
|       |-- Frameworks/
|       |   |-- ...
|       |   `-- libswift*.dylib
|       |-- Info.plist
|       |-- MacOS/
|       |   `-- PackageNameTests
|       `-- Resources/               <-- (aka TestResources/)
|           |-- SomeTestResource.sql <-- (copied from TestResources/)
|           `-- libswiftRemoteMirror.dylib
`-- TestScratch/
    `-- SomeTestProduct.sqlitedb     <-- (created by running tests)

Я также опубликовал GitHubGist с тем же подходом в 004.4'2 SW Dev Swift Package Manager (SPM) с ресурсами Qref

Начиная с Swift 5.3, благодаря SE-0271, вы можете добавить ресурсы пакета в диспетчер пакетов Swift, добавивresources на ваше .target декларация.

пример:

.target(
   name: "HelloWorldProgram",
   dependencies: [], 
   resources: [.process(Images), .process("README.md")]
)

Если вы хотите узнать больше, я написал статью о среде, обсуждая эту тему. Я специально не обсуждаю.testTarget, но, глядя на быстрое предложение, все выглядит одинаково.

Я нашел другое решение, глядя на этот файл.

Можно создать пакет с путем, например:

let currentBundle = Bundle.allBundles.filter() { $0.bundlePath.hasSuffix(".xctest") }.first!
let realBundle = Bundle(path: "\(currentBundle.bundlePath)/../../../../Tests/MyProjectTests/Resources")

Это немного уродливо, но если вы хотите избежать Makefile, это работает.

Обратите внимание, что, возможно, при использовании .copy(…) Resources. I couldn't get it to compile - something to do with code signing. .process(…)`действительно работает, и это прекрасно, потому что мне больше не нужно беспокоиться о структуре папок (поскольку она все сглаживает).

Я использую:

      extension Bundle {
    func locateFirst(forResource: String, withExtension: String) -> URL? {
        for b in Bundle.allBundles {
            if let u = b.url(forResource: forResource, withExtension: withExtension) {
                return u
            }
        }
        return nil
    }

}
'''

And then just call locateFirst, which gives the first item.
like:

'''
            let p12 = Bundle().locateFirst(forResource: "Certificates", withExtension: "p12")!
'''

Сделано простое решение, которое работает для устаревшего быстрого и будущего быстрого:

  1. Добавьте свои активы в корень вашего проекта
  2. В вашем быстром коде: ResourceHelper.projectRootURL(projectRef: #file, fileName: "temp.bundle/payload.json").path
  3. Работает в Xcode и быстро строит в действиях терминала или github https://eon.codes/blog/2020/01/04/How-to-include-assets-with-swift-package-manager/
Другие вопросы по тегам