知阅百微 见微知著

Testing Private Members in Swift with @_private(sourceFile:)

When writing unit tests for Swift code, you often need to verify the internal state of your classes. However, Swift's access control prevents tests from accessing private members - forcing developers to either expose implementation details with internal or settle for testing only public APIs. This is where @_private(sourceFile:) comes in.

What is @_private(sourceFile:)?

@_private(sourceFile:)
is an underscored Swift attribute that completely bypasses access control, allowing you to access private declarations from specific source files in an imported module. Think of it as a surgical access control override - you can reach into a module and access private members as if they were internal.

The syntax is straightforward:

@_private(sourceFile: "Location.swift")
import OpenSwiftUICore

With this import, you can access all private members from Location.swift in the OpenSwiftUICore module.

The Problem It Solves

Let's look at a real-world example from the OpenSwiftUI project. Consider this LocationBox class:

final package class LocationBox<L>: AnyLocation<L.Value>, Location
    where L: Location
{
    final private(set) package var location: L
    @AtomicBox
    private var cache = LocationProjectionCache()

    // ... implementation
}

The cache property is private because it's an implementation detail that external code shouldn't touch. But in unit tests, you need to verify that caching works correctly:

func testCaching() {
    let location = MockLocation()
    let box = LocationBox(location)

    // How do we test that cache is working?
    // We need to access box.cache, but it's private!
}

Previously, developers had two options:

  1. Make cache internal - exposing implementation details
  2. Test only through public APIs - making tests less precise

Neither option is ideal. The first violates encapsulation, the second makes debugging harder.

How to Use It

Step 1: Enable Private Imports in Your Module

The target module must be compiled with -enable-private-imports. In Package.swift:

.target(
    name: "OpenSwiftUICore",
    swiftSettings: [
        .unsafeFlags(["-Xfrontend", "-enable-private-imports"])
    ]
)

Step 2: Use @_private in Your Test

In your test file:

#if OPENSWIFTUI_ENABLE_PRIVATE_IMPORTS
@_private(sourceFile: "Location.swift")
import OpenSwiftUICore
#endif

Step 3: Access Private Members

Now you can access private members directly:

func testCaching() {
    let location = MockLocation()
    let box = LocationBox(location)

    // Access the private cache!
    #expect(box.cache.isEmpty == true)

    box.projecting(keyPath)

    #expect(box.cache.isEmpty == false)
}

Here's how it looks in practice with OpenSwiftUI's test suite:

Using @_private(sourceFile:) in OpenSwiftUI tests

The screenshot shows the full setup:

  • Line 6: Using #if to conditionally enable the feature
  • Line 8: The @_private(sourceFile: "Location.swift") import
  • Lines 20-30: Testing private cache state directly

Best Practices: Use It Conditionally

Here's the crucial part: @_private(sourceFile:) is an underscored attribute, which in Swift means "compiler and standard library use only." These attributes:

  • Are not part of Swift's stable ABI
  • Can change or be removed without warning
  • Are explicitly discouraged outside the Swift repository

This is why the OpenSwiftUI project wraps it in a conditional compilation flag:

#if OPENSWIFTUI_ENABLE_PRIVATE_IMPORTS
@_private(sourceFile: "Location.swift")
import OpenSwiftUICore
#endif

This pattern provides several benefits:


  1. Future-proofing
    : If Swift removes or changes this attribute, you can disable it via environment variable

  2. Explicit opt-in
    : The feature is only enabled when deliberately configured

  3. Easy rollback
    : If issues arise, disable it without code changes

Setting Up the Environment Variable

In your test scheme or CI/CD environment:

export OPENSWIFTUI_ENABLE_PRIVATE_IMPORTS=1

Real-World Example

The OpenSwiftUI project demonstrates this pattern effectively in PR #540. The changes include:

  1. Adding -enable-private-imports to the core module's build settings
  2. Conditionally importing with @_private(sourceFile:) in tests
  3. Using an environment variable to control the feature

This allows comprehensive testing of internal state while maintaining the flexibility to disable the feature if needed.

When Should You Use This?

@_private(sourceFile:)
is appropriate when:

  • Writing unit tests that need to verify internal state
  • The alternative is making implementation details public
  • You need precise, focused tests rather than broad integration tests
  • You can isolate usage to test targets only

Avoid using it when:

  • You're writing production code (never import it outside tests)
  • You can achieve the same coverage with public API tests
  • The private members you're accessing are in third-party code (they could change)

Limitations and Considerations


  1. Compiler version dependency
    : This feature requires Swift 5.x+ and may change in future versions

  2. Module compilation requirement
    : The imported module must be compiled with -enable-private-imports

  3. Maintenance burden
    : If the source file is renamed or members are moved, imports break

Conclusion

@_private(sourceFile:)
is a powerful tool for writing comprehensive unit tests without compromising encapsulation. By accessing private members only in test code and wrapping usage in conditional compilation, you can:

  • Test internal implementation details thoroughly
  • Keep your public API minimal and clean
  • Maintain flexibility to adapt if Swift changes the feature

Remember the golden rule: use underscored attributes defensively. Always wrap them in feature flags, document why you're using them, and be prepared to remove them if needed.

For a complete implementation example, check out the OpenSwiftUI PR #540 which demonstrates this pattern in production.

References