When test support code relies on production code, a diamond can occur. If this occurs across packages, it can lead to duplicated symbols and incorrect behavior at runtime despite no warnings at build time.
This sample project is a minimized and abstracted configuration to demonstrate the issue. It contains the minimum code required to build a failing test case. While dependencies are expressed in the package files, there are few actual import
statements because very little code is required to cause the problem.
Build and test this project. It should fail, indicating that there are two copies of Keychain.shared
. This can be seen in the logs by observing the object identifier logs.
graph
App --- Dependencies/AppDependencies --- Core/Core
Test --- Dependencies/TestDependencies --- Feature/FeatureTestSupport
Core/Core --- Core/Keychain
Core/CoreTestSupport --- Core/Keychain
Feature/FeatureTestSupport --- Core/Core
Feature/FeatureTestSupport --- Core/CoreTestSupport
App-level dependencies are managed by placing them all into a single Dependencies
package. Features are broken out into their own packages, with a Core package at the bottom. Packages contain ...TestSupport
modules to share code used by unit tests. These import XCTest
normally.
As an example of test support code, CoreTestSupport
provides an XCTestCase
subclass that is used throughout the tests.
-
Dependencies: Top-level modules linked from the app and unit tests. The application links the AppDependencies module and the unit tests link the TestDependencies module.
-
Core: Module with core logic. Contains Core and Keychain production modules. Also contains CoreTestSupport module that provides test support.
-
Feature: Module for a feature that relies on Core. Includes its own test support module that relies on both Core and CoreTestSupport.
Core.framework
shows up three times in DerivedData:
./App.app/PlugIns/AppTests.xctest/Frameworks/Core_59974D35D_PackageProduct.framework
./App.app/Frameworks/Core_59974D35D_PackageProduct.framework
./PackageFrameworks/Core_59974D35D_PackageProduct.framework
When unit tests are run, there is a collision on the symbol Keychain
:
objc[48914]: Class _TtC8Keychain8Keychain is implemented in both /Users/ornapier/Library/Developer/Xcode/DerivedData/App-grdjljgevqofhqgflgtrqvhvbtej/Build/Products/Debug-iphonesimulator/PackageFrameworks/Core_59974D35D_PackageProduct.framework/Core_59974D35D_PackageProduct (0x100a98118) and /Users/ornapier/Library/Developer/CoreSimulator/Devices/216C441E-4AE5-45EC-8E52-FA42D8562365/data/Containers/Bundle/Application/7197F2F2-EB26-42FF-B7DB-67116159897D/App.app/PlugIns/AppTests.xctest/AppTests (0x1011002c0). One of the two will be used. Which one is undefined.
This is not benign. There are two distinct copies of _TtC8Keychain8Keychain
and test cases will access one and the app will access a different one. This leads to mismatches when accessing static instances such as .shared
.
AppTests.xctest
should not have its own copy of Core.framework
. It should rely on the app to provide it, just as it relies on the app to provide all other production code. However, FeatureTestSupport needs to access things in Core to configure test cases. It is unclear how to implement this.
Generally speaking this all works. If all of these modules are in the same package, for example, there is no problem. It requires this cross-package dependency to break. There are no warnings during build. This also works if the top level is a Swift package rather than an iOS app. It also works if the impacted code is moved into a module. It is only a problem when the top-level app tests rely on support code.