Are you working in a development team on an extensive and dynamic iOS project? Then Fastlane, Danger, SwiftLint and GitLab can help you to ensure quality and save time.
Introduction
Waipu.tv is an IPTV video streaming platform that has become the market leader within two years. Its vision is to continuously improve the product through constant innovation and the incorporation of customer feedback. We had the pleasure to support waipu.tv with the development of the iOS / tvOS apps and it was a lot of fun.
The implementation of the video streaming app waipu.tv for iOS (iPhones) began in autumn 2015. Over time, iPadOS (Universal App) and later tvOS (Apple TV) were added to the supported platforms.
Since the beginning of the development (then Swift 1, iOS 7 / iOS 8 – today Swift 5 – iOS 11- iOS 14) the code base has been growing and changing constantly.
This is caused by:
- New features
- Bug fixes
- Refactoring of existing code
- Updates and maintenance of connected services
- New major versions of iOS, iPadOS, tvOS
- Updates to the version of the Swift programming language
- Integration and updates of 3rd party SDKs
We develop in two-week sprints at the end of which a new version of the apps is created. In order to ensure the quality of the rapidly growing code base and to keep the number of bugs low, the development team has introduced various automated measures beyond standard Unit- and UI-Tests, which are described below.
Since every code change finds its way to the main
-branch of the git repository via a merge request, we have implemented various mechanisms at this point in order to carry out basic checks automatically.
Automatic checks in the merge request
We use GitLab on premise as version control system. It is entirely based on Git and, among many other things, offers options for code reviews. New features and bug fixes are developed on feature branches. As soon as a new feature has been developed, it is made available as a merge request for review by at least one developer.
When creating a merge request, developers should be aware that a careful review entails a time investment for the reviewer and the code should meet team standards.
The following part explains how compliance with the team conventions can be checked using automatic checks and consequently a basic quality of the merge request can be ensured. This saves the team unnecessary discussions (and time) in merge requests, e.g. about formatting or missing changelog entries, as the rules are defined before and automatically checked.
Continuous Integration with Fastlane and Gitlab CI
We use Fastlane for build, test, release and code signing. The configuration takes place in the Fastfile
, in which so-called “Lanes“ are defined.
A “Release-Lane“ could, for example, consist of the following sub-steps:
- Run UnitTests and UITests
- Make a release build of the app
- Sign the app
- Upload the app to App Store Connect
A detailed description of continuous integration with fastlane and Gitlab-CI can be found in this blog article by Sebastian.
As soon as a new merge request is created GitLab triggers a pipeline that compiles the merge request code via Fastlane and executes unit tests. The build itself takes place on a separate macOS build server on which a GitLab Runner is active. It receives and executes the build jobs. If an error occurs when compiling or executing the tests of a merge request, it cannot be merged. This ensures that only compilable, unit-tested code can be merged.
Automatic checks for the merge request are also carried out at this point. The tools used for this purpose are presented below.
SwiftLint
On the project website SwiftLint is defined as follows:
A tool to enforce Swift style and conventions, loosely based on GitHub’s Swift Style Guide.
SwiftLint hooks into Clang and SourceKit to use the AST representation of your source files for more accurate results.
SwiftLint is a widely used open source tool with a large code style rule set that checks whether these have been adhered to in the written code. There are numerous additional opt-in rules that can be activated. There is also the possibility to create your own rules (customRules) or to participate in the project itself.
The rules are configured in a .swiftlint.yml
file:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
analyzer_rules: - unused_declaration - unused_import disabled_rules: - implicit_getter opt_in_rules: - anyobject_protocol - array_init - attributes - closure_end_indentation ... - yoda_condition included: # paths to include during linting. `--path` is ignored if present. - app-ios/Sources/ - app-tvos/Sources/ - myFramework/Sources/ - myOtherFramework/Sources excluded: # paths to ignore during linting. Takes precedence over `included`. - Pods # configurable rules can be customized from this configuration file # binary rules can set their severity level force_cast: warning # implicitly force_try: severity: warning # explicitly identifier_name: allowed_symbols: "_" min_length: 1 max_length: 60 # rules that have both warning and error levels, can set just the warning level # implicitly line_length: 300 # they can set both implicitly with an array type_body_length: - 300 # warning - 400 # error # or they can set both explicitly file_length: warning: 700 error: 1200 # naming rules can set warnings/errors for min_length and max_length # additionally they can set excluded names type_name: min_length: 4 # only warning max_length: # warning and error warning: 50 error: 60 excluded: - iPhone custom_rules: disable_print: included: ".*\\.swift" name: "print usage" regex: "((\\bprint)|(Swift\\.print))\\s*\\(" message: "Prefer mylog over print" severity: warning reporter: - "xcode" # reporter type (xcode, json, csv, checkstyle, junit, html, emoji) |
If SwiftLint is introduced to an existing project, the number of errors/warnings can be very high. It makes sense to gradually make parts of the code SwiftLint-conform by configuring included/excluded directories.
Another help can be swiftlint autocorrect
(swiftlint --fix
since version 0.43), which automatically fixes easy-to-solve warnings and errors. Typically, the .swiftlint.yml
file develops in the course of the development and growing codebase of the project and “custom rules“ are introduced. To see SwiftLint warnings and errors in Xcode, it can be integrated into Xcode in the build phase settings.
Danger
Danger is a software originally written in Ruby (implementations are also available in JS, Swift, Kotlin and Python) that runs during the CI process (or standalone). Danger can carry out pre-made or self-developed (custom) code checks for automatic reviews of merge requests that are stored in the Dangerfile
and leave comments in the merge request. Typically, this Dangerfile
develops because the team keeps finding new standards and ideas for automatic checks over time, e.g. to avoid a problem that has occurred before (we often added new checks as a measure from the scrum retrospective meeting). In this way, the team norms can be stored in the Dangerfile
and the reviewer can concentrate on the “higher“ problems when reviewing the merge request.
A comprehensive list of ready-to-use danger plugins (also for many other areas of application such as Android apps) is available on GitHub.
Examples of checks in the Dangerfile
:
- danger-ruby-swiftlint: SwiftLint plugin for Danger. Checks the Swift style.
-
danger-todoist: Warns if a
TODO
is left in the merge request (this potentially indicates that the developer has forgotten something). Our team has decided not to check in anyTODO
s. -
danger-ios_logs: Warns of
NSLog
andprint
statements in the release code. - danger-xcode_summary: A Danger plugin that shows all build errors, warnings and unit tests results generated from
xcodebuild
. - danger-xcprofiler: If compilation times of each methods are exceeding the thresholds, Danger adds an inline comment to your merge request.
- Danger-Slather: A Danger plugin that shows the code coverage of a Xcode project and file by file using Slather. It can add warnings or fail the build if a minimum coverage is not achieved.
-
Custom-Check for iPhone and iPad branches in the code: If there is a switch in the code that contains various implementations for iPad and iPhone, a message is displayed that the changes should be tested on the iPad and iPhone.
-
Custom-Check for changelog modification: Checks whether the changelog file has been modified. Usually a merge request contains a feature or a bug fix for which there should also be a corresponding changelog entry. This way you can ensure a well-maintained changelog for every release.
- Custom-Check for
info.plist
modifications: If theinfo.plist
has been edited (e.g. the version number has changed) a message with the hint that furtherinfo.plist
files may have to be adapted is displayed (e.g. forNotificationExtensions
or the build scheme of the tvOS app part of the project). - Custom-Check for changes in XIB-files (Xcode Interface Builder files): If a XIB-file has been edited, a message that the developer should add a screenshot of the modified UI to the merge requests displayed. Also prevents accidentally edited XIB files from getting into the productions code.
Heres an example of a Dangerfile
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 |
require 'json' # Check if changelog was edited was_changelog_edited = git.modified_files.any? { |file| File.basename(file) == "CHANGELOG.yml" } # Get ticketnumber, mentioned in MR-Title ticketnumber = gitlab.mr_title.scan(/JIRA-[0-9]*/).first # Get Todos in code todos = todoist.todos #Swiftlint swiftlint.lint_all_files = true swiftlint.lint_files( fail_on_error: true, additional_swiftlint_args: '--strict' ) # Print todos unless not empty unless todos.empty? message = "### TODOs found\n\n" message << "|File|Line|Text|\n" message << "| --- | ----- | ----- |\n" todos.each { |todo| file_name = File.basename(todo.file) message << "|#{file_name}|#{todo.line_number}|#{todo.text}|\n" } markdown message end # Warning for usage of isPad() an isPhone() containsPadPhoneWarnings = false message = "" warnings = "" warn_pad_phone_branches = [ { word: 'isPad()', reason: 'New isPad condition – double-check on both device types.'}, { word: 'isPhone()', reason: 'New isPhone condition – double-check on both device types.'} ] active_files = (git.modified_files + git.added_files).uniq swift_files = active_files .select { |file| file.end_with?('.swift') } swift_files.each do |filename| if !File.exist?(filename) next end file = File.read(filename) lines = file.lines diffFile = git.diff_for_file(filename) diffAddedLines = diffFile.patch.scan(/^\+(?!\+|\+).*$/) diffText = diffAddedLines.join(" ") didPrintTableHeader = false lines.each_with_index do |l, index| warn_pad_phone_branches.each do |warn_line| line = lines.index line if l.include? warn_line[:word] if diffText.include? warn_line[:word] containsPadPhoneWarnings = true if !didPrintTableHeader warnings = "#### :warning: iPad/iPhone condition found\n\n" warnings << "|File|Line|Text|\n" warnings << "| --- | ----- | ----- |\n" didPrintTableHeader = true end warnings << "|#{filename}|#{index}|#{warn_line[:reason]}\n" end end end warn_compareToFalse_branches.each do |warn_line| line = lines.index line if l.include? warn_line[:word] if diffText.include? warn_line[:word] containsComparesToFalse = true if !didPrintTableHeader warnings = "#### :warning: someBool == false condition found\n\n" warnings << "|File|Line|Text|\n" warnings << "| --- | ----- | ----- |\n" didPrintTableHeader = true end warnings << "|#{filename}|#{index}|#{warn_line[:reason]}\n" end end end end end # Check if plist Files were added or changed and if so, ask double-checking the requirement for all other platforms/flavors containsInfoPlistChanges = false plist_files = (git.modified_files).uniq .select { |file| file.end_with?('.plist') } plist_files.each do |filename| if !File.exist?(filename) next end containsInfoPlistChanges = true end # Print summary # 1. Changelog updated # 2. Ticket number mentioned in MR-Title # 3. No TODOs/FIXMEs in MR # 4. Changed Info.plist message = "### Summary:\n\n" message << "|Status|Text|\n" message << "| --- | ----- |\n" message << "|#{was_changelog_edited ? ':white_check_mark:' : ':x:'}|Changelog updated \n" message << "|#{ticketnumber.nil? ? ':x:' : ':white_check_mark:'} | Ticketnumber mentioned in MR-Title \n" message << "|#{swiftlint.issues.empty? ? ':white_check_mark:' : ':x:'} | No SwiftLint warnings in MR \n" message << "|#{todos.empty? ? ':white_check_mark:' : ':x:'} | No TODOs/FIXMEs in MR \n" # Check if xib files were edited all_edited_files = git.modified_files + git.added_files was_any_xib_edited = all_edited_files.any? { |file| File.extname(file) == '.xib' } if (was_any_xib_edited && !ticketnumber.nil?) ticket_link = "[#{ticketnumber}](https://exaring.atlassian.net/browse/#{ticketnumber})" message << "|:warning: | Xib files were edited. Consider adding screenshots to #{ticket_link}\n" end if containsPadPhoneWarnings message << "|:warning:| `isPad()`/`isPhone()` conditions were added. Double-check implementation on both device types.\n" end if containsComparesToFalse message << "|:warning:| `== false` conditions were added. Double-check implementation and refactor with ! syntax if applicable.\n" end if containsInfoPlistChanges message << "|:warning:| Info.plist files have been changed. Double-check the changes to be applied to all product flavors (O2) if required.\n" end markdown message if (containsPadPhoneWarnings || containsComparesToFalse || containsInfoPlistChanges) markdown "### Warnings:\n\n" markdown warnings end # Check for print entries in code ios_logs.check |
Configuration: Bringing it all together
The configuration for performing automatic checks for a merge request described below assumes that the project has been automated with Fastlane. Fastlane is implemented in Ruby. We will use Bundler to run FastLane. Bundler is a tool that helps to provide a consistent environment for Ruby projects. Bundler reads the so-called Gemfile
in which the dependencies on Ruby gems are defined. This means that a consistent environment can be provided at any time on any system (e.g. on the build server or on the development computer) with the required gems in the appropriate versions.
Heres an example of a Gemfile
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
ruby '~> 2.6.3' source 'https://rubygems.org' gem 'cocoapods', '~> 1.9.3' gem 'fastlane', '~> 2.167.0' gem 'rubyzip' gem 'xcode-install' gem 'danger-gitlab' gem 'danger-swiftlint' gem 'danger-todoist' gem 'danger-xcode_summary' gem 'danger-ios_logs' plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') eval_gemfile(plugins_path) if File.exist?(plugins_path) |
Fastlane comes with its own action for Danger. This action is the entry point and it is executed after creating a merge request in GitLab in the „test“ stage (bundle exec fastlane run danger
). To do this, the following configuration must be added in the .gitlab-ci.yml
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
… test: stage: test except: - schedules script: - bundle exec fastlane run danger - bundle exec fastlane test tags: - *IOS - *XC only: refs: - merge_requests changes: - "**/*.swift" - "**/*.strings" - Gemfile.lock - Podfile - Podfile.lock - fastlane/* - .gitlab-ci.yml - Brewfile - install.sh - "**/*.plist" - "Dangerfile" artifacts: paths: - fastlane/test_output/ expire_in: 3 days |
After GitLab has called Danger, it reads its configuration from the Dangerfile
and carries out the appropriate checks. SwiftLint is also configured as a plug-in in the Dangerfile
. This reads its configuration from the .swiftlint.yml
file.
In action: An example merge request
The merge request on the following screenshot shows Danger and GitLab in action. Danger (and the SwiftLint-Plugin) found a problem with the number of parameters in a function. The other checks were carried out successfully:
- The changelog file was adjusted ✔️
- Ticket number is included in the title of the merge request ✔️
- No
TODO
/FIXME
were found in the code ✔️
What are the advantages?
- Direct automated feedback after creating a merge request. New team members automatically learn the norms already set by the team.
- Code conventions are checked automatically via “code“.
- Useful automated hints for the reviewer (e.g. „do an extra check on the iPad“)
- No unnecessary friction in the team due to “indentations“, „code style“, … – the machine decides – the team defines the rules beforehand in the code.
- If the team recognizes a certain error pattern, e.g. in the scrum retrospective, a corresponding check can be implemented as a measure. In this way it can be avoided that the same error occurs again and again.
- The reviewer of the merge request has more time to focus on the core of the merge request.
Multiple Devices Test
As a mobile developer, we usually test our code on a current device or simulator with the latest operating system version. As a rule, however, older devices with older operating system versions are also supported. In our project there is also the fact that parts of the code are shared with the app for the tvOS platform. This can lead to bugs or crashes if, for example, switches for the OS version are implemented incorrectly. Problems can also occur in used 3rd-party libraries that are regularly updated (and potentially contain bugs with every update).
Manual testing with a defined set of device-/OS-combinations takes a lot of time. For this reason, we have chosen the following way to automatically check the functionality with a defined list of devices – we call it the Multiple Devices Test.
GitLab offers so-called schedules (comparable to a cron job) with which tasks can be carried out periodically. We have created a schedule that carries out all Unit- and UI-Tests of the project every night on a defined list of device-/OS-combinations.
Since this test run takes more than three hours, it would be too time-consuming to execute it within each merge request. The test is carried out via Fastlane. We use the Fastlane action Scan which offers the possibility of executing tests on a list of devices. First we define the list of test devices with which the test should run in the Fastfile
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
def test_devices(platform) latest_os_version = latest_os_version_for_testing(platform) case platform when "ios" [ primary_test_device(platform), 'iPhone 5s (10.3)', 'iPhone 6 (11.4)', 'iPad Air 2 (10.3)', "iPad Pro (10.5-inch) (#{latest_os_version})", 'iPad Pro (9.7-inch) (12.4)', ] when "tvos" [ primary_test_device(platform) ] else raise "Invalid platform: #{platform}" end end |
Then we create a new lane test_nightly_multiple_devices
in the Fastfile
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 |
desc 'Runs all the tests on multiple iOS/tvOS devices' lane :test_nightly_multiple_devices do |options| prepare_test workspace = 'myproject.xcworkspace' derived_data_path = 'build' # Build once for all test devices (build_for_testing) scan( build_for_testing: true, derived_data_path: derived_data_path, scheme: 'MyProjectTests', configuration: options[:build_scheme_config], slack_only_on_failure: true ) # Now, use compiled test and run the unit tests for multiple tvOS devices scan( test_without_building: true, clean: false, derived_data_path: derived_data_path, devices: test_devices('tvos'), max_concurrent_simulators: 1, disable_concurrent_testing: true, scheme: 'MyProjectTests', slack_only_on_failure: true ) # Now, use compiled test and run the unit tests for multiple iOS devices scan( test_without_building: true, clean: false, derived_data_path: derived_data_path, devices: test_devices('ios'), max_concurrent_simulators: 1, disable_concurrent_testing: true, scheme: 'MyProjectTests', slack_only_on_failure: true ) # Now, use compiled test and run the UI Tests for multiple iOS devices scan( devices: test_devices('ios'), max_concurrent_simulators: 1, disable_concurrent_testing: true, scheme: 'MyProjectUITests', clean: false, slack_only_on_failure: true ) end |
Now we add the section test_nightly_multiple_devices
to the .gitlab-ci.yml
. This allows the multiple devices test to be started via the GitLab schedule.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
… test_nightly_multiple_devices: stage: test only: refs: - schedules variables: - $NIGHTLY_BUILD_SCHEME == "Debug" script: - bundle exec fastlane test_nightly_multiple_devices build_scheme_config:${NIGHTLY_BUILD_SCHEME} tags: - *TVOS - *XC artifacts: paths: - fastlane/test_output/ expire_in: 3 days … |
Now the corresponding schedule can then be created in Gitlab:
All tests are now regularly carried out on the defined device-/OS-combinations and, in the event of an error, a message is posted via e-mail and in the slack team channel.
Advantages of the Multiple Devices Test
- Automatic check that the code does not crash on the defined platforms – no more: “Oops, the app crashes on iOS 11 immediately after starting“
- Problems with the UI on older OS versions can be recognized by the fact that the UI-Tests are also run on these OS versions
- Any problems with 3rd-party libraries on older operating system versions can be discovered
- Shared iOS/tvOS code is being tested on both platforms
The multiple device test helped us to identify bugs and problems before the release. The implementation effort is low and gives the team an additional insurance to deliver the best possible release.
That’s it ?
Thanks for reading! Maybe the measures described will find their way into the development process of your project.
Are you looking for support in your app development process? Have a look at our offerings!