I make a point of always running the test suite (at least the fastest of the bunch: the unit tests) from the command line before pushing code to CI to confirm that all has gone smoothly.
Why is this important to do?
One of my changes could have impacted another part of the codebase, or merging someone else’s changes might have affected mine.
Additionally, since the CI machine runs the tests from the command line and not through the IDE, running the tests in the same manner locally is expected to produce the same results.
I recently helped kick off a new iOS project and taught the engineers how to run the tests from the command line.
1 $ xcodebuild -project MyProject.xcodeproj -scheme MyProject -destination "platform=iOS Simulator,OS=latest,name=iPhone 13" clean build test
Their initial response?
“Do we have to type this every time?”
1 $ npm run test
Or even a simple Gradle (Java or Kotlin) test suite:
1 $ ./gradlew clean build test
While these two examples are arguably more straightforward to execute, each build chain requires different commands.
Remembering all of these commands is like memorizing trivial facts. Of course, there’s no harm in doing it, and it could come in handy someday, but aren’t there more complicated problems that deserve our focus and brainpower?
Thankfully, one is naturally inclined to make such a complicated task simpler to perform. For example, single-stack software engineers focusing solely on mobile development might suggest a tool like Fastlane. While Fastlane is a great tool, it is specific to iOS and Android development. Other developers might recommend using a shell script which, while extremely powerful, can become more challenging to organize once you have more than a few.
An Unlikely Candidate
The idea of using a Makefile was introduced to me without much explanation by a long-time Pivot1 when I first joined Pivotal Labs for precisely these kinds of tasks. Since that time, I’ve come to understand the benefits of leveraging Makefiles are much more than just saving a few keystrokes, and I recommend utilizing them on all projects now.
Intended as a build tool for C programs, Make’s documentation states:
“You can use
makewith any programming language whose compiler can be run with a shell command.”
Let’s take a look at a sample
Makefile for an iOS application that contains a couple Make “targets”:
1 2 3 4 5 6 7 8 9 tests: xcodebuild \ -project "MyProject.xcodeproj" \ -scheme "MyProject" \ -destination "platform=iOS Simulator,OS=latest,name=iPhone 13" \ clean build test beta: ./bin/testflight-deploy.sh
A “target” is the name of an action to carry out. There are two “targets” here:
This Make target leverages the
xcodebuild command-line tool to execute the test suite associated with the provided Xcode project and scheme on an iPhone 13 Simulator device running the latest version of iOS.
This Make target can be run simply by typing:
1 $ make tests
The steps needed to deploy a beta version of the application are more complicated than a single line. Therefore, this Make target executes a shell script in the
bin folder to deploy the application to TestFlight and can be run simply by typing:
1 $ make beta
The Hidden Power of Makefiles
Great! So we’ve saved a few keystrokes by doing this. But is that all we’ve accomplished? Hardly!
In addition to saving some keystrokes, this dramatically simplifies executing complex commands. Now you only need to remember a short word or phrase instead of the entire command to run these tasks!
How do I build the code? How do I run the tests? How do I deploy the code?
Placing your most important and frequently used commands into a Makefile makes them immediately discoverable by other members of your team. Now it can all be collected into a single location, ready to be discovered by anyone, anytime!
I like to define discoverability as:
How easy or difficult it is for someone to discover and understand for themselves any aspect of your software project.
Uncle Bob calls this Clean Code as it directly relates to code, and in the case of consuming an API, many may refer to the “developer experience.” However, I like to apply this concept to all aspects of software using the term “Discoverability.”
Learning + Executable Documentation
What if this developer is entirely new to the tech stack you are using?
Referencing a Makefile, the developer not only knows what tasks are possible to run but can also understand the commands executed to perform that task. In addition, they can see how to perform these tasks and independently learn by reading the Makefile or referenced scripts.
Consistency Within Projects
Your source code repository may contain multiple applications such as a back-end and multiple front-ends (web, iOS, Android, etc.). Given each one has a Makefile, the tasks can be consistent across all platforms.
Need to run the tests on the iOS app?
1 $ cd ios && make tests
Need to run the tests for the back-end?
1 $ cd server && make tests
Need to run the tests for the web app?
1 $ cd web && make tests
Consistency Across Organizations
Are you transferring to a new project within your department?
Applying Makefiles across an entire organization or company further increases their benefits. No need to stress as you will already know how to find and execute the most common tasks!
Last and not least is automation. At the risk of belaboring an already well-supported argument, I will keep this brief:
- Automation ensures consistent execution each time
- Automating laborious tasks removes the possibility of human error
- Automation removes process deviation as a possible cause of bugs
Common Questions and Concerns
“What do I need to know to get started using a Makefile?”
There are only two things you need to know to get started using a Makefile:
#1. Indent execution lines using tabs (not spaces)
The majority of modern IDEs understand this and will take care of it for you.
1 2 3 4 beta: ./bin/testflight-deploy.sh ^^^^^^^^ ↑↑ Caution! This needs to be a single tab, not multiple spaces.
phony: for targets that match directories in the execution location
Makefile exists with a target named
test: in a location where a directory called
test also exists:
1 2 3 4 5 6 7 8 $ ls -la drwxr-xr-x@ username 704 Dec 14 18:02 . drwxr-xr-x@ username 256 Dec 14 18:02 .. -rw-r--r--@ username 425 Apr 30 2021 Makefile drwxr-xr-x@ username 96 Dec 14 18:04 test ...
Therefore, in your
Makefile, you need to indicate that the
test directory is not something that the Makefile should be concerned with by marking it as
1 2 3 4 phony: test test: npm run test
“Can I call one make task from another?”
Absolutely! Define your new target the same way and denote which other targets it should invoke.
In this following example, the
tests target sorts the Xcode project file first before executing the unit tests:
1 2 3 4 5 6 7 8 9 10 11 sort: @perl ./bin/sortXcodeProject "MyProject.xcodeproj/project.pbxproj" unit-tests: @/usr/bin/time xcodebuild \ -project "MyProject.xcodeproj" \ -scheme "MyProject" \ -destination "platform=iOS Simulator,OS=latest,name=iPhone 13" \ clean build test tests: sort unit-tests
“What if the script I want to execute is long?”
Using a backslash (“\”) character, you can break long commands across multiple lines. This limits the length of a line so the reader can easily consume the entire command without scrolling horizontally and improves the readability of the command by placing each option on a new line:
1 2 3 4 5 6 tests: xcodebuild \ -project "MyProject.xcodeproj" \ -scheme "MyProject" \ -destination "platform=iOS Simulator,OS=latest,name=iPhone 13" \ clean build test
For multi-line or complex commands, simplify a Make target by extracting its contents into a separate shell script and calling that from the Makefile:
1 2 beta: ./bin/testflight-deploy.sh
I follow a rule of thumb similar to what Uncle Bob proposes for the maximum number of arguments to a function: if a command is three lines or less, then chances are it’s OK to place directly inside the Makefile. Anything longer should be extracted to a separate script and placed in a
“How can I show or hide the command executed in the output?”
When executing a Makefile, they naturally output the commands for each task.
If you would like to hide these commands from the output, add a “@” character to the beginning of the command:
1 2 beta: @./bin/testflight-deploy.sh
“Why use Make when there are other tools available?”
I consider the primary benefits of leveraging Make to be significant:
- Make comes installed with Unix-based operating systems by default.
- Make target execution is consistent across operating systems.
- Anyone who is already comfortable executing basic shell commands on a Linux-based OS can easily use Make.
- Make doesn’t require any additional dependencies or knowledge of a specific language.
- Make it super simple to use.
“What alternatives to Make could be considered?”
A quick internet search will result in a long list of alternatives to using a Makefile.
However, I would consider that any alternative would likely:
- Require you to install a new tool.
- Require you to learn the custom format or structure that the tool utilizes.
- Create a dependency upon that tool.
- Require you to update that tool as it is maintained.
How ‘discoverable’ are the most common tasks a team member performs on your project? For example, if someone new were to join the team tomorrow, how much hand-holding would be necessary for them to get started? Conversely, how easy would it be for them to get started without any assistance from anyone else?
Leveraging Makefiles in your applications provides:
- A simple and repeatable way to automate complex tasks.
- A menu of tasks allowing team members to discover what is possible quickly.
- Documentation of these processes for team members to learn from.
- Task consistency across all platforms for a single application.
Leveraging Makefiles across your organization provides:
- Consistency across projects.
- A simple way for new members to come up to speed when joining a new project.
Introduce a Makefile to your project and take a step closer to “README as code”!