Gaining access to Command-line from XCTest
What’s this?
The command line is quite a powerful tool. It can be used to automate a bunch of things here and there. Obviously, it’s not only about Shell scripts, you can run any other tool from it as well. But using it directly is not always possible.
XCTest, for instance, works inside a sandbox, which means you literally can’t even stick your nose out of the box. Well, almost. Let’s bypass this limitation and learn how to gain access to the command line from tests.
And this is useful because…?
- This uncovers a whole new world of simulator-related features that are available through simctl and idb but not through XCTest API yet. For example:
- This opens up the possibility to create and use a standalone mock server that can completely replace the real backend for the testing purposes
- This allows executing any macOS UI actions through Applescript from your XCTests
- This gives the option to automate web-related actions via playwright or selenium instead of opening Safari on iOS
- This offers a pretty flexible way to export anything you want from the test to the host machine (screenshots, mocks, test data, etc.)
- And much much more 🤠
Cool, what’s the trick?
We need to create an HTTP server that will be listening for requests. Then we can send a request from XCTest to the server and run whatever we want. The server can even return the output back to XCTest if required.
Okay, show me how to do this!
Proof of concept
GIVEN we have a server
running on localhost:4567
AND we sent a request to /terminal
endpoint from XCTest
with the desired command
WHEN server
receives the request
AND server
executes the command
THEN server
returns the result back to XCTest
AND XCTest
verifies the result
Walkthrough
-
Choose any web framework of your choice (they are pretty much all the same for our use case)
{ "js": "express", // https://github.com/expressjs/express "rb": "sinatra", // https://github.com/sinatra/sinatra "py": "flask", // https://github.com/pallets/flask "go": "gin", // https://github.com/gin-gonic/gin "..": "..." // ... }
-
Create an HTTP server (I picked up
{ "js": "express" }
as an example)-
Command-line:
npm install express touch server.js open server.js
-
Source code:
// server.js const process = require("child_process"); const express = require("express"); const app = express(); const port = 4567; app.use(express.json()); app.listen(port); app.post("/terminal", (req, res) => { const output = exec(req.body.command, req.query.async).toString("utf8").trim(); res.send(output); }); function exec(command, async) { if (async === "true") { return process.exec(command); } else { return process.execSync(command); } }
-
-
Run the server
node server.js
-
Create the test file
import XCTest class SampleUITests: XCTestCase { let host = "http://localhost" let port: UInt16 = 4567 func testPlatformName() { let platformName = exec("sw_vers -productName") XCTAssertEqual(platformName, "macOS") } func exec(_ command: String, async: Bool = false) -> String { let urlString = "\(host):\(port)/terminal?async=\(async)" guard let url = URL(string: urlString) else { return "" } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try? JSONSerialization.data(withJSONObject: ["command": command], options: []) var output = "" if async { // Do not wait for the command to complete URLSession.shared.dataTask(with: request).resume() } else { // Wait for the command to complete let semaphore = DispatchSemaphore(value: 0) let task = URLSession.shared.dataTask(with: request) { data, response, error in if let data = data, let string = String(data: data, encoding: .utf8) { output = string semaphore.signal() } } task.resume() semaphore.wait() } return output } }
- Run the test ✅
- Adjust the scripts to your needs and enjoy hacking!
Does this work on real devices?
Emm, yeah, but not out of the box. You need to make sure that the server is accessible from device.
There are two options to achieve this:
- Connect to Wi-Fi and send requests to the server using the desktop IP address instead of
localhost
or
- Expose your local server to the internet (e.g.: via ngrok)
Alrighty, does this work on CI?
Sure thing, works like a charm!
Just make sure to run the server in the background on CI:
node server.js &
Thanks a million!
You’re more than welcome! Check out this GitHub repo for more details 🙂