Use resources in unit tests with Swift Package Manager

Viewed 15592

I'm trying to use a resource file in unit tests and access it with Bundle.path, but it returns nil.

This call in MyProjectTests.swift returns nil:

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

Here is my project hierarchy. I also tried moving TestAudio.m4a to a Resources folder:

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

Here is my package description:

// 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"]
        ),
    ]
)

I am using Swift 4 and the Swift Package Manager Description API version 4.

8 Answers

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.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"),
      ]),

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_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

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

Bundle.module started to work for me after right file structure and dependencies setup.

File structure for test target:

enter image description here

Dependencies setup in 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")
        ]
    ),
]

Usage in the project:

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

I found another solution looking at this file.

It's possible to create a bundle with a path, for example:

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

It's a bit ugly, but if you want to avoid a Makefile, it works.

starting on Swift 5.3, thanks to SE-0271, you can add bundle resources on swift package manager by adding resources on your .target declaration.

example:

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

if you want to learn more, I have written an article on medium, discussing this topic. I don't specifically discuss .testTarget, but looking on the swift proposal, it looks alike.

I'm using:

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")!
'''

Related