Irbisa · Шпаргалка

Тестирование в iOS

XCTest, Swift Testing, XCUITest, тестовые дублёры, async-тесты, снапшоты, покрытие, флаки

Middle-разработчик · 25 вопросов

  1. 01

    Чем отличаются unit-, интеграционные и UI-тесты, и зачем вообще писать тесты для iOS-приложения?

    Unit-тест проверяет один тип в изоляции, с подменёнными зависимостями, и выполняется за миллисекунды; интеграционный тест запускает несколько настоящих частей вместе; UI-тест прогоняет собранное приложение через экраны так, как это делает пользователь.

  2. 02

    Как выглядит хороший unit-тест, и что такое Arrange-Act-Assert?

    Хороший unit-тест проверяет одно поведение, быстрый и детерминированный, и читается тремя блоками: Arrange (готовим входные данные и подмены), Act (вызываем один тестируемый метод), Assert (проверяем наблюдаемый результат).

    @Test("a coupon below its minimum spend is ignored")func couponBelowMinimum() {  // Arrange  var cart = Cart(items: [Item(price: 20)])  let coupon = Coupon(code: "SPRING", discount: 10, minimumSpend: 50)…
  3. 03

    Что делает @testable import, и что он требует от сборки?

    @testable import MyApp позволяет тестовому таргету пользоваться internal-объявлениями модуля так, будто они public, поэтому тестировать можно, не расширяя доступ в продакшен-коде.

  4. 04

    Что делают setUp и tearDown в XCTest, и сколько экземпляров класса тест-кейса создаёт XCTest?

    XCTest создаёт отдельный экземпляр вашего подкласса XCTestCase для каждого тестового метода и вызывает setUp до и tearDown после каждого из них, поэтому свойства экземпляра всегда начинают с чистого листа.

    final class CartTests: XCTestCase {  private var defaults: UserDefaults!  private var sut: Cart!   override func setUpWithError() throws {    // A private suite, not .standard: nothing bleeds into the next test…
  5. 05

    Этот тест зелёный, хотя FailingAPI всегда возвращает ошибку. Почему ассерты никогда не падают?

    final class ProfileViewModelTests: XCTestCase {  func testLoadReportsOffline() {    let sut = ProfileViewModel(api: FailingAPI(error: .offline))     sut.load { result in      XCTAssertEqual(result, .failure(.offline))      XCTAssertEqual(sut.state, .error)    }  }} // ProfileViewModel: the completion fires after an awaitfunc load(completion: @escaping (Result<Profile, APIError>) -> Void) {  Task { completion(await api.fetch()) }}

    load вызывает completion позже, асинхронно, поэтому тестовый метод возвращается раньше, чем выполнится замыкание; в этот момент XCTest считает тест завершённым и успешным, и ассерты внутри замыкания для этого теста так и не проверяются.

  6. 06

    Чем #expect отличается от #require в Swift Testing, и что они показывают такого, чего не может XCTAssertTrue?

    #expect фиксирует падение и даёт тесту продолжиться; try #require бросает ошибку и останавливает тест: это то, что нужно для предусловия вроде разворачивания опционала.

    import Testing @Suite("Cart")struct CartTests {  @Test func total() throws {    let cart = Cart(items: [Item(price: 10), Item(price: 15)])…
  7. 07

    Как UI-тест на XCUITest взаимодействует с приложением, и чем он отличается от unit-теста?

    UI-тест работает в собственном процессе-раннере и управляет установленным приложением снаружи через слой accessibility, как пользователь; он не может вызвать ваш код или прочитать его состояние, а может только найти элементы, взаимодействовать с ними и проверить, что на экране.

    final class CheckoutUITests: XCTestCase {  func testPayShowsConfirmation() {    let app = XCUIApplication()    app.launchArguments = ["-uiTesting"]    app.launch()…
  8. 08

    Чем Swift Testing отличается от XCTest, и что при миграции должно остаться в XCTest?

    Swift Testing заменяет подклассы XCTestCase и семейство XCTAssert на функции @Test, макросы #expect и #require и трейты, а тесты по умолчанию запускает параллельно внутри одного процесса.

    // Before: XCTestfinal class CartTests: XCTestCase {  private var sut: Cart!  override func setUp() { sut = Cart(pricing: StubPricing()) }   func testDiscount() {…
  9. 09

    Сколько кейсов запускает каждый из этих параметризованных тестов, и что пойдёт не так, если списки в zip разной длины?

    enum Plan: CaseIterable { case free, pro, team } @Test(arguments: Plan.allCases, [1, 5, 50])func priceIsNeverNegative(plan: Plan, seats: Int) {  #expect(Pricing.price(plan, seats: seats) >= 0)} @Test(arguments: zip(Plan.allCases, [0, 9, 29]))func basePrice(plan: Plan, expected: Int) {  #expect(Pricing.price(plan, seats: 1) == expected)}

    Первый запускает 9 кейсов, потому что две коллекции аргументов дают все комбинации (3 × 3); второй запускает 3, потому что zip составляет пары.

  10. 10

    Что такое stub, spy, mock и fake, и как сделать их в Swift без библиотеки для моков?

    Все четыре относятся к тестовым дублёрам (test doubles) настоящей зависимости: stub возвращает заготовленные ответы, spy ещё и записывает вызовы, mock проверяет заранее заданные ожидания, а fake представляет собой работающую облегчённую реализацию.

    protocol ProfileLoading {  func profile(id: String) async throws -> Profile} // Stub + spy: returns what the test needs and records the callsfinal class ProfileLoaderSpy: ProfileLoading {…
  11. 11

    Найдите баг: этот тест падает, хотя CheckoutViewModel.confirm() действительно вызывает track("purchase").

    protocol Analytics { mutating func track(_ event: String) }struct AnalyticsSpy: Analytics {  private(set) var events: [String] = []  mutating func track(_ event: String) { events.append(event) }}final class CheckoutViewModel {  private var analytics: any Analytics  init(analytics: any Analytics) { self.analytics = analytics }  func confirm() { analytics.track("purchase") }}@Test func confirmTracksPurchase() {  let spy = AnalyticsSpy()  let sut = CheckoutViewModel(analytics: spy)  sut.confirm()  #expect(spy.events == ["purchase"])}

    AnalyticsSpy является структурой, поэтому при передаче в CheckoutViewModel она копируется: confirm() записывает "purchase" в копию внутри view model, а у spy в тесте массив events остаётся пустым.

  12. 12

    Почему wait(for:timeout:) зависает в async-тесте XCTest, и когда при async/await всё ещё нужен expectation?

    wait(for:timeout:) блокирует поток, на котором вызван, а в async-тесте этот поток принадлежит кооперативному пулу, который нужен ожидаемой работе, поэтому тест может попасть в deadlock; в async-тесте используйте await fulfillment(of:timeout:): он приостанавливается, а не блокирует.

    // Wrong: blocks the thread the callback needsfunc testRefreshes_hangs() async {  let exp = expectation(description: "refreshed")  sut.refresh { _ in exp.fulfill() }  wait(for: [exp], timeout: 1)}…
  13. 13

    Тест вызывает load() у @MainActor view model и сразу проверяет состояние, а оно всё ещё .idle. Почему, и как исправить тест?

    load() почти наверняка запускает Task и сразу возвращается, поэтому проверка выполняется раньше, чем тело задачи успело выполниться хотя бы раз.

    @MainActor @Observablefinal class ProfileViewModel {  private(set) var state: State = .idle  private(set) var loadTask: Task<Void, Never>?  private let loader: ProfileLoading…
  14. 14

    Как доказать, что событие аналитики так и не отправилось, не платя в каждом прогоне пять секунд ожидания на inverted expectation?

    Обычно ждать вообще не нужно: внедрите spy вместо приёмника аналитики, доведите код до точки, которую можно дождаться через await, и проверьте, что spy ничего не записал.

    final class AnalyticsSpy: AnalyticsTracking {  private(set) var events: [Event] = []  var onTrack: ((Event) -> Void)?   func track(_ event: Event) {    events.append(event)…
  15. 15

    Что проверяет teardown-блок в этом тесте, и почему он падает для этой view model?

    final class FeedViewModel {  private let service: FeedService  private(set) var items: [Post] = []   init(service: FeedService) {    self.service = service    service.onUpdate = { posts in self.items = posts }  }} func testFeedViewModelIsReleased() {  let sut = FeedViewModel(service: FeedService())  addTeardownBlock { [weak sut] in    XCTAssertNil(sut, "FeedViewModel leaked")  }}

    Он проверяет утечку памяти: после возврата из тестового метода на sut не должно указывать ничего, кроме слабого захвата, значит, он обязан быть nil.

  16. 16

    Как тестировать SwiftUI-view, если в unit-тесте нельзя посмотреть, что отрисовал body?

    Вынесите решения из view в модель и покройте её unit-тестами; пиксели проверяйте snapshot-тестами, а поведение через XCUITest.

    @Observablefinal class CartModel {  private(set) var items: [Item] = []  var isSubmitting = false   var canBuy: Bool { !items.isEmpty && !isSubmitting }…
  17. 17

    Что такое snapshot-тесты, и почему они так часто падают на CI с едва заметным диффом?

    Snapshot-тест отрисовывает view и сравнивает результат с эталонным изображением или текстовым дампом, закоммиченным в репозиторий.

    import SnapshotTestingimport XCTest final class ProfileHeaderSnapshotTests: XCTestCase {  // Same destination locally and on CI, e.g.  // -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.4'…
  18. 18

    Сьют XCUITest зелёный на английском и красный в день, когда пришла немецкая сборка. По чему матчились запросы, и по чему должны?

    Они матчились по label, то есть переведённому тексту, который видит пользователь.

    struct CheckoutView: View {  let total: Decimal   var body: some View {    VStack {      Text(total, format: .currency(code: "EUR"))…
  19. 19

    UI-тесту нужен залогиненный пользователь с тремя товарами в корзине. Как привести приложение в это состояние, не проходя вход нажатиями?

    Передайте сценарий при запуске, и пусть приложение соберёт себя из стабов: UI-тест работает в другом процессе, поэтому launchArguments и launchEnvironment служат основным каналом в приложение.

    // Test targetfunc testCheckoutFromSeededCart() {  let app = XCUIApplication()  app.launchArguments = ["-uiTesting"]  app.launchEnvironment = ["SCENARIO": "signed_in_cart_3"]  app.launch()…
  20. 20

    Что на самом деле измеряет покрытие кода в Xcode, и почему view model с покрытием 87% всё равно уезжает с багом?

    Оно измеряет, какие участки кода хотя бы раз выполнились во время тестов, и ничего не говорит о том, проверялось ли что-то ассертами и какие комбинации входных данных прогонялись.

  21. 21

    Что такое TDD, и где в iOS-разработке он окупается, а где нет?

    Разработка через тестирование (TDD) означает сначала падающий тест, затем минимальный код, чтобы он прошёл, затем рефакторинг при зелёных тестах: red, green, refactor.

  22. 22

    Как настроить тестовые джобы iOS-пайплайна: что запускается на каждом pull request, а что на мерж или по ночам?

    На каждом pull request запускайте быстрые детерминированные наборы (unit- и интеграционные тесты по тест-плану Smoke), а медленные (полный UI, snapshot-тесты, производительность) переносите на мерж или на ночь.

  23. 23

    Включение параллельного тестирования сократило прогон с 20 минут до 6, и четыре теста начали падать случайно. Что изменила параллельность?

    Тесты перестали владеть машиной в одиночку: XCTest распределяет тестовые классы по клонированным процессам и симуляторам, а Swift Testing ещё и выполняет тесты конкурентно внутри одного процесса.

    // Wrong: a global the tests mutate; in parallel they raceenum Session { static var current: User? } @Test func signInStoresUser() {  Session.current = User(id: "1")  #expect(Session.current?.id == "1")   // fails whenever another test wins…
  24. 24

    Один XCUITest падает примерно в одном из восьми прогонов на CI и ни разу на вашем ноутбуке. Как отличить настоящую гонку от недостающего ожидания?

    Сначала добейтесь падения по требованию, затем меняйте одну вещь: если помогло более долгое ожидание, это была синхронизация; если тест падает и с таймаутом в 30 секунд, приложение пришло в неверное состояние, и это настоящая гонка (race condition).

    final class CheckoutUITests: XCTestCase {  private var app: XCUIApplication!   override func setUp() {    continueAfterFailure = false    app = XCUIApplication()…
  25. 25

    View controller на 900 строк вызывает AnalyticsManager.shared, URLSession.shared, UserDefaults.standard и Date(). Как покрыть его тестами уже на этой неделе?

    Сделайте шов (seam) на каждом из этих четырёх вызовов и больше ничего не меняйте: целью должен стать объект, который можно создать в тесте без глобальных зависимостей, а не переписанный экран.

    // After: seams only, the singletons stay as they areprotocol Analytics { func track(_ event: Event) }extension AnalyticsManager: Analytics {} final class ReceiptPresenter {  private let session: URLSession…