How to Unit Test Swift CLI applications in XCode
Tags: swift, ios, testing, mobile
Published: 2023-02-07 22:32:22

If you are short of time, you can jump directly to [the solution to the problem](#solution-1) skipping the description of failed attempts to solve it. 

I have recently started solving some programming problems in Swift and wanted to have the convenience of Unit Testing my code to decrease the need for manual testing and make sure my code stays correct while I keep modifying it.

## Initial Attempt <a name="initial-attempt-1"></a>


### CLI Project Setup
Creating an iOS or Mac OS X app just to solve simple problems seemed overkill for me, so I opted for the Command Line Interface (CLI) template in Xcode 13.


![selecting CLI app project type](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6sudb4gpm349lwrf6x7d.png)


The first thing I noticed was the absence of a Test Target which you usually get for iOS/Mac applications out of the box.


![no Test Target](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/u94577ot3ps4ulu8xmm6.png)


I have crafted some code to test whether a string is a palindrome in a `main.swift` file and made sure it compiles and runs without obvious runtimes crashes.


```swift
func isPalindrome<S: StringProtocol>(_ str: S) -> Bool {
    guard nil != str.first else { return false }
    
    var headIdx = str.startIndex
    var tailIdx = str.index(before: str.endIndex)
    
    while headIdx <= tailIdx {
        if str[headIdx] != str[tailIdx] { return false }
        if headIdx == tailIdx { break }
        
        headIdx = str.index(after: headIdx);
        tailIdx = str.index(before: tailIdx);
    }
    return true;
}
```


![code runs OK](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2lsub83bolx9v8fx45fb.png)


### Adding Test Target

Now I wanted to add a Test Target so I can write unit tests to extensively test my function.


![Adding Test Target](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fbobfu7wjr115krj6ibx.png)


The first suspicious thing is that **I cannot add my CLI application as a Target to be tested**


![cannot add the CLI application as a Target to be tested](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/19d0yntcndl442sbm531.png)


Upon trying to run the test I got an error `Cannot find 'isPalindrome' in scope`  which seems correct on the surface - I didn't import my CLI application into the Test Target.


![Cannot find 'isPalindrome' in scope](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/n3jt78md1o04jnfbdcjx.png)


I did import my CLI App Target and tried again

```swift
@testable import cli_unit_tests
```


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3un1dzx7th1pzfntcy4j.png)


No luck this time either! I got a linker error which is a bad sign.


![Linker Error](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bi7f4znyzq797vc3yyov.png)


## Solution <a name="solution-1"></a>

To save you some time of interesting discoveries - **there is no direct way to Unit Test a CLI application in XCode**. At least I failed to find one.

On the surface you have just two options:

1. write your CLI application code without unit tests, which may be a terrible idea in many circumstances.
2. Avoid writing a CLI application in Swift in the first place, which may be against what you wanted to achieve.

Fortunately, there is a third option - move all the code you need to Test in a **Framework**. By doing this you achieve:
- The CLI Application can use all the code from the Framework
- You can Unit Test all the code in the Framework
- You get a good boundary separation inside your code

Below is the sequence of steps to properly add Framework (not Static Library) to a CLI application to allow Unit Testing of your code.

_You can get the source codes of the project we will be creating below on [Github](https://github.com/sokol8/CodeExamples/tree/master/cli_unit_tests)._

### Optional Preparation
If you tried to follow the steps I did in the [initial attempt](#initial-attempt-1) make sure to **Delete** the `unit_tests` Target that was created in the previous steps. Or better start a new project from scratch altogether.


![Deleting non-working Test Target](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9telphe9nr5nd98wbna2.png)


You will also need to delete the Scheme that was created by XCode for the `unit_tests` Target.


![Opening Scheme Manager](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/w24iunm9udisjsfij6mf.png)


![Deleting Test Scheme](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/o8x1vpr5axgufofs63ro.png)


For the sake of the clean experiment, I also deleted the unit tests swift file that was created by XCode when I initially added the Test Target (Please save any code you may have created there).


### Creating Framework with Tests

Following the steps below we will create a Command Line Interface (CLI) Swift application with Test Target.

#### 1)  Add a new Framework Target


![Adding Framework Target](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4931hqwc2q7u3xry98bl.png)


#### 2) Name it 
preferably name with a `Framework` suffix for enhanced readability


![Naming Framework Target](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9l7bgh7qeirzcbdhh6qy.png)


Please observe that now, we can include Unit tests in our framework


#### 3) Verify we have a Test Target
Now we got the Framework and Tests Targets. Very promising!


![verifying Test Target](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/u0kufwnr6to8zrh4vkyr.png)


#### 4) Add Tests
Now let's add back the tests of our `isPalindrome(_:)` function to `CLI_Unit_Tests_FrameworkTests.swift` file.

```swift
func testIsPalindrome() {
        
        let s0 = ""; //false
        let s1 = "a"; // true
        let s2 = "aa"; // true
        let s3 = "abba"; // true
        let s4 = "abcba"; // true
        let s5 = "abc"; // false
        let s6 = "abcd"; // false
        let s7 = "öooooö"; //true
        let s8 = "öooooø"; //false
        
        XCTAssert(isPalindrome(s0) == false, "Test of \"\(s0)\" is wrong")
        XCTAssert(isPalindrome(s1) == true, "Test of \"\(s1)\" is wrong")
        XCTAssert(isPalindrome(s2) == true, "Test of \"\(s2)\" is wrong")
        XCTAssert(isPalindrome(s3) == true, "Test of \"\(s3)\" is wrong")
        XCTAssert(isPalindrome(s4) == true, "Test of \"\(s4)\" is wrong")
        XCTAssert(isPalindrome(s5) == false, "Test of \"\(s5)\" is wrong")
        XCTAssert(isPalindrome(s6) == false, "Test of \"\(s6)\" is wrong")
        XCTAssert(isPalindrome(s7) == true, "Test of \"\(s7)\" is wrong")
        XCTAssert(isPalindrome(s8) == false, "Test of \"\(s8)\" is wrong")
    }
```

![Adding Tests Back](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/k5kqxgeu5eieo6iwzvfr.png)


#### 5) Switch Scheme to Framework
To run tests we will need to change Scheme from the CLI App Target to the Framework (our code and tests now reside in Framework as you can remember from the previous steps)


![Switching Scheme](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lna23bbmnk1mh3d7nm8q.png)


#### 6) Move code from App to Framework

`Cannot find 'isPalindrome' in scope` Compilation problem is back with us. Why? We created the Framework and Tests but forgot to move our function from `main.swift` for the framework!


![Cannot find 'isPalindrome' in scope problem](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lwknlaefitfodh3aphxh.png)


Let's add a new file and name it `utils.swift`. Please observe that we're adding the file to
- `CLI_Unit_Tests_Framework` (the Framework we just created) and
- `CLI_Unit_Tests_FrameworkTests` (Framework Test Target that XCode automatically created for us) 
Targets upon creation. 

You can always change it later in **Build Phases->Compile Sources** settings of any given Target.

Next, let's move our `isPalindrome(_:)` function to `utils.swift`.


![Creating utils.swift and adding to proper targets](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/meip7w9tqfue6lpxau1p.png)

```swift
func isPalindrome<S: StringProtocol>(_ str: S) -> Bool {
    guard nil != str.first else { return false }
    
    var headIdx = str.startIndex
    var tailIdx = str.index(before: str.endIndex)
    
    while headIdx <= tailIdx {
        if str[headIdx] != str[tailIdx] { return false }
        
        headIdx = str.index(after: headIdx);
        tailIdx = str.index(before: tailIdx);
    }
    return true;
}
```

#### 7) Tests are running now

This time our Tests ran and immediately crashed the code on a single-character string!

![Tests work](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/n1mp5gwdqhozsbump2nn.png)

Let's fix the code by adding `if headIdx == tailIdx { break }` to `isPalindrome(_:)` function and run the tests again

**(Fixed code)**
```swift
func isPalindrome<S: StringProtocol>(_ str: S) -> Bool {
    guard nil != str.first else { return false }
    
    var headIdx = str.startIndex
    var tailIdx = str.index(before: str.endIndex)
    
    while headIdx <= tailIdx {
        if str[headIdx] != str[tailIdx] { return false }
        if headIdx == tailIdx { break }
        
        headIdx = str.index(after: headIdx);
        tailIdx = str.index(before: tailIdx);
    }
    return true;
}
```

#### 7.1) Make Tests pass
This time the Function tested nicely and passed all the tests

![All Tests Pass](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/d6yqc23r7vkii8yw3sk4.png)

Hooray! We did it! **(not entirely though)**

#### 8) Switch back to App Target
Let's return to our `main.swift` and try to run the CLI application again.
Don't forget to change the Scheme back to `cli_unit_tests` application.

Now the CLI app **doesn't compile** though.


![ClI app doesn't compile](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/pvn5l8fp1k3lqjrb1vtf.png)


Why that? As you can remember we moved `isPalindrome(_:)` function **to the Framework** so we can Test it. Now we need to add the Framework to the CLI application by adding `import CLI_Unit_Tests_Framework` in `main.swift` to have access to `isPalindrome(_:)`

But the problem is still there!

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cjfnfxgd6uf3gylq4hym.png)


#### 9) Fixing Code Access
We have to do yet two more steps to make `isPalindrome(_:)` function accessible to the CLI application in `cli_unit_tests` App Target. 

#### 10) Fix Access Levels
We need to make our function [`public`](https://docs.swift.org/swift-book/LanguageGuide/AccessControl.html#ID4), otherwise it won't be accessible to `cli_unit_tests` App Target.

```swift
public func isPalindrome<S: StringProtocol>(_ str: S) -> Bool
```

This has to deal with the fact that _by default_ function has `internal` access level. In practice, that means that code **outside of the module** where the function is defined cannot see a function defined as `internal`.

As [Swift book](https://docs.swift.org/swift-book/LanguageGuide/AccessControl.html#ID5) puts this:

> Any internal implementation details of your framework can still use the default access level of internal, or can be marked as private or file private if you want to hide them from other parts of the framework’s internal code. You need to mark an entity as open or public only if you want it to become part of your framework’s API.

You may ask where did we get the modules? If you recall we moved the `isPalindrome(_:)` function to a Framework Target which is now a separate **module** from `cli_unit_tests` App Target (which is another module). 

The Project is now split into 3 modules:
- CLI App Target
- Framework Target
- Framework Test Target


_You can read more on modules in [Swift book](https://www.swift.org/package-manager/)._

##### A note on `@testable`
You may have noticed that `CLI_Unit_Tests_FrameworkTests` is a **separate Target** from `CLI_Unit_Tests_Framework` (hence a separate module) but testing the code inside Framework didn't require marking `isPalindrome(_:)` function as `public`.

If you carefully look into `CLI_Unit_Tests_FrameworkTests.swift` file (created automatically by XCode for us) you will see that the import line contains `@testable` keyword.

```swift
@testable import CLI_Unit_Tests_Framework
```

[Swift book](https://docs.swift.org/swift-book/LanguageGuide/AccessControl.html) has a succinct explanation of it

>  ...a unit test target can access any internal entity, if you mark the import declaration for a product module with the @testable attribute and compile that product module with testing enabled.

Thus our **Test Target** could access `isPalindrome(_:)` without it being public.


#### 10) Adding Dependencies (Linking Modules)

In the previous step, we fixed the Access Level of `isPalindrome(_:)` function but it won't be sufficient for the `cli_unit_tests` App Target to get access to the function.

If you wonder why that happens - recall that the `cli_unit_tests` App Target and `CLI_Unit_Tests_Framework` Framework are separate **modules** and they don't know about each other despite being inside the same project.
XCode added `CLI_Unit_Tests_Framework` as a dependency to `CLI_Unit_Tests_FrameworkTests` automatically upon creation so you didn't notice this problem in the previous steps

_You can read more about Managing dependencies in XCode in Apple's [official documentation](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app)._

To add a dependency you need to
1. _Select Project_
2. _Select App Target_
3. _Open Build Phases tab_
4. _Open Dependencies section_ and
5. press _+_ button

![Adding Framework as Dependency](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8jhdw8gjy6j8voa5b0pz.png)


Next, you need to select the `CLI_Unit_Tests_Framework` Target **as a dependency**.

![Selecting Framework as Dependency](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hi5w7gyl9vvsb1e08mro.png)

Now if you try to Build the `cli_unit_tests` App Target everything should build and run successfully.

If you peek into the Builds logs you can see how dependencies get compiled before the App target and then there's a **Linking step** right before the final App is ready for signing.


![List of Build Steps](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4fvpk9xeu49ypma9n5nc.png)

Hooray! We built and run both Tests and the App!


![CLI App target works](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6l3jcgiarc1uhp7pfa76.png)

###Summary

Creating CLI Apps in Xcode with a working Test Target requires several steps to make things work which is unfortunate. Hopefully, Apple will fix it sometime.

On the other hand, separating code into Modules provides the added benefit of having clear code boundaries and better abstractions while encouraging code reuse.