Subclassing non-open / final classes in unit tests with @testable

In Swift the canonical pattern for unit testing is dependency injection using protocols. Occasionally we may not have a protocol in front of a class or cannot introduce one for various reasons in the codebase.

I propose is that the @testable import includes the ability to subclass any non-open class or method from that imported testable module. This opens up a pattern for stubs and other enhancements and conveniences for writing unit tests.

// ModuleA
public final class SomeClassA {
    public func someMethod() { ... }
}

// ModuleB
import ModuleA

class AnotherClassB {
   init(someClass: SomeClassA) { ... }
}

// ModuleB tests
@testable import ModuleA

func test_thing_happens() {
   
   // ERROR: Cannot inherit from non-open class 'SomeClass' outside of its defining module
   private class SomeClassAStub: SomeClassA {
      func someMethod() { }
   }

   let stub = SomeClassAStub()
   let anotherClass = AnotherClassB(someClass: stub)
}

This is already the documented behavior for @testable, as far as I am aware:

When you add the @testable attribute to an import statement for a module compiled with testing enabled, you activate the elevated access for that module in that scope. Classes and class members marked as internal or public behave as if they were marked open. Other entities marked as internal act as if they were declared public.

1 Like

Sorry, updated example to include final to make it more clear

I'm not sure how that's supposed to work. You can't subclass a final class even inside of the defining module, and that limitation is "baked in" in terms of how members are dispatched, etc. You can extend the class within your tests if it's imported as @testable, but I don't understand why you'd try to test subclassing a class that can't be subclassed. The correct result of such an attempt is a compile-time failure.

4 Likes

I understand the theory of why this wouldn't make sense, but in practice this would be a real time saver. Is not weird to want to have a class final and closed in production code, but at the same time want to make a mock of it for testing and subclassing makes that easy. I have more than one class non-final just for the sake of testing.

And yes, I'm aware of the "use protocols instead" answer but I don't think that's a good answer. There is no need to introduce the complexity of protocols when you just need a single class.

Since @testable already changes the behaviour of the compiled binary (making internal things public) is there a technical roadblock for this?

At least some folks on the Swift team have said that @testable is a "hack" and a "stopgap", and it doesn't sound like it's believed that making small tweaks to its behavior would improve the testing story around Swift.

I tend to agree with that. Components should be designed with testability in mind from the outset, rather than depending on hypothetical and unsafe language hacks to break guarantees that you've explicitly asked for in your code.

As mentioned above, the final keyword allows the compiler to make certain decisions about the code it generates for the types and its methods based on the fact that you've declared it that way. That's an innate characteristic of the type, independent of how or from where it is imported. The technical roadblock is that methods simply wouldn't be dispatched correctly; when the compiler knows a class is final, it can generate method calls without using dynamic dispatch because it knows they'll never be overridden. If you tried to import such a class, subclass it, and override a method, some calls just wouldn't work.

The compiler can't guarantee correctness while also helping you avoid designing a solution to this problem. Having a protocol and a final class in your business logic module, with a test-fake implementation of the protocol in your test module, is the right way to do this. You can even put some of the implementation into the protocol so that it's shared by the real class and the fake, and you only have to override what you need.

1 Like

The other option that is also a hack is to change the class definition with an #if DEBUG check to remove the final

The thing about access control is that it is valid for a compiler flag like -enable-testing to silently treat all classes as open, etc. However it is not sound to just ignore the presence of final. The reason is that final has semantic effects.

For example, a final class can conform to a protocol with a Self requirement in an invariant position:

struct Holder<T> {}

protocol BoxMeIn {
  func putInABox() -> Holder<Self>
}

class NonFinalClass : BoxMeIn {
  func putInABox() -> Holder<NonFinalClass> {} // not valid!
  func putInABox() -> Holder<Self> {} // also not valid!
}

final class FinalClass : BoxMeIn {
  func putInABox() -> Holder<FinalClass> {} // no problem
}

If -enable-testing let you subclass FinalClass from another module, you could call putInABox() on an instance of the subclass, which would return a result of the wrong type at runtime, causing a runtime crash.

12 Likes

If protocols weren’t so awful to use in Swift, using them as you suggest would be a good option. However, as things stand, the pain is usually not worth it.

Without entering on what is right or wrong around the tradeoffs of testing I just wanted to know what technical difference was between ignoring "internal" and ignoring "final".

Thanks @Slava_Pestov for clarifying.

That makes sense ^^

1 Like

I am curious, are there cases where using a protocol has technical limitations compared to using a class? I have yet to arrive to such a situation which is why I would like to know.