Test iOS CSV exports on a cloud Mac before users open them

Test iOS CSV exports on a cloud Mac before users open them

A team exports records from its iOS app to CSV and runs tests on a remote build node. The unit test reports that the file was written successfully, but when a colleague opens it, a note appears split across two lines and another user-entered value has become a spreadsheet formula. The acceptance check is not whether the Share button works. It is whether the exported bytes, field boundaries, and handling of dangerous input are correct. A cloud Mac can run these checks on every build without manual interaction.

Define the export contract first

This article uses an explicit text contract: UTF-8 output with no byte order mark; every field enclosed in double quotes, with any double quote inside a field doubled; and CRLF between records and at the end of the file. Line breaks inside fields remain unchanged. Do not split the entire CSV on line breaks and then count the records.

There is also a safety rule: if a free-form input field starts with =, +, -, or @ after leading and trailing whitespace and control characters are removed, the export fails. This rejects some negative numbers that could otherwise be ordinary text. If the product needs numeric columns, parse and validate values according to each column’s definition rather than relaxing the rule for every text column. Headers go through the same encoding function so there is only one escaping implementation.

CSV quoting establishes field boundaries; it does not control how a spreadsheet interprets formulas. Prefixing text with an apostrophe may not produce the same result in every importer. Rejecting dangerous input is easier to specify and test, but you still need to verify the behavior of the target importer.

Generate testable bytes with one function

Put the encoder in a Swift file that both the app and test target can use. The function below accepts a two-dimensional array of strings; a real app can first map its data model to columns in a specified order. trimmingCharacters is used only to detect risk. The original value is preserved in the output, so user data is not silently changed.

import Foundation

enum CSVExportError: Error {
    case unsafeValue
}

func csvField(_ value: String) throws -> String {
    let ignored = CharacterSet.whitespacesAndNewlines
        .union(.controlCharacters)
    let probe = value.trimmingCharacters(in: ignored)

    if let first = probe.first, "=+-@".contains(first) {
        throw CSVExportError.unsafeValue
    }

    let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
    return "\"" + escaped + "\""
}

func csvDocument(_ rows: [[String]]) throws -> Data {
    let lines = try rows.map { row in
        try row.map(csvField).joined(separator: ",")
    }
    return Data((lines.joined(separator: "\r\n") + "\r\n").utf8)
}

The caller should also check that each row has the same number of columns as the header, decide whether an empty dataset should produce a header-only file or a message to the user, and turn errors into visible feedback. Do not catch unsafeValue and write the unchecked original text instead. The tests might pass while the file actually shared with users bypasses the rule. If the export contains sensitive data, also verify the destination file and its access permissions before sharing it.

Test both successful output and rejection

Tests should compare the actual output, not merely assert that Data is nonempty. The first example below covers commas, quotes, Chinese characters, and a line break inside a field. The second confirms that a leading tab cannot bypass the check.

import XCTest

final class CSVExportTests: XCTestCase {
    func testQuotingAndLineEndings() throws {
        let data = try csvDocument([
            ["name", "note"],
            ["张三", "甲,\"乙\"\n丙"]
        ])

        XCTAssertEqual(
            String(decoding: data, as: UTF8.self),
            "\"name\",\"note\"\r\n\"张三\",\"甲,\"\"乙\"\"\n丙\"\r\n"
        )
    }

    func testFormulaLikeTextIsRejected() {
        for value in ["=1+1", "\t@SUM(A1:A2)", "+1", " -2"] {
            XCTAssertThrowsError(try csvDocument([[value]]))
        }
    }
}

These tests establish the agreed output format and rejection policy. They do not prove that every spreadsheet will import the file as intended. In particular, line breaks inside quoted fields, date-like text, and non-ASCII characters still need to be checked using the import method your recipients actually use.

Keep reviewable results on the build node

First confirm that the Xcode command-line tools are available on the cloud Mac, then run the tests using a scheme shared by the project. Replace the example names with the real names in your repository; you can check the available schemes with xcodebuild -list -project App.xcodeproj before running the test. The test target should access the encoder file directly rather than maintain a separate copy of its implementation.

xcodebuild test \
  -project App.xcodeproj \
  -scheme App \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -only-testing:AppTests/CSVExportTests \
  -resultBundlePath "$PWD/CSVExport.xcresult"

The simulator name must match a runtime installed on that node; check first with xcrun simctl list devices available. The CSVExport.xcresult path must not already exist when the command starts. Give each run its own directory so old results cannot interfere. This test checks pure Swift encoding and does not depend on a graphical interface. If you also need to verify the sharing flow, prepare a separate session and permission setup for UI tests.

Move from test data to a real file

Once the unit tests pass, generate a fixed sample through the app’s actual export path. Include at least an empty field, a comma, a double quote, a line break inside a field, and Chinese characters. Confirm that the file comes from the same csvDocument, rather than having the sharing layer assemble strings again. When saving to a location the app can write to, Data.write(to:options: .atomic) can prevent another process from reading a partially written file. Test how the app cleans up temporary files after a failed share, too.

Do not judge success solely by a terminal “line count”: quoted fields can contain line breaks. A more reliable check is to import the sample and compare it field by field, including column order, the final record, and error messages. If the importer automatically turns an identifier into a number or date-like text into a date, that is a separate format contract. Address it with explicit column types or a dedicated interchange format; do not assume CSV quotes will lock a cell’s type.

Pre-merge checklist

The hardest CSV export bugs to spot often hide not in a typical example, but in a note entered by a real user. Turning edge cases into repeatable tests and then checking the final file is far more reliable than inspecting a generated file by eye when something goes wrong.

Frequently asked questions

Do double quotes inside a quoted CSV field still need escaping?

Yes. Replace each internal double quote with two double quotes, then wrap the entire field in double quotes.

Does prefixing an apostrophe prevent formulas in every spreadsheet?

No. Importers differ. The example rejects suspicious text instead, and the intended spreadsheet importer still needs its own acceptance test.

UDPMac Cloud Mac

Move your next Xcode build to a dedicated physical machine.

Choose an Apple Silicon model, location, and rental term, then connect remotely to run your own build workflow. Availability is confirmed in the console when you place your order.

View Cloud Mac Plans