[Pitch] Targeted Interoperability between Swift Testing and XCTest

Hello Swift Testers! I’d like your feedback on a proposal for targeted interoperability between Swift Testing and XCTest.

Proposal: https://github.com/jerryjrchen/swift-evolution/blob/swt-xct-interop/proposals/testing/NNNN-targeted-interoperability-swift-testing-and-xctest.md

Many developers, perhaps including you, are eager to migrate tests from XCTest to Swift Testing. In reality, your project might not be able to modernize all XCTest test code in a single pass. Maybe your project is simply too large to update all the test code at once. Or, you might rely on a feature available in XCTest but not yet implemented in Swift Testing (please file an issue if so!). As a result, your codebase can have elements of both testing frameworks present.

If any of this is sounding familiar, that's because "Enable incremental adoption" is actually an important part of the Swift Testing vision! We encourage you to develop "footholds" for Swift Testing where possible and continue to adopt at your own pace.

Unfortunately, mixing an API call from one framework with a test from the other framework may not work as expected. For example, if you take an existing test helper function written for XCTest and call it in a Swift Testing test, it won't report the assertion failure:

func assertUnique(_ elements: [Int]) {
  XCTAssertEqual(Set(elements).count, elements.count, "\(elements) has non unique elements")
}

// XCTest

class FooTests: XCTestCase {
    func testDups() {
        assertUnique([1, 2, 1]) // Fails as expected
    }
}

// Swift Testing

@Test func `Duplicate elements`() {
  assertUnique([1, 2, 1]) // Passes? Oh no!
}

Proposal

It can understandably be quite challenging to migrate if you don't know if your tests will work the same afterwards! We think supporting targeted interoperability between the Swift Testing and XCTest frameworks will make it easier to modernise your projects for Swift Testing. If you’re already all-in on Swift Testing, you can opt-in to strict interop mode to ensure you never inadvertently introduce XCTest API in the future!

When running a Swift Testing test... Current Proposed Proposed (strict)
XCTAssert failure is a... :double_exclamation_mark: No-op :warning: Runtime Warning, :cross_mark: Test Failure :collision: fatalError identifying unsupported interop
XCTAssert success is a... No-op :warning: Runtime Warning :collision: fatalError
throw XCTSkip is a... :cross_mark: Test Failure :cross_mark: Test Failure :cross_mark: Test Failure

Check out the full proposal for more details, including:

  • Specific APIs receiving interoperability support

  • Configurable interop modes

  • Phased rollout strategy

Please feel free to share any questions or concerns that come to mind! Here are a few questions to get you started:

  • Have you run into issues where you missed a test failure due to usage of an XCTAssert in Swift Testing?

  • Which interoperability features would help you the most in migrating to Swift Testing?

  • What other tools or features would help you adopt Swift Testing in your project?

7 Likes

I've been doing a lot of XCTest-to-Swift-Testing migrations lately and I've definitely seen places where this would be extremely useful—especially when Swift Testing doesn't have parity with XCTest (test cancellation [though this has been pitched], and timeouts for asynchronous confirmations).

Likewise, when XCTest and Swift Testing both have a feature but the APIs differ significantly, it would be nice to be able to migrate the majority of the test structure but still leave the difficult things untouched until later. For example, let's say I'm testing how multiple components interact, and one of the components is a fake implementation where I stash an instance of XCTExpectation that another operation will fulfill. That's a lot trickier to migrate to Swift Testing's confirmation API, so I'd love to migrate everything else but keep that working as it does today.

My main question, though, is how will this be implemented? The discussion in the pitch is about runtime control rather than the build-time impact. Does this require the Testing module to take on an unconditional dependency with XCTest, or would this support be managed with something like a cross-import overlay? I ask because on Linux, we build our Swift toolchain from source (this gets checked in), and we also build Swift Testing and XCTest from source when they are consumed as a dependency (we're not using prebuilt dylibs/so's). So for clients who are writing pure Swift Testing based tests, I would not like to see XCTest also having to be inserted into the build graph for each of those.

3 Likes

Love it! Would be really convenient.

If this means XCTExpectation would be fully supported as-is in Swift Testing test cases, I would be ecstatic. Expectation-based test cases are definitely the hardest to migrate today.

Proposed: XCTAssert success is a... :warning: Runtime Warning

What’s the thinking here? I would prefer XCTest APIs work correctly with no warnings.

One of the motivations for this proposal is to encourage users to migrate code using XCTest APIs to using the equivalent Swift Testing APIs (when they exist) instead, even if that code may be called by tests using either framework. Swift Testing is the successor to XCTest, so one of the high-level goals is to facilitate increased migration and adoption of the "newer" testing library.

Proposed: XCTAssert success is a... :warning: Runtime Warning

This example shows what would happen if you called one of the XCTAssert functions from a Swift Testing test only and it did not fail. The purpose of the warning is to notify you that you are using an XCTest API from a Swift Testing test: since Swift Testing has an analogous, newer API (#expect), the warning would indicate that you have an opportunity to adopt that newer alternative. Thus, these runtime warnings serve a similar purpose as (build-time) deprecation warnings[1]. And under this proposal, XCTAssert functions called from XCTest tests would never emit those "modernization" warnings.


  1. Although formally speaking, XCTest is not considered deprecated and this proposal doesn't seek to change that. ↩︎

4 Likes

Makes sense. My perspective is that if migrating most but not all of a test suite from XCTest to Swift Testing produces new warnings, I’m less likely to want to migrate the test suite at all. I’d rather have a 100% XCTest test suite with no warnings than a 90-95% Swift Testing test suite with warnings.

I’m particularly thinking about automated migrations (e.g. this SwiftFormat rule). One reason I’ve held off from actually applying this in our production codebase is due to the complexities around XCTExpectation. If XCTExpectation code could be preserved as-is, it makes it a lot easier and more reasonable to simply migrate the surrounding test code to Swift Testing. But if this introduces new warnings, an automated migration starts feeling like a non-starter.

XCTExpectation and XCTWaiter are fundamentally incompatible with Swift Concurrency. In combination, they act like a semaphore, and semaphores block forward progress of the current thread. Sorry to be a party-pooper, but we have no plans to support them in Swift Testing.

We know confirmation isn't an exact match for XCTExpectation, but most of the use cases for the latter can be replaced with Swift Concurrency mechanisms like async/await and continuations. If there are specific scenarios you run into where you can't replace your expectations with Swift Concurrency API, please feel free to start a new thread where we can troubleshoot and try to find solutions that will work for you. Thanks!

5 Likes

From proposal

This proposal refers to XCTest in the abstract. There are two different implementations of XCTest: the open source swift-corelibs-xctest and a proprietary XCTest that is shipped as part of Xcode. The Swift evolution process governs changes to the former only. Therefore, this proposal is targeted for Corelibs XCTest.

What implications does this mean? Am I not ALWAYS using the proprietary XCTest when importing XCTest in an test target in my xcodeproj? So it wont work in that case?

But it will work for XCTest in SPM package testTargets?

1 Like

When it comes to this thread's proposal though, given that XCTestExpectation/XCTWaiter do not have a counterpart in Swift Testing that offers equivalent functionality for most users, I think those APIs would (a) not interoperate and (b) not emit the :warning: runtime warnings discussed earlier.

So for these specific APIs the status quo would be preserved. As a general rule, this proposal would only emit the modernization warnings when there is an analogous API in Swift Testing that offers equivalent or greater functionality. The list of APIs that policy applies to could expand over time, as Swift Testing evolves and adds new features.

2 Likes

For the edification of y'all… Stuart, Jerry, and I (along with the TWG separately) have discussed modifying the behaviour of confirmation() to allow (optionally) waiting indefinitely without blocking. It's technically feasible to do so, but a lot of developers want to specify a timeout, usually on the order of a second or less, and such tests don't work well with Swift Concurrency or in a resource-constrained CI environment.

If we ever do end up adding such API, it would make sense to add the described compatibility modes for fulfillment(of:). However, the classic blocking wait(for:) can't be supported safely.

4 Likes

Whenever you use XCTest on Apple platforms, whether from an Xcode project or a Swift package, from an IDE or via CLI, you are using the proprietary copy of XCTest included in Xcode.

Here's the thing: the Swift evolution process only has authority over the open source Corelibs version. Formally speaking, the copy of XCTest in Xcode is entirely separate and not covered by SE. Historically, the maintainers of these codebases have sought to keep them aligned so that tests which build successfully against both will behave the same. The maintainers of the Xcode copy are free to continue that tradition and match this proposal's behavior in that version if they see fit to do so. However, the proposal cannot promise that will or will not happen, nor give a specific timeline for it.

As an Apple employee I can't comment further on the proprietary copy or its future plans, but I hope that provides some helpful context. If you have suggestions on how that "Note" callout in the proposal could be clarified I'm sure @jerryjrchen is open to suggestions.

1 Like

Hi Jerry,

In general I'm in favor of this proposal.

Couple of remarks:

  1. It took me a couple of times to read the proposal to fully understand the various cases it handles. For me this came down to two terms that were introduced that are key to understanding the behavior:
    a. lossy without interop : this is a key defining term. Which I glanced over on first reading. :face_with_peeking_eye: Maybe we can add more emphasis to it (suggestion: title the section where you define it as: lossy without interop)
    b. No-op For understanding the behavior, the tables are the obvious entry point. However, I found the 'no-op' to be unintuitive in meaning, with regards to tests anyway. Especially as there are two types: false negative (bad) and true negative (good). For instance, in the case where you use XCTAssert... in a Swift Testing test and have something that fails the assertion you state ‼️ No-op as the result. I would reword this as something like ‼️ false positive.

  2. I understand the Permissive as default interoperability mode for existing projects. However, I wouldn't mind the default for new Swift projects/packages to be strict

  3. To what extend will this interoperability place a burden on maintainers of Swift Testing in the future? I.e. will this impact adding features to Swift Testing that also need to take interop into account?

Cheers! Maarten

3 Likes

Thanks, I’ll add that as a heading.

I’m not sure if you meant to say “false negative” for the replacement text as well, as I think it would fit better here with “negative” meaning the absence of a failure condition (defn from Wikipedia):

false negative is the opposite error, where the test result incorrectly indicates the absence of a condition when it is actually present

I agree expanding on the “no-op” here would be beneficial, although I’d like to still include it to be “symmetrical” with the assertion success case. How about something like this?

When running a Swift Testing test... Current
XCTAssert failure is a ... :double_exclamation_mark: No-op (False Negative)
XCTAssert success is a ... No-op

It might be challenging to only apply the strict mode as a default to new packages because the proposal uses swift-tools-version as a primary means of determining the interop mode.

If the initial release were to default to strict, existing packages interested using permissive mode would need to set SWIFT_TESTING_XCTEST_INTEROP_MODE=permissive after bumping their tools version. I am open to ideas for making existing packages default to permissive mode and new packages default to strict mode since that appeals to me as well.

Yes, new Swift Testing features will need to take interop into account. My hope is that it will not be a large burden after the initial work is done, because:

  1. The implementation of this proposal will be generic enough that APIs requiring interop get it automatically or with little extra effort
  2. Most new Swift Testing features will not actually require interop because there will be no analogous XCTest API

@jerryjrchen Thank you for your response!

I still don't love the no-op term, but the addition of False Negative helps a lot. So for me that addition would be a valuable improvement.

I would not suggest to make strict the default for everything as that's a breaking change. So if it's not possible to distinguish between old and new packages, I agree with permissive as the best default.

Clear! :white_check_mark:

I thought on it some more, and actually now think highlighting “false negative” would be clearer :slightly_smiling_face:. That being said, I’d still like to describe what the end-user would see (or in this case, not see) with an assertion failure.

I’m thinking of flipping the order to look like this:

When running a Swift Testing test... Current
XCTAssert failure is a ... :double_exclamation_mark: False Negative (No-op)
1 Like

This looks very clear. :+1:

Overall, very happy with this pitch! Looks like a very good improvement. A couple of specific questions:

  • Is Issue.record/XCTFail mapping covered in the pitch? It reads like they should be but would be good to explicitly call this out
  • How will the strict interop mode work across modules and packages? Is it for the whole package and dependency tree or will it be enabled per module. A specific example - if I’m using SwiftSyntaxMacrosTestSupport to test macros in my package using Swift Testing (which currently doesn’t work, but should - see Add support for Swift Testing in SwiftSyntaxMacrosTestsSupport by 0xTim · Pull Request #3192 · swiftlang/swift-syntax · GitHub ) - if I enable strict interop mode will I start hitting fatal errors in libraries I don’t control because they’re calling through to XCT functions when being used from Swift Testing
2 Likes

Yes, those are both included as well. I can update the pitch to make this clearer.

I'm of the opinion that strict interop should extend to dependencies, which is in no small part because it would be easier to implement that way :slight_smile: . But also, I would argue a user that has opted into strict interop would want to see such usages of XCTest highlighted, e.g. in CI.

1 Like

I don't think you can distinguish package dependencies at runtime anyway. They all get statically linked into the same executable.