XCTestCase Life Cycle Management

Paul Himes
Livefront
Published in
6 min readJun 13, 2023

for Xcode 14.3

An illustration of the life cycle of a butterfly. Along a ring of leaves is a leaf with eggs attached, a caterpillar, a chrysalis, and an adult butterfly all rendered in line art with blue hues.

When we write a unit test, we usually focus on testing the features of our code to make sure they produce the expected results. But there’s another important aspect to consider when writing a test: the test case life cycle. By correctly managing the life cycle, we can limit memory use, avoid side effects, and help our test suite run smoothly. Let’s see how.

This is our test subject, Memory Hog.

class MemoryHog {
init() {
print("Memory Hog INIT")
}

deinit {
print("Memory Hog DEINIT")
}

func add(_ l: Int, _ r: Int) -> Int {
print("Memory Hog ADD")
return l + r
}

func divide(_ l: Int, _ r: Int) -> Int {
print("Memory Hog DIVIDE")
return l / r
}

func multiply(_ l: Int, _ r: Int) -> Int {
print("Memory Hog MULTIPLY")
return l * r
}

func subtract(_ l: Int, _ r: Int) -> Int {
print("Memory Hog SUBTRACT")
return l - r
}
}

Let’s imagine that this class uses a lot of memory, perhaps it loads a large resource file. To help track our memory use, we’ve added print statements to the init, deinit, and other functions. This way, we’ll be able to see when Memory Hog consumes and releases memory during our test run.

Here’s our test class. We have one test function for each of Memory Hog’s features.

import XCTest
@testable import Example

final class ExampleTests: XCTestCase {

func testAdd() {
let hog = MemoryHog()
XCTAssertEqual(hog.add(2, 2), 4)
}

func testDivide() {
let hog = MemoryHog()
XCTAssertEqual(hog.divide(4, 2), 2)
}

func testMultiply() {
let hog = MemoryHog()
XCTAssertEqual(hog.multiply(2, 2), 4)
}

func testSubtract() {
let hog = MemoryHog()
XCTAssertEqual(hog.subtract(4, 2), 2)
}
}

Now, rather than creating a Memory Hog in each test function, it’s common practice to initialize the test subject in a single place. This is especially helpful if the subject takes multiple steps to set up. One option is to move the initialization to an instance property where it will be visible to all the test functions.

import XCTest
@testable import Example

final class ExampleTests: XCTestCase {

let hog = MemoryHog()

func testAdd() {
XCTAssertEqual(hog.add(2, 2), 4)
}

func testDivide() {
XCTAssertEqual(hog.divide(4, 2), 2)
}

func testMultiply() {
XCTAssertEqual(hog.multiply(2, 2), 4)
}

func testSubtract() {
XCTAssertEqual(hog.subtract(4, 2), 2)
}
}

But this approach has a major problem. Let’s run our tests and see what happens.

Memory Hog INIT
Memory Hog INIT
Memory Hog INIT
Memory Hog INIT
Test Suite 'All tests' started at 2023-06-07 17:15:04.266
Test Suite 'ExampleTests.xctest' started at 2023-06-07 17:15:04.266
Test Suite 'ExampleTests' started at 2023-06-07 17:15:04.266
Test Case '-[ExampleTests.ExampleTests testAdd]' started.
Memory Hog ADD
Test Case '-[ExampleTests.ExampleTests testAdd]' passed (0.001 seconds).
Test Case '-[ExampleTests.ExampleTests testDivide]' started.
Memory Hog DIVIDE
Test Case '-[ExampleTests.ExampleTests testDivide]' passed (0.000 seconds).
Test Case '-[ExampleTests.ExampleTests testMultiply]' started.
Memory Hog MULTIPLY
Test Case '-[ExampleTests.ExampleTests testMultiply]' passed (0.000 seconds).
Test Case '-[ExampleTests.ExampleTests testSubtract]' started.
Memory Hog SUBTRACT
Test Case '-[ExampleTests.ExampleTests testSubtract]' passed (0.000 seconds).
Test Suite 'ExampleTests' passed at 2023-06-07 17:15:04.269.
Executed 4 tests, with 0 failures (0 unexpected) in 0.002 (0.002) seconds
Test Suite 'ExampleTests.xctest' passed at 2023-06-07 17:15:04.274.
Executed 4 tests, with 0 failures (0 unexpected) in 0.002 (0.008) seconds
Test Suite 'All tests' passed at 2023-06-07 17:15:04.274.
Executed 4 tests, with 0 failures (0 unexpected) in 0.002 (0.008) seconds

All tests pass, which is great, but look what happened at the very beginning of the test run. Four Memory Hogs were initialized before any tests were run. So what’s going on here?

It has to do with the way Xcode initializes test cases during a test run. At the beginning of the run, Xcode initializes multiple copies of each test case class. The number of copies depends on the number of test functions in each class. Since our test case has four test functions, four copies are initialized along with their instance properties. That’s why we see four Memory Hogs.

Now imagine if our test suite contained hundreds of test cases, like you might find in a larger project. That’s potentially thousands of instance properties all using up memory at the same time before any tests have even been run. So how can we avoid this?

It turns out XCTestCase has a built-in function to handle this situation. It’s called setUp. It’s a dedicated place to initialize properties used by our test functions, and it’s called right before each test function is run.

import XCTest
@testable import Example

final class ExampleTests: XCTestCase {

var hog: MemoryHog!

override func setUp() {
hog = MemoryHog()
}

func testAdd() {
XCTAssertEqual(hog.add(2, 2), 4)
}

func testDivide() {
XCTAssertEqual(hog.divide(4, 2), 2)
}

func testMultiply() {
XCTAssertEqual(hog.multiply(2, 2), 4)
}

func testSubtract() {
XCTAssertEqual(hog.subtract(4, 2), 2)
}
}

Let’s run our tests again and see what happens.

Test Suite 'All tests' started at 2023-06-07 17:17:21.003
Test Suite 'ExampleTests.xctest' started at 2023-06-07 17:17:21.003
Test Suite 'ExampleTests' started at 2023-06-07 17:17:21.003
Test Case '-[ExampleTests.ExampleTests testAdd]' started.
Memory Hog INIT
Memory Hog ADD
Test Case '-[ExampleTests.ExampleTests testAdd]' passed (0.001 seconds).
Test Case '-[ExampleTests.ExampleTests testDivide]' started.
Memory Hog INIT
Memory Hog DIVIDE
Test Case '-[ExampleTests.ExampleTests testDivide]' passed (0.000 seconds).
Test Case '-[ExampleTests.ExampleTests testMultiply]' started.
Memory Hog INIT
Memory Hog MULTIPLY
Test Case '-[ExampleTests.ExampleTests testMultiply]' passed (0.000 seconds).
Test Case '-[ExampleTests.ExampleTests testSubtract]' started.
Memory Hog INIT
Memory Hog SUBTRACT
Test Case '-[ExampleTests.ExampleTests testSubtract]' passed (0.000 seconds).
Test Suite 'ExampleTests' passed at 2023-06-07 17:17:21.006.
Executed 4 tests, with 0 failures (0 unexpected) in 0.002 (0.003) seconds
Test Suite 'ExampleTests.xctest' passed at 2023-06-07 17:17:21.006.
Executed 4 tests, with 0 failures (0 unexpected) in 0.002 (0.003) seconds
Test Suite 'All tests' passed at 2023-06-07 17:17:21.006.
Executed 4 tests, with 0 failures (0 unexpected) in 0.002 (0.004) seconds

Great, we’ve avoided initializing all our instance properties up front. A single Memory Hog is initialized right before each test is run.

But there’s still another problem. Remember how we set up Memory Hog to print one message when it’s initialized and another message when it’s deinitialized? We can see where Memory Hogs are initialized, but where do they free their memory?

The answer is nowhere. In fact, we have nearly the same problem that we started with. Not only does Xcode create multiple copies of each test case at the beginning of the run, it also holds on to these copies until the end of the test run. As each test is run, more and more instance properties are initialized, and the memory once again grows out of control.

Lucky for us there’s another XCTestCase function to handle this situation. It’s called tearDown. It’s a dedicated place to deinitialize your instance properties, and it’s called after each test is run. In our case, deinitialization is simple. We set the instance property to nil.

import XCTest
@testable import Example

final class ExampleTests: XCTestCase {

var hog: MemoryHog!

override func setUp() {
hog = MemoryHog()
}

override func tearDown() {
hog = nil
}

func testAdd() {
XCTAssertEqual(hog.add(2, 2), 4)
}

func testDivide() {
XCTAssertEqual(hog.divide(4, 2), 2)
}

func testMultiply() {
XCTAssertEqual(hog.multiply(2, 2), 4)
}

func testSubtract() {
XCTAssertEqual(hog.subtract(4, 2), 2)
}
}

Let’s see how this affects our test run.

Test Suite 'All tests' started at 2023-06-07 17:20:15.809
Test Suite 'ExampleTests.xctest' started at 2023-06-07 17:20:15.809
Test Suite 'ExampleTests' started at 2023-06-07 17:20:15.810
Test Case '-[ExampleTests.ExampleTests testAdd]' started.
Memory Hog INIT
Memory Hog ADD
Memory Hog DEINIT
Test Case '-[ExampleTests.ExampleTests testAdd]' passed (0.001 seconds).
Test Case '-[ExampleTests.ExampleTests testDivide]' started.
Memory Hog INIT
Memory Hog DIVIDE
Memory Hog DEINIT
Test Case '-[ExampleTests.ExampleTests testDivide]' passed (0.000 seconds).
Test Case '-[ExampleTests.ExampleTests testMultiply]' started.
Memory Hog INIT
Memory Hog MULTIPLY
Memory Hog DEINIT
Test Case '-[ExampleTests.ExampleTests testMultiply]' passed (0.000 seconds).
Test Case '-[ExampleTests.ExampleTests testSubtract]' started.
Memory Hog INIT
Memory Hog SUBTRACT
Memory Hog DEINIT
Test Case '-[ExampleTests.ExampleTests testSubtract]' passed (0.000 seconds).
Test Suite 'ExampleTests' passed at 2023-06-07 17:20:15.812.
Executed 4 tests, with 0 failures (0 unexpected) in 0.002 (0.003) seconds
Test Suite 'ExampleTests.xctest' passed at 2023-06-07 17:20:15.812.
Executed 4 tests, with 0 failures (0 unexpected) in 0.002 (0.003) seconds
Test Suite 'All tests' passed at 2023-06-07 17:20:15.813.
Executed 4 tests, with 0 failures (0 unexpected) in 0.002 (0.003) seconds

Sure enough, there’s our missing deinit calls. We can now see that for each test a single Memory Hog is initialized, the test is run, and the Memory Hog is deinitialized. And we’re never using more memory than necessary for the current test.

If your test cases have instance properties, setUp and tearDown are important tools for managing their life cycles. Always initialize your properties in setUp, and remember to deinitialize them during tearDown.

Thanks for reading!

Paul works at Livefront, where the test coverage is strong, the designs are good looking, and all the developers are above average.

--

--