- Setting up and executing automated unit tests is slightly more involved than generating built apps for distribution.
- The following is the set up that I use for some of my projects, I have just renamed the project to
TestProject
for convenience. - For this article I am assuming that you have Jenkins, Xcode, Xcode Command Line Tools, and the Xcode Jenkins plugin already installed.
- Because I use cocoaPods for dependency management, I build a workspace using custom build schemes. To build a single project with a unit-test target would make these steps easier.
Dependencies
- HomeBrew
- Used as a package manager on OSX for easy installation of dependencies.
- Install from this website: http://mxcl.github.io/homebrew/ or by copy-pasting the following into the terminal:
ruby -e "$(curl -fsSL https://raw.github.com/mxcl/homebrew/go)"
- Ruby 1.9.3
- Needed for other dependencies.
- Can be installed via brew, or with RVM.
- I used RVM to set up my ruby install.
- Sinatra
- Sinatra is a ruby based server that we use for serving JSON fixtures to the unit tests.
- Can be installed using the ruby package manager
sudo gem install sinatra
- ios-sim
- Required because Xcode doesn’t allow unit tests to be run natively in the iOS simulator from the command line.
- Can be installed using brew:
brew install ios-sim
Step 1 - Poll SCM
- The
TestProject
Jenkins job polls the SCM looking for changes to themaster
branch at midnight every night. - If no changes have occurred, then the project is not built.
- If modifications have been made, the next step is executed.
- If you want, Jenkins can be set up to build on a push to a branch. E.g. Pushing to the remote master branch.
Step 2 - Simulator and Sinatra setup
We run the following script:
#!/bin/bash
#reset the content and settings of the iphone sim
rm -r ~/Library/Application\ Support/iPhone\ Simulator/
#open the iphonesimulator and kill it
#this is required after a system restart
#so the simulator knows to run iPad rather than iPhone apps
echo "Opening iphone simulator"
open "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/Applications/iPhone Simulator.app"
sleep 10
killall 'iPhone Simulator'
echo "iphone simulator killed"
#delete previous build folders
echo "Removing previous build folder"
rm -r ${WORKSPACE}/build
mkdir ${WORKSPACE}/build
#Start sinatra server in the background
ruby TestProject/server.rb &
#get the PID of the process
PID=$!
#save PID to file
echo $PID > ${WORKSPACE}/sinatra.pid
- We first remove the iPhone Simulator folder
- This makes sure that no previous
TestProject
apps are installed on the simulator. Otherwise we may get core data upgrade problems.
- This makes sure that no previous
- We then have to open the iphone simulator and then kill it
- This is a stupid workaround that has to be done so that the iphonesimulator recognizes that we have to run an iPad application rather than an iPhone app.
- We then remove any previous build folders.
- Because we have our project set up as a workspace, there are multiple
.xcodeproj
files and libraries that we have to build, including ourpods
dependencies. Because of this, our default build location is relative to the project, not located in theiPhone Simulator
folder or in Xcode’sDerivedData
folder.
- Because we have our project set up as a workspace, there are multiple
- We then start the sinatra server in the background
- The
&
operator detaches the ruby process from the current shell so that once this script has finished, the sinatra server is still running. - We store the PID of the process to the
PID
variable. - The
$!
expands to the process ID of the most recently executed background (asynchronous) command. More details here - The PID is then written to file so it persists.
- The
Step 3 - Xcode Build
Below is a screenshot from jenkins showing the fields used for the xcode plugin
Clean before build
- we don’t want any cached compiled objects hanging around.Xcode Schema File
-TestProjectTests
- Because of a limitation where workspaces can’t build targets directly, we have to use a Build Scheme to run unit tests. This scheme is set up the run the attached unit test target included in the production scheme
TestProject
- Because of a limitation where workspaces can’t build targets directly, we have to use a Build Scheme to run unit tests. This scheme is set up the run the attached unit test target included in the production scheme
SDK
-iphonesimulator
- We are targeting the simulator to run unit tests so we specify it here.
Configuration
-Debug
- Unit tests only execute in
Debug
mode, so this option has to be this.
- Unit tests only execute in
Custom xcodebuild arguments
TEST_AFTER_BUILD
- We manually specify that we want to run unit tests after building the project.ARCHS=i386
- We have to force the architecture toi386
because xcode wants to default toarmv6
,armv7
orarmv7s
.ONLY_ACTIVE_ARCH=NO
- Tell Xcode to not build just the architectures that it wants to by default.VALID_ARCHS=i386
- We have to specify the architecture here again. Xcode does not make this easy for us.SL_RUN_UNIT_TESTS=YES
- This is where the magic happens, this will be explained in more detail in the next section.
Clean test reports?
- This outputs clean test reports so we can export them to
JUnit
reports later.
- This outputs clean test reports so we can export them to
Unlock keychain?
- Required so we don’t have to enter the password to use debugging.
Step 4 - Unit testing
As explained in the previous step, the SL_RUN_UNIT_TESTS=YES
xcodebuild argument is extremely important.
The TestProjectTests
target in Xcode has a custom script that it executes after building. The script can be found in Project Settings -> TestProjectTest -> Build Phases -> Run Script
The script is shown below:
ruby -v
ruby "${SRCROOT}/commandlineunittests.rb"
- The first line is unnecessary, and just used for outputting the ruby version.
- The second line calls a ruby script that is present in the repository that kicks off the unit tests.
The second ruby script is shown below:
if ENV['SL_RUN_UNIT_TESTS'] then
launcher_path = "/usr/local/bin/ios-sim"
#File.join(ENV['SRCROOT'], "Scripts", "ios-sim")
test_bundle_path= File.join(ENV['BUILT_PRODUCTS_DIR'], "#{ENV['PRODUCT_NAME']}.#{ENV['WRAPPER_EXTENSION']}")
environment = {
'DYLD_INSERT_LIBRARIES' => "/../../Library/PrivateFrameworks/IDEBundleInjection.framework/IDEBundleInjection",
'XCInjectBundle' => test_bundle_path,
'XCInjectBundleInto' => ENV["TEST_HOST"]
}
environment_args = environment.collect { |key, value| "--setenv #{key}=\"#{value}\""}.join(" ")
app_test_host = File.dirname(ENV["TEST_HOST"])
system("#{launcher_path} launch \"#{app_test_host}\" #{environment_args} --args -SenTest All #{test_bundle_path}")
else
puts "SL_RUN_UNIT_TESTS not set - Did not run unit tests!"
end
- The script checks for that magic variable
SL_RUN_UNIT_TESTS
and if it’s present runs the unit tests. - Using the
ios-sim
dependency, the script dynamically patches theTEST_HOST
of the ios simulator and runs the unit tests. This is really complicated to try and do by hand, which is what we were doing before usingios-sim
Step 5 - Cleanup
The following script is executing after the unit tests have finished, regardless of the output status (PASS
or FAIL
).
#!/bin/bash
PID=$(<${WORKSPACE}/sinatra.pid)
echo "Sinatra server pid $PID"
kill -9 $PID
- This script reads the process id (
PID
) from the file we stored earlier containing thesinatra
server’s PID. - We then kill the
sinatra
process.- We don’t want the
sinatra
server hanging around after the unit tests have run, because subsequent tests will fail because they will try to start asinatra
server using the same port as the previous process.
- We don’t want the