使用 Swift 包管理器在单元测试中使用资源

Use resources in unit tests with Swift Package Manager

我试图在单元测试中使用资源文件并使用 Bundle.path 访问它,但它 returns 没有。

这个调用在 MyProjectTests.swift returns nil:

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 和 Swift 包管理器描述 API 版本 4。

我在 this file 中找到了另一个解决方案。

可以创建带有路径的包,例如:

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

它有点难看,但如果你想避免 Makefile,它是可行的。

用于 Swift 5.2 及更早版本的 Swift 脚本方法...

Swift 包管理器 (SwiftPM)

通过一些额外的设置和自定义脚本,可以在 macOS 和 Linux 的 SwiftPM 单元测试中使用资源。以下是对一种可能方法的描述:

SwiftPM 还没有提供处理资源的机制。以下是在包中使用测试资源 TestResources/ 的可行方法;并且,还提供了一个一致的 TestScratch/ 目录,用于在需要时创建测试文件。

设置:

  • PackageName/目录下添加测试资源目录TestResources/

  • 为了 Xcode 使用,将测试资源添加到测试包目标的项目“Build Phases”。

    • 项目编辑器 > 目标 > CxSQLiteFrameworkTests > 构建阶段 > 复制文件:目标资源,+ 添加文件
  • 对于命令行使用,设置 Bash 别名,其中包括 swift-copy-testresources.swift

  • 将 swift-copy-testresources.swift 的可执行版本放在包含 $PATH.

    的适当路径上
    • Ubuntu: nano ~/bin/ swift-copy-testresources.swift

Bash 别名

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()

测试实用代码

////////////////
// 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 buildswift test 期间,进程环境变量 PWD 提供了包根目录 …/PackageName 的路径。 PackageName/TestResources/ 个文件被复制到 $PWD/.buid/TestResourcesTestScratch/ 目录,如果在测试运行期间使用,则在 $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)

我还在 004.4'2 SW Dev Swift Package Manager (SPM) With Resources Qref

上发布了同样方法的 GitHubGist

SwiftPM (5.1) 本身不支持资源 yet,但是...

当单元测试为 运行 时,可以预期存储库可用,因此只需使用从 #file 派生的内容加载资源即可。这适用于 SwiftPM 的所有现存版本。

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

在测试以外的情况下,存储库在运行时不存在,仍然可以包含资源,尽管以二进制大小为代价。通过将任意文件表示为字符串文字中的 base 64 数据,可以将任意文件嵌入到 Swift 源中。 Workspace 是一个可以自动执行该过程的开源工具:$ workspace refresh resources(免责声明:我是它的作者。)

Swift 5.3

查看 Apple 文档:"Bundling Resources with a Swift Package"

Swift 5.3 包括 Package Manager Resources SE-0271 进化提案,“状态:已实施(Swift 5.3)”。

Resources aren't always intended for use by clients of the package; one use of resources might include test fixtures that are only needed by unit tests. Such resources would not be incorporated into clients of the package along with the library code, but would only be used while running the package's tests.

  • Add a new resources parameter in target and testTarget APIs to allow declaring resource files explicitly.

SwiftPM uses file system conventions for determining the set of source files that belongs to each target in a package: specifically, a target's source files are those that are located underneath the designated "target directory" for the target. By default this is a directory that has the same name as the target and is located in "Sources" (for a regular target) or "Tests" (for a test target), but this location can be customized in the package manifest.

// 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()) and thus not present in Foundation.Bundle 由 Xcode 生成。

Xcode 中的一种类似方法是手动将 Resources 参考文件夹添加到 Xcode 项目,添加 Xcode 构建阶段 copyResource 放入某个 *.bundle 目录,并为 Xcode 构建添加一些自定义 #ifdef XCODE_BUILD 编译器指令以使用资源。

#if XCODE_BUILD
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

从 Swift 5.3 开始,感谢 SE-0271,您可以通过在 .target 声明中添加 resources 来在 swift 包管理器上添加包资源.

示例:

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

如果你想了解更多,我在medium上写了一篇文章,讨论这个话题。我不专门讨论 .testTarget,但看一下 swift 提案,它看起来很像。

A 提出了一个适用于旧版 swift 和未来 swift 的简单解决方案:

  1. 将资产添加到项目的根目录中
  2. 在您的 swift 代码中:ResourceHelper.projectRootURL(projectRef: #file, fileName: "temp.bundle/payload.json").path
  3. 适用于 Xcode 和 swift 内置终端或 github 操作 https://eon.codes/blog/2020/01/04/How-to-include-assets-with-swift-package-manager/ and https://github.com/eonist/ResourceHelper/

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
}