XCTest.setUp()/tearDown() vs TaskLocal.withValue()

I have a test where I'm using TaskLocal.withValue() to configure reproducible testing environment.

I end up with code like this:

class MyTest: XCTestCase {
    let testEnv = TestEnvironment()
    
    func testZero() {
        Environment.$current.withValue(testEnv) {
            XCTAssertEqual(sut(value: 0), ...)
       }
    }

    func testPositive() {
        Environment.$current.withValue(testEnv) {
            XCTAssertEqual(sut(value: +1), ...)
       }
    }

   func testNegative() {
        Environment.$current.withValue(testEnv) {
            XCTAssertEqual(sut(value: +1), ...)
       }
    }

   ...
}

Which is a lot of boilerplate code. In theory, XCTestCase provides methods setUp() and tearDown() which exist exactly as a home for common preparation code before and after test.

But in this case, I cannot use them, because TaskLocal does not provide two separate methods for setup and teardown, for good reasons.

So, I think this should be addressed on the side of the XCTest. There should be a customisation point that allows extracting common setup/teardown code using single method that takes a closure as a parameter. Something like this:

class MyTest: XCTestCase {
    override func withEnvironment(_ test: () -> Void) {
        let testEnv = TestEnvironment()
        Environment.$current.withValue(testEnv) {
            super.withEnvironment(test)
        }
    }
    
    func testZero() {
        XCTAssertEqual(sut(value: 0), ...)
    }

    func testPositive() {
        XCTAssertEqual(sut(value: +1), ...)
    }

   func testNegative() {
        XCTAssertEqual(sut(value: +1), ...)
    }

   ...
}
1 Like

Hi @Nickolas_Pohilets, XCTest actually does have this tool, and it's called invokeTest. You can do:

override func invokeTest() {
  let testEnv = TestEnvironment()
  Environment.$current.withValue(testEnv) {
    super.invokeTest()
  }
}
13 Likes

invokeTest was working quite well for me, until I've tried to migrate a subclass of FBSnapshotTestCase:

old code:

override func setUp() {
    super.setUp()
    let testEnv = TestEnvironment()
    Environment.setCurrent(testEnv)
    recordMode = true
}

override func tearDown() {
    super.tearDown()
    Environment.reset()
}

new code:

override func invokeTest() {
  let testEnv = TestEnvironment()
  Environment.$current.withValue(testEnv) {
    // Triggers ObjC exception - "setRecordMode cannot be called before [super setUp]
    recordMode = true
    super.invokeTest()
  }
}

This could be solved by having both invokeTest() and setUp(), but with most of the setup living in the invokeTest(), having setUp() only as a place for recordMode = true looks ugly.

I wonder if there is method to override that wraps invocation of the test without setUp/tearDown?