Use resources in unit tests with Swift Package Manager Use resources in unit tests with Swift Package Manager swift swift

Use resources in unit tests with Swift Package Manager


Swift 5.3

See Apple Documentation: "Bundling Resources with a Swift Package"

Swift 5.3 includes Package Manager Resources SE-0271 evolution proposal with "Status: Implemented (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)

Example

// swift-tools-version:5.3import 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"),      ]),

Reported Issues & Possible Workarounds

Xcode

Bundle.module is generated by SwiftPM (see Build/BuildPlan.swift SwiftTargetBuildDescription generateResourceAccessor()) and thus not present in Foundation.Bundle when built by Xcode.

A comparable approach in Xcode would be to manually add a Resources reference folder to the Xcode project, add an Xcode build phase copy to put the Resource into some *.bundle directory, and add a some custom #ifdef XCODE_BUILD compiler directive for the Xcode build to work with the resources.

#if XCODE_BUILDextension 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) does not support resources natively yet, however...

When unit tests are running, the repository can be expected to be available, so simply load the resource with something derived from #file. This works with all extant versions of SwiftPM.

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

In cases other than tests, where the repository will not be around at runtime, resources can still be included, albeit at the expense of the binary size. Any arbitrary file can be embedded into Swift source by expressing it as base 64 data in a string literal. Workspace is an open‐source tool that can automate that process: $ workspace refresh resources. (Disclaimer: I am its author.)


A Swift script approach for Swift 5.2 and earlier...

Swift Package Manager (SwiftPM)

It is possible to use resources in unit tests with SwiftPM for both macOS and Linux with some additional setup and custom scripts. Here is a description of one possible approach:

The SwiftPM does not yet provide a mechanism for handling resources. The following is a workable approach for using test resources TestResources/ within a package; and, also provides for a consistent TestScratch/ directory for creating test files if needed.

Setup:

  • Add test resources directory TestResources/ in the PackageName/ directory.

  • For Xcode use, add test resources to project "Build Phases" for the test bundle target.

    • Project Editor > TARGETS > CxSQLiteFrameworkTests > Build Phases > Copy Files: Destination Resources, + add files
  • For command line use, set up Bash aliases which include swift-copy-testresources.swift

  • Place an executable version of swift-copy-testresources.swift on an appropriate path which is included $PATH.

    • Ubuntu: nano ~/bin/ swift-copy-testresources.swift

Bash Aliases

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. Apppend to end. Change /opt/swift/current to where Swift is installed for a given system.

################ SWIFT ################if [ -d "/opt/swift/current/usr/bin" ] ; then    PATH="/opt/swift/current/usr/bin:$PATH"fialias swiftbuild='swift-copy-testresources.swift $PWD; swift build;'alias swifttest='swift-copy-testresources.swift $PWD; swift test;'

Script: 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 Foundationfunc 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/TestResourcesfunc 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/TestScratchfunc 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/TestScratchfunc 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/TestResourcesfunc 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

File Locations:

Linux

During the swift build and swift test the process environment variable PWD provides a path the package root …/PackageName. The PackageName/TestResources/ files are copied to $PWD/.buid/TestResources. The TestScratch/ directory, if used during test runtime, is created in $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/ files are copied into the test bundle Contents/Resources folder as part of the Build Phases. If used during tests, TestScratch/ is placed alongside the *xctest bundle.

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)

I also posted a GitHubGist of this same approach at 004.4'2 SW Dev Swift Package Manager (SPM) With Resources Qref