EduFlow

Section 4 · Shipping a Real App

Testing and TestFlight

16 min4 min readFill in the blanks

Lesson at a glance

Protect Pocket Budget’s logic with Swift Testing using @Test, #expect, #require and parameterized tests, then archive the app and ship a build to testers with TestFlight.

  1. 1Swift Testing in five minutes
  2. 2#require and parameterized tests
  3. 3What to test first
  4. 4Shipping a beta with TestFlight

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

You’ve built Pocket Budget over fourteen lessons. Now picture changing CategoryBudget next month to support rollover between months. How do you know the over-limit rule still works? You could tap through the app every time, or you could have the computer check it in a fraction of a second, on every change, forever. Then, once it works, you need real people using it on real phones before you trust it with an App Store launch.

Swift Testing in five minutes

Swift Testing is Apple’s testing framework, built into Xcode 16 and later. When you create a project, pick Swift Testing as its testing system and Xcode adds a test target; for an existing project, choose File › New › Target › Unit Testing Bundle and pick Swift Testing. Test the budget logic from the error-handling lesson:

PocketBudgetTests/CategoryBudgetTests.swift
import Testing
@testable import PocketBudget

struct CategoryBudgetTests {
    @Test func recordingAnExpenseIncreasesSpent() throws {
        var food = CategoryBudget(limit: 300)
        try food.record(title: "Groceries", amount: 64.2)
        #expect(food.spent == 64.2)
        #expect(food.limit - food.spent == 235.8)
    }

    @Test("Going over the limit throws and leaves the budget unchanged")
    func overLimitThrows() throws {
        var food = CategoryBudget(limit: 50)
        try food.record(title: "Lunch", amount: 12)

        #expect(throws: BudgetError.overLimit(by: 7)) {
            try food.record(title: "Dinner", amount: 45)
        }
        #expect(food.spent == 12)
    }
}

@testable import gives the tests access to your app’s internal types. A test is any function marked @Test; the optional string is a readable name for the test navigator. Grouping tests in a struct makes it a suite, and each test gets a fresh instance, so tests can’t leak state into each other.

#expect takes an ordinary Swift expression. When it fails, the report includes the actual values, for example Expectation failed: (food.spent → 12) == 57, which usually tells you what went wrong before you open the debugger. #expect(throws:) passes only if the closure throws that exact error. That’s why BudgetError conforms to Equatable back in the error-handling lesson. Run everything with Cmd-U, or click the diamond next to a single test.

#require and parameterized tests

Swift
@Test func parsesValidAmount() throws {
    let amount = try #require(parseAmount("12.50"))
    #expect(amount == 12.5)
}

@Test(arguments: ["", "abc", "0", "-3"])
func rejectsInvalidAmounts(text: String) {
    #expect(parseAmount(text) == nil)
}

#require is #expect with a stop button: if the condition fails, the test ends right there. Given an optional, it unwraps it, so the rest of the test uses a real Decimal. Use it for preconditions; use #expect for the actual claims, so one run reports every broken claim, not just the first.

@Test(arguments:) runs the same test once per input and reports each case separately. Four bad inputs, one function, and the failure tells you exactly which input broke.

What to test first

Start where bugs would hurt users and where tests are cheap: pure logic with no UI. In Pocket Budget that’s budget arithmetic, amount parsing, currency conversion and LoadState transitions. Those tests run in milliseconds and never flake. For code that needs SwiftData, create the in-memory container from the previous lesson inside the test, and mark the test @MainActor when it touches the main context. Leave pixel-level UI checks for later; previews catch most layout problems faster.

Archive in Xcode, upload to App Store Connect, wait for processing. Then internal testers get the build immediately, while external testers get it after Beta App Review. Testers install with the TestFlight app. Archive Xcode Upload Organizer Processing App Store Connect Internal testers up to 100, no review External testers up to 10,000, reviewed
From archive to testers

Shipping a beta with TestFlight

TestFlight is how you get a build onto other people’s iPhones before release. You need a paid Apple Developer Program membership and an app record in App Store Connect with the same bundle identifier as your project. Then:

  1. In the target’s General settings, set the Version (what users see, such as 1.0) and the Build number (1, 2, 3…).
  2. Choose a generic iOS device as the run destination, then Product › Archive.
  3. In the Organizer window that opens, select the archive, click Distribute App and choose the App Store Connect option. Xcode signs and uploads the build.
  4. Wait for App Store Connect to process it, usually minutes, and answer the export compliance question about encryption.
  5. Add the build to a tester group. Internal testers, up to 100 people on your App Store Connect team, can install it right away. External testers, up to 10,000 people invited by email or a public link, get it after the build passes Beta App Review.

Testers install the free TestFlight app, accept the invite and install your beta. They can send feedback with screenshots straight from the app, and you’ll find their reports, plus crash logs, in App Store Connect. Each build stays testable for 90 days.

That’s the whole loop a professional iOS developer runs every week: model the data with types, build the screens in SwiftUI, fetch and store data safely under Swift 6’s concurrency checks, prove the logic with tests and put builds in testers’ hands. From here, good next steps are adding charts of spending by category with Swift Charts, syncing with iCloud through SwiftData, and preparing your App Store listing. You have the foundations for all of them.

Key takeaways

  • Swift Testing tests are plain functions marked @Test; #expect checks a condition and reports the actual values when it fails.
  • #require stops a test early when a precondition fails, and also unwraps optionals so the rest of the test works with real values.
  • #expect(throws:) checks that code throws a specific error, and @Test(arguments:) runs one test for many inputs.
  • Test pure logic first, such as budgets, parsing and formatting, because it’s fast, stable and holds the rules users care about most.
  • TestFlight distributes builds to up to 100 internal testers without review, and up to 10,000 external testers after Beta App Review.

Further reading

Check your understanding

3 questions

Question 1 of 3

A test needs a parsed amount before it can check anything else, and parseAmount returns Decimal?. Which line fits best?

Question 2 of 3

#expect(food.spent == 57) fails. What does Swift Testing report?

Question 3 of 3

Who can install a TestFlight build without it going through Beta App Review?

Practice

Fill in the blanks

Test the amount parser

Write Swift Testing tests for parseAmount(_:), which returns a positive Decimal or nil. The first test must unwrap the parsed value and stop if it’s nil, then check it equals 12.5. The second test must run once for each invalid input.

import Testing @testable import PocketBudget struct ParsingTests { @ func parsesValidAmount() throws { let amount = try (parseAmount("12.50")) (amount == 12.5) } @Test(: ["", "abc", "0", "-3"]) func rejectsInvalidAmounts(text: String) { #expect(parseAmount(text) == nil) } }